Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec4f402fa | ||
|
|
628f05532b | ||
|
|
51fd59ce92 | ||
|
|
87f2926db2 | ||
|
|
2a9449bc81 | ||
|
|
60d84535c0 |
+4
-1
@@ -68,7 +68,10 @@ dmypy.json
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/*
|
||||||
|
!logs/traces/
|
||||||
|
logs/traces/*
|
||||||
|
!logs/traces/viewer.html
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
+57
-1
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.10.0] - 2025-12-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Lightweight Request Tracing
|
||||||
|
- **JSON-based tracing system** for local development debugging
|
||||||
|
- Captures full request flow through multi-agent architecture
|
||||||
|
- `Trace` and `Span` dataclasses with automatic timing and nesting
|
||||||
|
- ContextVar-based propagation for async-safe tracing
|
||||||
|
- `trace_span` async context manager for clean instrumentation
|
||||||
|
- Traces written to `logs/traces/{trace_id}.json`
|
||||||
|
- Enabled via `DEBUG=true` environment variable
|
||||||
|
- **Trace Viewer UI** (`logs/traces/viewer.html`)
|
||||||
|
- Standalone HTML viewer with timeline visualization
|
||||||
|
- Filter by status, search by request text
|
||||||
|
- Expandable span details with prompts and responses
|
||||||
|
- **Tracing REST API** (`/traces`)
|
||||||
|
- `GET /traces` - Serve trace viewer UI
|
||||||
|
- `GET /traces/list` - List available traces with filtering
|
||||||
|
- `GET /traces/{trace_id}` - Retrieve specific trace JSON
|
||||||
|
- Only available when `DEBUG=true`
|
||||||
|
- **Full pipeline instrumentation**
|
||||||
|
- Router-level trace start/end with context management
|
||||||
|
- Steward analysis spans in preprocessing
|
||||||
|
- Tatlock orchestrate/synthesize spans
|
||||||
|
- Expert delegation spans (librarian/biographer/housekeeper)
|
||||||
|
- Tool-level spans extracted from PydanticAI messages
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Replaced Redis benchmarks with file-based tracing** - Simpler, more useful for debugging
|
||||||
|
- **Context management moved to service layer** - Router simplified, context set in response service
|
||||||
|
- **Server binds to all interfaces** - `wakeup.sh` now uses `0.0.0.0` for network access
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Redis benchmark system** (`src/core/benchmarks.py`)
|
||||||
|
- `ENABLE_BENCHMARKS` config setting
|
||||||
|
- `REDIS_BENCHMARK_DB` config setting
|
||||||
|
- `redis_url` property (kept `redis_memory_url`)
|
||||||
|
- Benchmark recording in Steward service and tool tracking
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Librarian fabrication prevention** - Added explicit instructions to never invent data when tools fail or sources are unavailable
|
||||||
|
|
||||||
## [1.9.0] - 2025-12-18
|
## [1.9.0] - 2025-12-18
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -779,7 +825,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- CORS middleware
|
- CORS middleware
|
||||||
- Exception handlers (OpenAI-compatible error format)
|
- Exception handlers (OpenAI-compatible error format)
|
||||||
|
|
||||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.6.0...main
|
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.10.0...main
|
||||||
|
[1.10.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.9.0...v1.10.0
|
||||||
|
[1.9.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.6...v1.9.0
|
||||||
|
[1.8.6]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.5...v1.8.6
|
||||||
|
[1.8.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.4...v1.8.5
|
||||||
|
[1.8.4]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.3...v1.8.4
|
||||||
|
[1.8.3]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.2...v1.8.3
|
||||||
|
[1.8.2]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.1...v1.8.2
|
||||||
|
[1.8.1]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.0...v1.8.1
|
||||||
|
[1.8.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.7.0...v1.8.0
|
||||||
|
[1.7.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.6.0...v1.7.0
|
||||||
[1.6.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.5.0...v1.6.0
|
[1.6.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.5.0...v1.6.0
|
||||||
[1.5.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...v1.5.0
|
[1.5.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.4.0...v1.5.0
|
||||||
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
|
[1.4.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.3.3...v1.4.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "1.9.0"
|
version = "1.10.0"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
+145
-84
@@ -13,6 +13,7 @@ from enum import Enum
|
|||||||
from typing import AsyncGenerator, Callable, Optional, Any
|
from typing import AsyncGenerator, Callable, Optional, Any
|
||||||
|
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.tracing import trace_span, SpanType
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -239,38 +240,58 @@ async def delegate_to_librarian(
|
|||||||
has_context=bool(context),
|
has_context=bool(context),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
async with trace_span(
|
||||||
# Use run() not run_stream() - avoids Ollama bug
|
"delegate_to_librarian",
|
||||||
output = await run_librarian(task=task, context=context)
|
SpanType.EXPERT,
|
||||||
|
metadata={
|
||||||
|
"expert": "librarian",
|
||||||
|
"task_preview": task[:100],
|
||||||
|
"has_context": bool(context),
|
||||||
|
},
|
||||||
|
) as span:
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_librarian(task=task, context=context)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"delegation_to_librarian_completed",
|
"delegation_to_librarian_completed",
|
||||||
task=task[:50],
|
task=task[:50],
|
||||||
output_length=len(output),
|
output_length=len(output),
|
||||||
)
|
)
|
||||||
|
|
||||||
return DelegationResult(
|
if span:
|
||||||
expert_name="librarian",
|
span.metadata["success"] = True
|
||||||
task=task,
|
span.metadata["output_length"] = len(output)
|
||||||
success=True,
|
span.details["task"] = task
|
||||||
output=output,
|
span.details["context"] = context[:500] if context else None
|
||||||
)
|
span.details["result_preview"] = output[:1000]
|
||||||
|
|
||||||
except Exception as e:
|
return DelegationResult(
|
||||||
logger.error(
|
expert_name="librarian",
|
||||||
"delegation_to_librarian_error",
|
task=task,
|
||||||
task=task[:50],
|
success=True,
|
||||||
error=str(e),
|
output=output,
|
||||||
exc_info=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return DelegationResult(
|
except Exception as e:
|
||||||
expert_name="librarian",
|
logger.error(
|
||||||
task=task,
|
"delegation_to_librarian_error",
|
||||||
success=False,
|
task=task[:50],
|
||||||
output="",
|
error=str(e),
|
||||||
error=str(e),
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if span:
|
||||||
|
span.metadata["success"] = False
|
||||||
|
span.details["error"] = str(e)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="librarian",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delegate_to_biographer(
|
async def delegate_to_biographer(
|
||||||
@@ -317,38 +338,58 @@ async def delegate_to_biographer(
|
|||||||
has_context=bool(context),
|
has_context=bool(context),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
async with trace_span(
|
||||||
# Use run() not run_stream() - avoids Ollama bug
|
"delegate_to_biographer",
|
||||||
output = await run_biographer(task=task, context=context)
|
SpanType.EXPERT,
|
||||||
|
metadata={
|
||||||
|
"expert": "biographer",
|
||||||
|
"task_preview": task[:100],
|
||||||
|
"has_context": bool(context),
|
||||||
|
},
|
||||||
|
) as span:
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_biographer(task=task, context=context)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"delegation_to_biographer_completed",
|
"delegation_to_biographer_completed",
|
||||||
task=task[:50],
|
task=task[:50],
|
||||||
output_length=len(output),
|
output_length=len(output),
|
||||||
)
|
)
|
||||||
|
|
||||||
return DelegationResult(
|
if span:
|
||||||
expert_name="biographer",
|
span.metadata["success"] = True
|
||||||
task=task,
|
span.metadata["output_length"] = len(output)
|
||||||
success=True,
|
span.details["task"] = task
|
||||||
output=output,
|
span.details["context"] = context[:500] if context else None
|
||||||
)
|
span.details["result_preview"] = output[:1000]
|
||||||
|
|
||||||
except Exception as e:
|
return DelegationResult(
|
||||||
logger.error(
|
expert_name="biographer",
|
||||||
"delegation_to_biographer_error",
|
task=task,
|
||||||
task=task[:50],
|
success=True,
|
||||||
error=str(e),
|
output=output,
|
||||||
exc_info=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return DelegationResult(
|
except Exception as e:
|
||||||
expert_name="biographer",
|
logger.error(
|
||||||
task=task,
|
"delegation_to_biographer_error",
|
||||||
success=False,
|
task=task[:50],
|
||||||
output="",
|
error=str(e),
|
||||||
error=str(e),
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if span:
|
||||||
|
span.metadata["success"] = False
|
||||||
|
span.details["error"] = str(e)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="biographer",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delegate_to_housekeeper(
|
async def delegate_to_housekeeper(
|
||||||
@@ -394,38 +435,58 @@ async def delegate_to_housekeeper(
|
|||||||
has_context=bool(context),
|
has_context=bool(context),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
async with trace_span(
|
||||||
# Use run() not run_stream() - avoids Ollama bug
|
"delegate_to_housekeeper",
|
||||||
output = await run_housekeeper(task=task, context=context)
|
SpanType.EXPERT,
|
||||||
|
metadata={
|
||||||
|
"expert": "housekeeper",
|
||||||
|
"task_preview": task[:100],
|
||||||
|
"has_context": bool(context),
|
||||||
|
},
|
||||||
|
) as span:
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - avoids Ollama bug
|
||||||
|
output = await run_housekeeper(task=task, context=context)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"delegation_to_housekeeper_completed",
|
"delegation_to_housekeeper_completed",
|
||||||
task=task[:50],
|
task=task[:50],
|
||||||
output_length=len(output),
|
output_length=len(output),
|
||||||
)
|
)
|
||||||
|
|
||||||
return DelegationResult(
|
if span:
|
||||||
expert_name="housekeeper",
|
span.metadata["success"] = True
|
||||||
task=task,
|
span.metadata["output_length"] = len(output)
|
||||||
success=True,
|
span.details["task"] = task
|
||||||
output=output,
|
span.details["context"] = context[:500] if context else None
|
||||||
)
|
span.details["result_preview"] = output[:1000]
|
||||||
|
|
||||||
except Exception as e:
|
return DelegationResult(
|
||||||
logger.error(
|
expert_name="housekeeper",
|
||||||
"delegation_to_housekeeper_error",
|
task=task,
|
||||||
task=task[:50],
|
success=True,
|
||||||
error=str(e),
|
output=output,
|
||||||
exc_info=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return DelegationResult(
|
except Exception as e:
|
||||||
expert_name="housekeeper",
|
logger.error(
|
||||||
task=task,
|
"delegation_to_housekeeper_error",
|
||||||
success=False,
|
task=task[:50],
|
||||||
output="",
|
error=str(e),
|
||||||
error=str(e),
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if span:
|
||||||
|
span.metadata["success"] = False
|
||||||
|
span.details["error"] = str(e)
|
||||||
|
|
||||||
|
return DelegationResult(
|
||||||
|
expert_name="housekeeper",
|
||||||
|
task=task,
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ Your responses are returned to Tatlock (the butler) who will synthesize them int
|
|||||||
- Note any gaps in available information
|
- Note any gaps in available information
|
||||||
- Be concise but thorough - Tatlock will format the final response
|
- Be concise but thorough - Tatlock will format the final response
|
||||||
- Structure your findings clearly so they can be easily integrated with other responses
|
- Structure your findings clearly so they can be easily integrated with other responses
|
||||||
|
|
||||||
|
## CRITICAL: Never Fabricate Information
|
||||||
|
If a tool fails or you cannot access a data source:
|
||||||
|
- Say "I was unable to retrieve [information type]" - be specific about what failed
|
||||||
|
- Do NOT provide placeholder, template, or made-up data
|
||||||
|
- Do NOT say "Here's what I would have said" or "Here's a sample response"
|
||||||
|
- Do NOT invent specific numbers, dates, or facts when the actual data is unavailable
|
||||||
|
- It is better to return no information than to return fabricated information
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Lazy initialization to avoid connection issues during imports
|
# Lazy initialization to avoid connection issues during imports
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Steward service layer.
|
Steward service layer.
|
||||||
|
|
||||||
Provides high-level interface for request analysis with logging,
|
Provides high-level interface for request analysis with logging
|
||||||
benchmarking, and error handling.
|
and error handling.
|
||||||
|
|
||||||
Parses plain text recommendations into structured data.
|
Parses plain text recommendations into structured data.
|
||||||
Includes memory pre-fetch for user context injection.
|
Includes memory pre-fetch for user context injection.
|
||||||
@@ -10,7 +10,6 @@ Includes memory pre-fetch for user context injection.
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger, log_operation
|
from src.core.logging_config import get_logger, log_operation
|
||||||
from src.core.memory_service import memory_service
|
from src.core.memory_service import memory_service
|
||||||
@@ -285,8 +284,7 @@ async def analyze_request(
|
|||||||
This is the main entry point for Steward analysis. It:
|
This is the main entry point for Steward analysis. It:
|
||||||
1. Calls the Steward agent with full conversation history
|
1. Calls the Steward agent with full conversation history
|
||||||
2. Logs the operation with timing
|
2. Logs the operation with timing
|
||||||
3. Records performance benchmarks to Redis
|
3. Returns structured recommendations
|
||||||
4. Returns structured recommendations
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_request: The current user message to analyze
|
user_request: The current user message to analyze
|
||||||
@@ -365,23 +363,6 @@ async def analyze_request(
|
|||||||
reasoning=analysis_text[:200], # First 200 chars
|
reasoning=analysis_text[:200], # First 200 chars
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record performance benchmark
|
|
||||||
if log_ctx.get("duration_seconds"):
|
|
||||||
benchmark = PerformanceBenchmark(
|
|
||||||
operation="steward_analysis",
|
|
||||||
duration_seconds=log_ctx["duration_seconds"],
|
|
||||||
success=True,
|
|
||||||
recommendation_count=len(recommendation.recommended_capabilities),
|
|
||||||
confidence=None, # Could add confidence scoring in future
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
metadata={
|
|
||||||
"complexity": recommendation.estimated_complexity,
|
|
||||||
"has_context": recommendation.conversation_context.has_previous_context,
|
|
||||||
"missing_capabilities": recommendation.missing_capabilities is not None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await get_benchmark_store().record(benchmark)
|
|
||||||
|
|
||||||
return recommendation
|
return recommendation
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+56
-2
@@ -20,6 +20,11 @@ from src.agents.tatlock_core.tools import (
|
|||||||
)
|
)
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.tracing import (
|
||||||
|
start_span, end_span, get_current_span,
|
||||||
|
add_tool_spans_from_messages,
|
||||||
|
SpanType, SpanStatus,
|
||||||
|
)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -433,7 +438,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
steward_note: Note from Steward (prepended to request, invisible to user)
|
steward_note: Note from Steward (prepended to request, invisible to user)
|
||||||
scoped_tools: List of tool definitions from household registry
|
scoped_tools: List of tool definitions from household registry
|
||||||
message_history: Conversation history in PydanticAI format
|
message_history: Conversation history in PydanticAI format
|
||||||
tool_tracker: Optional tool call tracker for benchmarking
|
tool_tracker: Optional tool call tracker for analysis
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Tatlock's response text
|
str: Tatlock's response text
|
||||||
@@ -627,7 +632,7 @@ class TatlockAgent(AgentInterface):
|
|||||||
steward_note: Note from Steward (invisible to user)
|
steward_note: Note from Steward (invisible to user)
|
||||||
scoped_tools: List of tool definitions from household registry
|
scoped_tools: List of tool definitions from household registry
|
||||||
message_history: Conversation history
|
message_history: Conversation history
|
||||||
tool_tracker: Optional tool call tracker for benchmarking
|
tool_tracker: Optional tool call tracker for analysis
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with:
|
dict with:
|
||||||
@@ -655,6 +660,16 @@ class TatlockAgent(AgentInterface):
|
|||||||
history_length=len(message_history),
|
history_length=len(message_history),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start tracing span for orchestration phase
|
||||||
|
orchestrate_span = start_span(
|
||||||
|
"tatlock_orchestrate",
|
||||||
|
SpanType.TATLOCK,
|
||||||
|
metadata={
|
||||||
|
"scoped_tool_count": len(scoped_tools),
|
||||||
|
"tool_names": [getattr(t, '__name__', str(t)) for t in scoped_tools[:5]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Create a fresh agent instance with scoped tools only
|
# Create a fresh agent instance with scoped tools only
|
||||||
clean_host = self.ollama_host.rstrip('/')
|
clean_host = self.ollama_host.rstrip('/')
|
||||||
base_url = f"{clean_host}/v1"
|
base_url = f"{clean_host}/v1"
|
||||||
@@ -731,6 +746,23 @@ class TatlockAgent(AgentInterface):
|
|||||||
tool_output_count=len(tool_outputs),
|
tool_output_count=len(tool_outputs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add tool-level spans from result messages
|
||||||
|
if orchestrate_span:
|
||||||
|
add_tool_spans_from_messages(result.new_messages(), orchestrate_span)
|
||||||
|
|
||||||
|
# End orchestration span with results
|
||||||
|
end_span(
|
||||||
|
orchestrate_span,
|
||||||
|
metadata_update={
|
||||||
|
"tools_called": tools_called,
|
||||||
|
"expert_count": len(expert_results),
|
||||||
|
"tool_output_count": len(tool_outputs),
|
||||||
|
},
|
||||||
|
details_update={
|
||||||
|
"steward_note_preview": steward_note[:500] if steward_note else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tools_called": tools_called,
|
"tools_called": tools_called,
|
||||||
"expert_results": expert_results,
|
"expert_results": expert_results,
|
||||||
@@ -769,6 +801,16 @@ class TatlockAgent(AgentInterface):
|
|||||||
tool_count=len(orchestration_results.get("tool_outputs", {})),
|
tool_count=len(orchestration_results.get("tool_outputs", {})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start tracing span for synthesis phase
|
||||||
|
synthesize_span = start_span(
|
||||||
|
"tatlock_synthesize",
|
||||||
|
SpanType.TATLOCK,
|
||||||
|
metadata={
|
||||||
|
"expert_count": len(orchestration_results.get("expert_results", {})),
|
||||||
|
"tool_output_count": len(orchestration_results.get("tool_outputs", {})),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Build synthesis prompt with all available information
|
# Build synthesis prompt with all available information
|
||||||
synthesis_parts = []
|
synthesis_parts = []
|
||||||
synthesis_parts.append(f"The user asked: {user_message}")
|
synthesis_parts.append(f"The user asked: {user_message}")
|
||||||
@@ -841,6 +883,18 @@ class TatlockAgent(AgentInterface):
|
|||||||
response_preview=result.output[:100],
|
response_preview=result.output[:100],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# End synthesis span with result
|
||||||
|
end_span(
|
||||||
|
synthesize_span,
|
||||||
|
metadata_update={
|
||||||
|
"response_length": len(result.output),
|
||||||
|
},
|
||||||
|
details_update={
|
||||||
|
"synthesis_prompt": synthesis_prompt[:1000],
|
||||||
|
"response_preview": result.output[:500],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return result.output
|
return result.output
|
||||||
|
|
||||||
async def get_capabilities(self) -> dict:
|
async def get_capabilities(self) -> dict:
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
"""
|
|
||||||
Performance benchmark storage using Redis.
|
|
||||||
|
|
||||||
Tracks operation timing, tool usage, and recommendation accuracy across sessions.
|
|
||||||
Provides time-series data for performance analysis and optimization.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
import redis.asyncio as redis
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from .config import config
|
|
||||||
from .logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PerformanceBenchmark(BaseModel):
|
|
||||||
"""
|
|
||||||
Performance benchmark record.
|
|
||||||
|
|
||||||
Stores timing and metadata for operations like Steward analysis,
|
|
||||||
tool calls, and agent execution.
|
|
||||||
"""
|
|
||||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
||||||
operation: str # "steward_analysis", "tool_call", "tatlock_execution"
|
|
||||||
duration_seconds: float
|
|
||||||
success: bool
|
|
||||||
|
|
||||||
# Steward-specific fields
|
|
||||||
recommendation_count: Optional[int] = None
|
|
||||||
confidence: Optional[float] = None
|
|
||||||
|
|
||||||
# Tool-specific fields
|
|
||||||
tool_name: Optional[str] = None
|
|
||||||
was_recommended: Optional[bool] = None
|
|
||||||
was_actually_used: Optional[bool] = None
|
|
||||||
|
|
||||||
# Context
|
|
||||||
conversation_id: Optional[str] = None
|
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_redis_dict(self) -> dict[str, Any]:
|
|
||||||
"""Convert to dict suitable for Redis storage."""
|
|
||||||
data = self.model_dump()
|
|
||||||
data["timestamp"] = self.timestamp.isoformat()
|
|
||||||
data["metadata"] = json.dumps(self.metadata)
|
|
||||||
# Convert booleans to strings (Redis doesn't accept bool type)
|
|
||||||
for key, value in data.items():
|
|
||||||
if isinstance(value, bool):
|
|
||||||
data[key] = str(value)
|
|
||||||
return data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_redis_dict(cls, data: dict[str, Any]) -> "PerformanceBenchmark":
|
|
||||||
"""Reconstruct from Redis dict."""
|
|
||||||
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
|
||||||
data["metadata"] = json.loads(data.get("metadata", "{}"))
|
|
||||||
# Convert string booleans back to bool
|
|
||||||
for key in ["success", "was_recommended", "was_actually_used"]:
|
|
||||||
if key in data and isinstance(data[key], str):
|
|
||||||
data[key] = data[key] == "True"
|
|
||||||
return cls(**data)
|
|
||||||
|
|
||||||
|
|
||||||
class BenchmarkStore:
|
|
||||||
"""
|
|
||||||
Redis-backed benchmark storage with automatic expiry.
|
|
||||||
|
|
||||||
Stores performance metrics in time-series format with 30-day retention.
|
|
||||||
Provides querying capabilities for analysis and reporting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_client: Optional[redis.Redis] = None):
|
|
||||||
"""
|
|
||||||
Initialize benchmark store.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
redis_client: Optional Redis client. If None, creates from config.
|
|
||||||
"""
|
|
||||||
self._client = redis_client
|
|
||||||
self._ttl_days = 30 # 30-day retention
|
|
||||||
|
|
||||||
async def _get_client(self) -> redis.Redis:
|
|
||||||
"""Get or create Redis client."""
|
|
||||||
if self._client is None:
|
|
||||||
self._client = redis.from_url(
|
|
||||||
config.redis_url,
|
|
||||||
encoding="utf-8",
|
|
||||||
decode_responses=True,
|
|
||||||
socket_timeout=config.REDIS_TIMEOUT,
|
|
||||||
socket_connect_timeout=config.REDIS_TIMEOUT,
|
|
||||||
)
|
|
||||||
return self._client
|
|
||||||
|
|
||||||
async def record(self, benchmark: PerformanceBenchmark) -> None:
|
|
||||||
"""
|
|
||||||
Record a performance benchmark.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
benchmark: Performance benchmark to record
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> await store.record(PerformanceBenchmark(
|
|
||||||
... operation="steward_analysis",
|
|
||||||
... duration_seconds=1.23,
|
|
||||||
... success=True,
|
|
||||||
... recommendation_count=3,
|
|
||||||
... ))
|
|
||||||
"""
|
|
||||||
if not config.ENABLE_BENCHMARKS:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = await self._get_client()
|
|
||||||
|
|
||||||
# Generate key: benchmark:{operation}:{timestamp_ms}
|
|
||||||
timestamp_ms = int(benchmark.timestamp.timestamp() * 1000)
|
|
||||||
key = f"benchmark:{benchmark.operation}:{timestamp_ms}"
|
|
||||||
|
|
||||||
# Store as hash
|
|
||||||
await client.hset(key, mapping=benchmark.to_redis_dict())
|
|
||||||
|
|
||||||
# Set expiry
|
|
||||||
await client.expire(key, self._ttl_days * 24 * 60 * 60)
|
|
||||||
|
|
||||||
# Add to sorted set for time-based queries
|
|
||||||
index_key = f"benchmark_index:{benchmark.operation}"
|
|
||||||
await client.zadd(index_key, {key: timestamp_ms})
|
|
||||||
await client.expire(index_key, self._ttl_days * 24 * 60 * 60)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"benchmark_recorded",
|
|
||||||
operation=benchmark.operation,
|
|
||||||
duration=benchmark.duration_seconds,
|
|
||||||
success=benchmark.success,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"benchmark_recording_failed",
|
|
||||||
error=str(e),
|
|
||||||
operation=benchmark.operation,
|
|
||||||
)
|
|
||||||
# Don't fail the request if benchmarking fails
|
|
||||||
|
|
||||||
async def query(
|
|
||||||
self,
|
|
||||||
operation: str,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[PerformanceBenchmark]:
|
|
||||||
"""
|
|
||||||
Query benchmarks by operation and time range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation: Operation name to filter by
|
|
||||||
start_time: Start of time range (inclusive)
|
|
||||||
end_time: End of time range (inclusive)
|
|
||||||
limit: Maximum number of results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of benchmarks matching the query
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> from datetime import timedelta
|
|
||||||
>>> now = datetime.now(timezone.utc)
|
|
||||||
>>> yesterday = now - timedelta(days=1)
|
|
||||||
>>> benchmarks = await store.query(
|
|
||||||
... "steward_analysis",
|
|
||||||
... start_time=yesterday,
|
|
||||||
... limit=50
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
if not config.ENABLE_BENCHMARKS:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = await self._get_client()
|
|
||||||
index_key = f"benchmark_index:{operation}"
|
|
||||||
|
|
||||||
# Convert time range to timestamps
|
|
||||||
min_score = (
|
|
||||||
int(start_time.timestamp() * 1000)
|
|
||||||
if start_time
|
|
||||||
else "-inf"
|
|
||||||
)
|
|
||||||
max_score = (
|
|
||||||
int(end_time.timestamp() * 1000)
|
|
||||||
if end_time
|
|
||||||
else "+inf"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Query sorted set
|
|
||||||
keys = await client.zrevrangebyscore(
|
|
||||||
index_key,
|
|
||||||
max_score,
|
|
||||||
min_score,
|
|
||||||
start=0,
|
|
||||||
num=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch benchmark data
|
|
||||||
benchmarks = []
|
|
||||||
for key in keys:
|
|
||||||
data = await client.hgetall(key)
|
|
||||||
if data:
|
|
||||||
benchmarks.append(PerformanceBenchmark.from_redis_dict(data))
|
|
||||||
|
|
||||||
return benchmarks
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"benchmark_query_failed",
|
|
||||||
error=str(e),
|
|
||||||
operation=operation,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_statistics(
|
|
||||||
self,
|
|
||||||
operation: str,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get aggregate statistics for an operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation: Operation name
|
|
||||||
start_time: Start of time range
|
|
||||||
end_time: End of time range
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with statistics (count, avg_duration, success_rate, etc.)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> stats = await store.get_statistics("steward_analysis")
|
|
||||||
>>> print(f"Average duration: {stats['avg_duration']}s")
|
|
||||||
>>> print(f"Success rate: {stats['success_rate']}%")
|
|
||||||
"""
|
|
||||||
benchmarks = await self.query(operation, start_time, end_time, limit=1000)
|
|
||||||
|
|
||||||
if not benchmarks:
|
|
||||||
return {
|
|
||||||
"count": 0,
|
|
||||||
"avg_duration": 0.0,
|
|
||||||
"min_duration": 0.0,
|
|
||||||
"max_duration": 0.0,
|
|
||||||
"success_rate": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
durations = [b.duration_seconds for b in benchmarks]
|
|
||||||
successes = sum(1 for b in benchmarks if b.success)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"count": len(benchmarks),
|
|
||||||
"avg_duration": sum(durations) / len(durations),
|
|
||||||
"min_duration": min(durations),
|
|
||||||
"max_duration": max(durations),
|
|
||||||
"success_rate": (successes / len(benchmarks)) * 100,
|
|
||||||
"total_successes": successes,
|
|
||||||
"total_failures": len(benchmarks) - successes,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_tool_accuracy(
|
|
||||||
self,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Analyze tool recommendation accuracy.
|
|
||||||
|
|
||||||
Compares recommended tools vs actually used tools to measure
|
|
||||||
Steward's recommendation precision.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_time: Start of time range
|
|
||||||
end_time: End of time range
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with accuracy metrics
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> accuracy = await store.get_tool_accuracy()
|
|
||||||
>>> print(f"Precision: {accuracy['precision']}%")
|
|
||||||
"""
|
|
||||||
tool_calls = await self.query("tool_call", start_time, end_time, limit=1000)
|
|
||||||
|
|
||||||
if not tool_calls:
|
|
||||||
return {
|
|
||||||
"total_calls": 0,
|
|
||||||
"recommended_and_used": 0,
|
|
||||||
"recommended_not_used": 0,
|
|
||||||
"not_recommended_but_used": 0,
|
|
||||||
"precision": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
recommended_and_used = sum(
|
|
||||||
1 for b in tool_calls
|
|
||||||
if b.was_recommended and b.was_actually_used
|
|
||||||
)
|
|
||||||
not_recommended_but_used = sum(
|
|
||||||
1 for b in tool_calls
|
|
||||||
if not b.was_recommended and b.was_actually_used
|
|
||||||
)
|
|
||||||
|
|
||||||
total_used = sum(1 for b in tool_calls if b.was_actually_used)
|
|
||||||
precision = (
|
|
||||||
(recommended_and_used / total_used * 100) if total_used > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_calls": len(tool_calls),
|
|
||||||
"total_used": total_used,
|
|
||||||
"recommended_and_used": recommended_and_used,
|
|
||||||
"not_recommended_but_used": not_recommended_but_used,
|
|
||||||
"precision": precision,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close Redis connection."""
|
|
||||||
if self._client:
|
|
||||||
await self._client.aclose()
|
|
||||||
self._client = None
|
|
||||||
|
|
||||||
|
|
||||||
# Global benchmark store instance
|
|
||||||
_benchmark_store: Optional[BenchmarkStore] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_benchmark_store() -> BenchmarkStore:
|
|
||||||
"""
|
|
||||||
Get global benchmark store instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BenchmarkStore instance
|
|
||||||
"""
|
|
||||||
global _benchmark_store
|
|
||||||
if _benchmark_store is None:
|
|
||||||
_benchmark_store = BenchmarkStore()
|
|
||||||
return _benchmark_store
|
|
||||||
+1
-11
@@ -101,10 +101,6 @@ class Config(BaseSettings):
|
|||||||
default=6379,
|
default=6379,
|
||||||
description="Redis server port"
|
description="Redis server port"
|
||||||
)
|
)
|
||||||
REDIS_BENCHMARK_DB: int = Field(
|
|
||||||
default=6,
|
|
||||||
description="Redis database number for benchmarks"
|
|
||||||
)
|
|
||||||
REDIS_TIMEOUT: int = Field(
|
REDIS_TIMEOUT: int = Field(
|
||||||
default=5,
|
default=5,
|
||||||
description="Redis connection timeout in seconds"
|
description="Redis connection timeout in seconds"
|
||||||
@@ -158,7 +154,7 @@ class Config(BaseSettings):
|
|||||||
description="Ollama model for embeddings"
|
description="Ollama model for embeddings"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redis Memory Database (separate from benchmarks)
|
# Redis Memory Database
|
||||||
REDIS_MEMORY_DB: int = Field(
|
REDIS_MEMORY_DB: int = Field(
|
||||||
default=1,
|
default=1,
|
||||||
description="Redis database number for memory cache"
|
description="Redis database number for memory cache"
|
||||||
@@ -173,7 +169,6 @@ class Config(BaseSettings):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Logging level (auto-set based on environment if not specified)"
|
description="Logging level (auto-set based on environment if not specified)"
|
||||||
)
|
)
|
||||||
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
|
||||||
|
|
||||||
# User Configuration
|
# User Configuration
|
||||||
DEFAULT_USER: str | None = Field(
|
DEFAULT_USER: str | None = Field(
|
||||||
@@ -190,11 +185,6 @@ class Config(BaseSettings):
|
|||||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||||
|
|
||||||
@property
|
|
||||||
def redis_url(self) -> str:
|
|
||||||
"""Construct Redis connection URL for benchmarks."""
|
|
||||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_BENCHMARK_DB}"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def redis_memory_url(self) -> str:
|
def redis_memory_url(self) -> str:
|
||||||
"""Construct Redis connection URL for memory cache."""
|
"""Construct Redis connection URL for memory cache."""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Provides short-term memory storage with TTL:
|
|||||||
- Recent entities mentioned in conversation
|
- Recent entities mentioned in conversation
|
||||||
- User-scoped with conversation isolation
|
- User-scoped with conversation isolation
|
||||||
|
|
||||||
Uses Redis DB 2 (separate from benchmarks in DB 1).
|
Uses Redis DB 1.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from src.agents.steward import analyze_request, format_steward_note
|
|||||||
from src.agents.steward.schemas import StewardRecommendation
|
from src.agents.steward.schemas import StewardRecommendation
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.tracing import trace_span, SpanType
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -93,12 +94,32 @@ async def preprocess_request(
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call Steward with full conversation history
|
# Call Steward with full conversation history (traced)
|
||||||
recommendation = await analyze_request(
|
async with trace_span(
|
||||||
enriched_request,
|
"steward_analysis",
|
||||||
conversation_history=conversation_history,
|
SpanType.STEWARD,
|
||||||
conversation_id=conversation_id,
|
metadata={
|
||||||
)
|
"request_preview": user_request[:100],
|
||||||
|
"history_length": len(conversation_history),
|
||||||
|
},
|
||||||
|
) as span:
|
||||||
|
recommendation = await analyze_request(
|
||||||
|
enriched_request,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update span with results
|
||||||
|
if span:
|
||||||
|
span.metadata.update({
|
||||||
|
"recommended_capabilities": recommendation.recommended_capabilities,
|
||||||
|
"complexity": recommendation.estimated_complexity,
|
||||||
|
"has_memory_context": bool(recommendation.memory_context),
|
||||||
|
"has_conversation_context": recommendation.conversation_context.has_previous_context,
|
||||||
|
})
|
||||||
|
span.details["reasoning"] = recommendation.reasoning
|
||||||
|
if recommendation.enriched_query:
|
||||||
|
span.details["enriched_query"] = recommendation.enriched_query
|
||||||
|
|
||||||
# Format note for Tatlock (includes conversation context)
|
# Format note for Tatlock (includes conversation context)
|
||||||
steward_note = await format_steward_note(recommendation)
|
steward_note = await format_steward_note(recommendation)
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Tool call tracking and benchmarking.
|
Tool call tracking.
|
||||||
|
|
||||||
Tracks which tools are recommended by the Steward versus which tools
|
Tracks which tools are recommended by the Steward versus which tools
|
||||||
are actually used by Tatlock, recording benchmarks for analysis.
|
are actually used by Tatlock for debugging and analysis.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.core.benchmarks import PerformanceBenchmark, get_benchmark_store
|
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -15,7 +13,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
class ToolCallTracker:
|
class ToolCallTracker:
|
||||||
"""
|
"""
|
||||||
Tracks tool calls for benchmarking and accuracy analysis.
|
Tracks tool calls for accuracy analysis.
|
||||||
|
|
||||||
Compares Steward's recommendations with Tatlock's actual tool usage
|
Compares Steward's recommendations with Tatlock's actual tool usage
|
||||||
to measure recommendation accuracy.
|
to measure recommendation accuracy.
|
||||||
@@ -53,6 +51,10 @@ class ToolCallTracker:
|
|||||||
return tool_name.replace("delegate_to_", "")
|
return tool_name.replace("delegate_to_", "")
|
||||||
return tool_name
|
return tool_name
|
||||||
|
|
||||||
|
def log_call(self, message: str):
|
||||||
|
"""Log a tool call message (for UI display)."""
|
||||||
|
logger.debug("tool_call_message", message=message)
|
||||||
|
|
||||||
async def track_call(self, tool_name: str, duration: float):
|
async def track_call(self, tool_name: str, duration: float):
|
||||||
"""
|
"""
|
||||||
Record a tool call with timing.
|
Record a tool call with timing.
|
||||||
@@ -78,23 +80,6 @@ class ToolCallTracker:
|
|||||||
recommended=list(self.recommended_capabilities),
|
recommended=list(self.recommended_capabilities),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record benchmark to Redis
|
|
||||||
benchmark = PerformanceBenchmark(
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
operation="tool_call",
|
|
||||||
duration_seconds=duration,
|
|
||||||
success=True, # If we got here, the call succeeded
|
|
||||||
tool_name=tool_name,
|
|
||||||
was_recommended=was_recommended,
|
|
||||||
was_actually_used=True,
|
|
||||||
conversation_id=self.conversation_id,
|
|
||||||
metadata={
|
|
||||||
"recommended_capabilities": list(self.recommended_capabilities),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await get_benchmark_store().record(benchmark)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"tool_call_tracked",
|
"tool_call_tracked",
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
@@ -124,24 +109,6 @@ class ToolCallTracker:
|
|||||||
conversation_id=self.conversation_id,
|
conversation_id=self.conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record benchmarks for unused recommendations
|
|
||||||
for tool_name in unused_tools:
|
|
||||||
benchmark = PerformanceBenchmark(
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
operation="tool_call",
|
|
||||||
duration_seconds=0.0, # Not used
|
|
||||||
success=True,
|
|
||||||
tool_name=tool_name,
|
|
||||||
was_recommended=True,
|
|
||||||
was_actually_used=False,
|
|
||||||
conversation_id=self.conversation_id,
|
|
||||||
metadata={
|
|
||||||
"recommended_capabilities": list(self.recommended_capabilities),
|
|
||||||
"reason": "recommended_but_unused",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await get_benchmark_store().record(benchmark)
|
|
||||||
|
|
||||||
# Log summary
|
# Log summary
|
||||||
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"""
|
||||||
|
Lightweight request tracing for local development.
|
||||||
|
|
||||||
|
Captures the full request flow through Tatlock's multi-agent architecture
|
||||||
|
as structured JSON traces for debugging and optimization.
|
||||||
|
|
||||||
|
Enable via DEBUG=true environment variable.
|
||||||
|
|
||||||
|
Traces are written to logs/traces/{trace_id}.json
|
||||||
|
View with logs/traces/viewer.html
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SpanType(str, Enum):
|
||||||
|
"""Types of traced operations."""
|
||||||
|
ROUTER = "router"
|
||||||
|
STEWARD = "steward"
|
||||||
|
TATLOCK = "tatlock"
|
||||||
|
EXPERT = "expert"
|
||||||
|
TOOL = "tool"
|
||||||
|
|
||||||
|
|
||||||
|
class SpanStatus(str, Enum):
|
||||||
|
"""Span completion status."""
|
||||||
|
OK = "ok"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Span:
|
||||||
|
"""A single traced operation."""
|
||||||
|
span_id: str
|
||||||
|
name: str
|
||||||
|
type: SpanType
|
||||||
|
start_time: datetime
|
||||||
|
parent_id: str | None = None
|
||||||
|
end_time: datetime | None = None
|
||||||
|
status: SpanStatus = SpanStatus.OK
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
children: list[str] = field(default_factory=list)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_ms(self) -> float | None:
|
||||||
|
"""Calculate duration in milliseconds."""
|
||||||
|
if self.end_time and self.start_time:
|
||||||
|
return (self.end_time - self.start_time).total_seconds() * 1000
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert span to dictionary for JSON serialization."""
|
||||||
|
result = {
|
||||||
|
"span_id": self.span_id,
|
||||||
|
"parent_id": self.parent_id,
|
||||||
|
"name": self.name,
|
||||||
|
"type": self.type.value,
|
||||||
|
"start_time": self.start_time.isoformat(),
|
||||||
|
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||||
|
"duration_ms": round(self.duration_ms, 2) if self.duration_ms else None,
|
||||||
|
"status": self.status.value,
|
||||||
|
"metadata": self.metadata if self.metadata else None,
|
||||||
|
}
|
||||||
|
# Only include non-empty optional fields
|
||||||
|
if self.details:
|
||||||
|
result["details"] = self.details
|
||||||
|
if self.children:
|
||||||
|
result["children"] = self.children
|
||||||
|
if self.error:
|
||||||
|
result["error"] = self.error
|
||||||
|
return {k: v for k, v in result.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trace:
|
||||||
|
"""Complete trace of a request."""
|
||||||
|
trace_id: str
|
||||||
|
conversation_id: str | None
|
||||||
|
user: str
|
||||||
|
timestamp: datetime
|
||||||
|
request: dict[str, Any]
|
||||||
|
spans: list[Span] = field(default_factory=list)
|
||||||
|
response: dict[str, Any] | None = None
|
||||||
|
status: str = "in_progress"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_duration_ms(self) -> float | None:
|
||||||
|
"""Calculate total trace duration from span timings."""
|
||||||
|
if not self.spans:
|
||||||
|
return None
|
||||||
|
start = min(s.start_time for s in self.spans)
|
||||||
|
ends = [s.end_time for s in self.spans if s.end_time]
|
||||||
|
if not ends:
|
||||||
|
return None
|
||||||
|
end = max(ends)
|
||||||
|
return (end - start).total_seconds() * 1000
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert trace to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"user": self.user,
|
||||||
|
"timestamp": self.timestamp.isoformat(),
|
||||||
|
"total_duration_ms": round(self.total_duration_ms, 2) if self.total_duration_ms else None,
|
||||||
|
"status": self.status,
|
||||||
|
"request": self.request,
|
||||||
|
"response": self.response,
|
||||||
|
"spans": [s.to_dict() for s in self.spans],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ContextVar for async-safe trace propagation
|
||||||
|
_current_trace: ContextVar[Trace | None] = ContextVar("current_trace", default=None)
|
||||||
|
_current_span: ContextVar[Span | None] = ContextVar("current_span", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def tracing_enabled() -> bool:
|
||||||
|
"""Check if tracing is enabled (requires DEBUG=true)."""
|
||||||
|
from src.core.config import config
|
||||||
|
return config.DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_id(prefix: str = "") -> str:
|
||||||
|
"""Generate unique ID with optional prefix."""
|
||||||
|
return f"{prefix}{secrets.token_hex(8)}"
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(
|
||||||
|
conversation_id: str | None,
|
||||||
|
user: str,
|
||||||
|
request: dict[str, Any],
|
||||||
|
) -> Trace | None:
|
||||||
|
"""
|
||||||
|
Start a new trace for a request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
user: User identifier
|
||||||
|
request: Request data (should include preview and full)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Trace object if tracing enabled, None otherwise
|
||||||
|
"""
|
||||||
|
if not tracing_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
trace = Trace(
|
||||||
|
trace_id=_generate_id("trace_"),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user=user,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
_current_trace.set(trace)
|
||||||
|
|
||||||
|
logger.debug("trace_started", trace_id=trace.trace_id, user=user)
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_trace() -> Trace | None:
|
||||||
|
"""Get the current trace from context."""
|
||||||
|
return _current_trace.get()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_span() -> Span | None:
|
||||||
|
"""Get the current span from context."""
|
||||||
|
return _current_span.get()
|
||||||
|
|
||||||
|
|
||||||
|
def start_span(
|
||||||
|
name: str,
|
||||||
|
span_type: SpanType,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> Span | None:
|
||||||
|
"""
|
||||||
|
Start a new span within the current trace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Span name (e.g., "steward_analysis")
|
||||||
|
span_type: Type of operation
|
||||||
|
metadata: Quick-access metadata (shown in timeline)
|
||||||
|
details: Expandable details (prompts, full responses)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Span object if tracing enabled, None otherwise
|
||||||
|
"""
|
||||||
|
trace = get_current_trace()
|
||||||
|
if not trace:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parent = get_current_span()
|
||||||
|
span = Span(
|
||||||
|
span_id=_generate_id("span_"),
|
||||||
|
name=name,
|
||||||
|
type=span_type,
|
||||||
|
start_time=datetime.now(timezone.utc),
|
||||||
|
parent_id=parent.span_id if parent else None,
|
||||||
|
metadata=metadata or {},
|
||||||
|
details=details or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to parent's children list
|
||||||
|
if parent:
|
||||||
|
parent.children.append(span.span_id)
|
||||||
|
|
||||||
|
trace.spans.append(span)
|
||||||
|
_current_span.set(span)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"span_started",
|
||||||
|
span_id=span.span_id,
|
||||||
|
name=name,
|
||||||
|
type=span_type.value,
|
||||||
|
parent_id=span.parent_id,
|
||||||
|
)
|
||||||
|
return span
|
||||||
|
|
||||||
|
|
||||||
|
def end_span(
|
||||||
|
span: Span | None = None,
|
||||||
|
status: SpanStatus = SpanStatus.OK,
|
||||||
|
metadata_update: dict[str, Any] | None = None,
|
||||||
|
details_update: dict[str, Any] | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
End a span and restore parent as current.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
span: Span to end (defaults to current span)
|
||||||
|
status: Completion status
|
||||||
|
metadata_update: Additional metadata to merge
|
||||||
|
details_update: Additional details to merge
|
||||||
|
error: Error message if failed
|
||||||
|
"""
|
||||||
|
if span is None:
|
||||||
|
span = get_current_span()
|
||||||
|
if not span:
|
||||||
|
return
|
||||||
|
|
||||||
|
span.end_time = datetime.now(timezone.utc)
|
||||||
|
span.status = status
|
||||||
|
if error:
|
||||||
|
span.error = error
|
||||||
|
span.status = SpanStatus.ERROR
|
||||||
|
if metadata_update:
|
||||||
|
span.metadata.update(metadata_update)
|
||||||
|
if details_update:
|
||||||
|
span.details.update(details_update)
|
||||||
|
|
||||||
|
# Restore parent span as current
|
||||||
|
trace = get_current_trace()
|
||||||
|
if trace and span.parent_id:
|
||||||
|
parent = next((s for s in trace.spans if s.span_id == span.parent_id), None)
|
||||||
|
_current_span.set(parent)
|
||||||
|
else:
|
||||||
|
_current_span.set(None)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"span_ended",
|
||||||
|
span_id=span.span_id,
|
||||||
|
duration_ms=span.duration_ms,
|
||||||
|
status=status.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def end_trace(
|
||||||
|
response: dict[str, Any] | None = None,
|
||||||
|
status: str = "completed",
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
End the current trace and write to file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Response data to include
|
||||||
|
status: Final trace status ("completed" or "error")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to trace file if written, None otherwise
|
||||||
|
"""
|
||||||
|
trace = get_current_trace()
|
||||||
|
if not trace:
|
||||||
|
return None
|
||||||
|
|
||||||
|
trace.response = response
|
||||||
|
trace.status = status
|
||||||
|
|
||||||
|
# Write trace to file
|
||||||
|
trace_path = _write_trace(trace)
|
||||||
|
|
||||||
|
# Clear context
|
||||||
|
_current_trace.set(None)
|
||||||
|
_current_span.set(None)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"trace_completed",
|
||||||
|
trace_id=trace.trace_id,
|
||||||
|
total_duration_ms=round(trace.total_duration_ms, 2) if trace.total_duration_ms else None,
|
||||||
|
span_count=len(trace.spans),
|
||||||
|
path=str(trace_path) if trace_path else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(trace_path) if trace_path else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_trace(trace: Trace) -> Path | None:
|
||||||
|
"""Write trace to JSON file."""
|
||||||
|
try:
|
||||||
|
# Ensure traces directory exists
|
||||||
|
traces_dir = Path("logs/traces")
|
||||||
|
traces_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write trace file
|
||||||
|
trace_path = traces_dir / f"{trace.trace_id}.json"
|
||||||
|
with open(trace_path, "w") as f:
|
||||||
|
json.dump(trace.to_dict(), f, indent=2, default=str)
|
||||||
|
|
||||||
|
return trace_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("trace_write_failed", error=str(e), trace_id=trace.trace_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def trace_span(
|
||||||
|
name: str,
|
||||||
|
span_type: SpanType,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Async context manager for tracing a span.
|
||||||
|
|
||||||
|
Automatically handles start/end timing and error capture.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with trace_span("steward_analysis", SpanType.STEWARD) as span:
|
||||||
|
result = await analyze_request(...)
|
||||||
|
if span:
|
||||||
|
span.metadata["result_count"] = len(result)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Span name
|
||||||
|
span_type: Type of operation
|
||||||
|
metadata: Initial metadata
|
||||||
|
details: Initial details (expandable in viewer)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Span object or None if tracing disabled
|
||||||
|
"""
|
||||||
|
span = start_span(name, span_type, metadata, details)
|
||||||
|
try:
|
||||||
|
yield span
|
||||||
|
except Exception as e:
|
||||||
|
end_span(span, SpanStatus.ERROR, error=str(e))
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
end_span(span, SpanStatus.OK)
|
||||||
|
|
||||||
|
|
||||||
|
def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Extract tool calls from PydanticAI result messages and add as child spans.
|
||||||
|
|
||||||
|
Call this after an agent.run() to capture tool-level timing retroactively.
|
||||||
|
Note: Since we don't have actual timing, we estimate based on sequence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List from result.new_messages()
|
||||||
|
parent_span: Parent span to attach tool spans to
|
||||||
|
"""
|
||||||
|
trace = get_current_trace()
|
||||||
|
if not trace or not parent_span:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import PydanticAI message types
|
||||||
|
try:
|
||||||
|
from pydantic_ai.messages import ModelRequest, ModelResponse, ToolCallPart, ToolReturnPart
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Track tool calls and their returns
|
||||||
|
tool_calls: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if isinstance(msg, ModelResponse):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolCallPart):
|
||||||
|
tool_calls[part.tool_call_id] = {
|
||||||
|
"name": part.tool_name,
|
||||||
|
"args": part.args if hasattr(part, 'args') else {},
|
||||||
|
}
|
||||||
|
elif isinstance(msg, ModelRequest):
|
||||||
|
for part in msg.parts:
|
||||||
|
if isinstance(part, ToolReturnPart):
|
||||||
|
if part.tool_call_id in tool_calls:
|
||||||
|
tool_info = tool_calls[part.tool_call_id]
|
||||||
|
# Create a span for this tool call
|
||||||
|
span = Span(
|
||||||
|
span_id=_generate_id("span_"),
|
||||||
|
name=tool_info["name"],
|
||||||
|
type=SpanType.TOOL,
|
||||||
|
start_time=parent_span.start_time, # Approximate
|
||||||
|
end_time=parent_span.end_time or datetime.now(timezone.utc),
|
||||||
|
parent_id=parent_span.span_id,
|
||||||
|
status=SpanStatus.OK,
|
||||||
|
metadata={
|
||||||
|
"tool_name": tool_info["name"],
|
||||||
|
"args_preview": str(tool_info.get("args", {}))[:100],
|
||||||
|
},
|
||||||
|
details={
|
||||||
|
"args": tool_info.get("args", {}),
|
||||||
|
"result": part.content[:2000] if isinstance(part.content, str) else str(part.content)[:2000],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
parent_span.children.append(span.span_id)
|
||||||
|
trace.spans.append(span)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Trace viewer router.
|
||||||
|
|
||||||
|
Serves the trace viewer UI and trace files when tracing is enabled.
|
||||||
|
Only available when DEBUG=true.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from src.core.config import config
|
||||||
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/traces", tags=["traces"])
|
||||||
|
|
||||||
|
TRACES_DIR = Path("logs/traces")
|
||||||
|
VIEWER_PATH = TRACES_DIR / "viewer.html"
|
||||||
|
|
||||||
|
|
||||||
|
def tracing_enabled() -> bool:
|
||||||
|
"""Check if tracing is enabled."""
|
||||||
|
return config.DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def get_trace_viewer():
|
||||||
|
"""
|
||||||
|
Serve the trace viewer UI.
|
||||||
|
|
||||||
|
Returns the standalone HTML viewer for browsing traces.
|
||||||
|
"""
|
||||||
|
if not tracing_enabled():
|
||||||
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
||||||
|
|
||||||
|
if not VIEWER_PATH.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Viewer not found")
|
||||||
|
|
||||||
|
return HTMLResponse(content=VIEWER_PATH.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_traces(
|
||||||
|
limit: int = 50,
|
||||||
|
since_minutes: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List available trace files.
|
||||||
|
|
||||||
|
Returns most recent traces first, with basic metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of traces to return (default 50)
|
||||||
|
since_minutes: Only return traces from the last N minutes
|
||||||
|
status: Filter by status (completed, error, streaming)
|
||||||
|
search: Search in request preview text
|
||||||
|
"""
|
||||||
|
if not tracing_enabled():
|
||||||
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
||||||
|
|
||||||
|
if not TRACES_DIR.exists():
|
||||||
|
return {"traces": [], "total": 0}
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Calculate cutoff time if filtering by time
|
||||||
|
cutoff_time = None
|
||||||
|
if since_minutes:
|
||||||
|
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||||
|
|
||||||
|
# Get all trace files, sorted by modification time (newest first)
|
||||||
|
trace_files = sorted(
|
||||||
|
TRACES_DIR.glob("trace_*.json"),
|
||||||
|
key=lambda p: p.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
traces = []
|
||||||
|
for path in trace_files:
|
||||||
|
if len(traces) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Parse timestamp for filtering
|
||||||
|
trace_timestamp = data.get("timestamp")
|
||||||
|
if cutoff_time and trace_timestamp:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
|
||||||
|
if ts < cutoff_time:
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter by status
|
||||||
|
trace_status = data.get("status", "")
|
||||||
|
if status and trace_status != status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter by search text
|
||||||
|
request_preview = data.get("request", {}).get("input_preview", "")
|
||||||
|
if search and search.lower() not in request_preview.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
traces.append({
|
||||||
|
"trace_id": data.get("trace_id"),
|
||||||
|
"timestamp": trace_timestamp,
|
||||||
|
"user": data.get("user"),
|
||||||
|
"status": trace_status,
|
||||||
|
"total_duration_ms": data.get("total_duration_ms"),
|
||||||
|
"span_count": len(data.get("spans", [])),
|
||||||
|
"request_preview": request_preview[:100],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
|
||||||
|
|
||||||
|
return {"traces": traces, "total": len(traces)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{trace_id}")
|
||||||
|
async def get_trace(trace_id: str):
|
||||||
|
"""
|
||||||
|
Get a specific trace by ID.
|
||||||
|
|
||||||
|
Returns the full trace JSON.
|
||||||
|
"""
|
||||||
|
if not tracing_enabled():
|
||||||
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
||||||
|
|
||||||
|
# Sanitize trace_id to prevent path traversal
|
||||||
|
if not trace_id.startswith("trace_") or "/" in trace_id or "\\" in trace_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid trace ID")
|
||||||
|
|
||||||
|
trace_path = TRACES_DIR / f"{trace_id}.json"
|
||||||
|
|
||||||
|
if not trace_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Trace not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(trace_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return JSONResponse(content=data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("trace_read_error", trace_id=trace_id, error=str(e))
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to read trace")
|
||||||
+7
-1
@@ -23,6 +23,7 @@ from src.core.exceptions import AppException
|
|||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
from src.core.router import router as core_router
|
from src.core.router import router as core_router
|
||||||
from src.core.startup import initialize_application
|
from src.core.startup import initialize_application
|
||||||
|
from src.core.tracing_router import router as tracing_router
|
||||||
from src.models.router import router as models_router
|
from src.models.router import router as models_router
|
||||||
from src.responses.router import router as responses_router
|
from src.responses.router import router as responses_router
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
environment=config.ENVIRONMENT.value,
|
environment=config.ENVIRONMENT.value,
|
||||||
ollama_host=str(config.OLLAMA_HOST),
|
ollama_host=str(config.OLLAMA_HOST),
|
||||||
ollama_model=config.OLLAMA_DEFAULT_MODEL,
|
ollama_model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
redis_url=config.redis_url,
|
redis_url=config.redis_memory_url,
|
||||||
log_format=config.log_format,
|
log_format=config.log_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +92,11 @@ def create_application() -> FastAPI:
|
|||||||
application.include_router(models_router, prefix=config.API_PREFIX)
|
application.include_router(models_router, prefix=config.API_PREFIX)
|
||||||
application.include_router(responses_router, prefix=config.API_PREFIX) # Responses API
|
application.include_router(responses_router, prefix=config.API_PREFIX) # Responses API
|
||||||
|
|
||||||
|
# Conditionally include tracing router (only in debug mode)
|
||||||
|
if config.DEBUG:
|
||||||
|
application.include_router(tracing_router)
|
||||||
|
logger.info("tracing_router_enabled")
|
||||||
|
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
-73
@@ -10,7 +10,6 @@ from sse_starlette.sse import EventSourceResponse
|
|||||||
from src.responses import service
|
from src.responses import service
|
||||||
from src.responses.schemas import ResponseRequest, Response
|
from src.responses.schemas import ResponseRequest, Response
|
||||||
from src.core.exceptions import ModelNotFoundError, AppException
|
from src.core.exceptions import ModelNotFoundError, AppException
|
||||||
from src.core.context import current_user, current_conversation, get_default_user
|
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -37,77 +36,16 @@ async def create_response(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response object or SSE stream
|
Response object or SSE stream
|
||||||
|
|
||||||
Example non-streaming request:
|
|
||||||
POST /v1/responses
|
|
||||||
{
|
|
||||||
"model": "lorem-tester",
|
|
||||||
"input": [{"role": "user", "content": "Hello"}],
|
|
||||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
|
||||||
"stream": false
|
|
||||||
}
|
|
||||||
|
|
||||||
Example streaming request:
|
|
||||||
POST /v1/responses
|
|
||||||
{
|
|
||||||
"model": "lorem-tester",
|
|
||||||
"input": [{"role": "user", "content": "Hello"}],
|
|
||||||
"stream": true
|
|
||||||
}
|
|
||||||
|
|
||||||
Response format (non-streaming):
|
|
||||||
{
|
|
||||||
"id": "resp_...",
|
|
||||||
"object": "response",
|
|
||||||
"created_at": 1733529600,
|
|
||||||
"model": "lorem-tester",
|
|
||||||
"status": "completed",
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_...",
|
|
||||||
"summary": ["Analyzing...", "Considering..."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_...",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "Lorem ipsum..."}]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"output_tokens": 50,
|
|
||||||
"reasoning_tokens": 20,
|
|
||||||
"total_tokens": 80
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Streaming format (SSE):
|
|
||||||
event: response.reasoning_summary_text.delta
|
|
||||||
data: {"delta": "Analyzing..."}
|
|
||||||
|
|
||||||
event: response.output_text.delta
|
|
||||||
data: {"delta": "Lorem"}
|
|
||||||
|
|
||||||
event: response.done
|
|
||||||
data: {"response": {...}}
|
|
||||||
"""
|
"""
|
||||||
# Set request context (propagates through all async calls)
|
|
||||||
effective_user = request.user or get_default_user()
|
|
||||||
user_token = current_user.set(effective_user)
|
|
||||||
conv_id = request.metadata.get("conversation_id") if request.metadata else None
|
|
||||||
conv_token = current_conversation.set(conv_id)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"response_request_received",
|
"response_request_received",
|
||||||
model=request.model,
|
model=request.model,
|
||||||
user=effective_user,
|
user=request.user,
|
||||||
conversation_id=conv_id,
|
streaming=request.stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
# Check if this is a Tatlock request - use Steward preprocessing
|
||||||
model_id = request.model
|
model_id = request.model
|
||||||
if "." in model_id:
|
if "." in model_id:
|
||||||
model_id = model_id.split(".", 1)[1]
|
model_id = model_id.split(".", 1)[1]
|
||||||
@@ -116,21 +54,20 @@ async def create_response(
|
|||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
logger.info("Streaming response requested")
|
logger.info("Streaming response requested")
|
||||||
|
|
||||||
if use_steward:
|
if use_steward:
|
||||||
logger.info("Streaming with Steward preprocessing for Tatlock request")
|
logger.info("Streaming with Steward preprocessing for Tatlock request")
|
||||||
# Use Steward + Tatlock streaming (Milestone 3.5)
|
|
||||||
from src.responses.streaming import StreamingCoordinator
|
from src.responses.streaming import StreamingCoordinator
|
||||||
coordinator = StreamingCoordinator()
|
coordinator = StreamingCoordinator()
|
||||||
return EventSourceResponse(
|
return EventSourceResponse(
|
||||||
coordinator.stream_response_with_steward(request)
|
coordinator.stream_response_with_steward(request)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Regular streaming for non-Tatlock models
|
|
||||||
return EventSourceResponse(
|
return EventSourceResponse(
|
||||||
service.create_response_stream(request)
|
service.create_response_stream(request)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use appropriate service method
|
# Non-streaming response
|
||||||
if use_steward:
|
if use_steward:
|
||||||
logger.info("Using Steward preprocessing for Tatlock request")
|
logger.info("Using Steward preprocessing for Tatlock request")
|
||||||
return await service.create_response_with_steward(request)
|
return await service.create_response_with_steward(request)
|
||||||
@@ -148,8 +85,3 @@ async def create_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
finally:
|
|
||||||
# Reset context (important for connection reuse)
|
|
||||||
current_user.reset(user_token)
|
|
||||||
current_conversation.reset(conv_token)
|
|
||||||
|
|||||||
+251
-140
@@ -26,6 +26,8 @@ from src.responses.context import ContextWindow
|
|||||||
from src.core.preprocessing import preprocess_request
|
from src.core.preprocessing import preprocess_request
|
||||||
from src.core.tool_tracking import ToolCallTracker
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
from src.core.tracing import start_trace, end_trace, start_span, SpanType
|
||||||
|
from src.core.context import current_user, current_conversation, get_default_user
|
||||||
from src.agents.steward.schemas import StewardRecommendation
|
from src.agents.steward.schemas import StewardRecommendation
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -34,6 +36,29 @@ import asyncio
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_user_input(input_data) -> str:
|
||||||
|
"""Extract user input text from request input for tracing."""
|
||||||
|
if isinstance(input_data, str):
|
||||||
|
return input_data
|
||||||
|
elif isinstance(input_data, list) and input_data:
|
||||||
|
last_msg = input_data[-1]
|
||||||
|
if isinstance(last_msg, dict):
|
||||||
|
return last_msg.get("content", str(last_msg))
|
||||||
|
return str(last_msg)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_response_preview(response: Response) -> str:
|
||||||
|
"""Extract response preview text for tracing."""
|
||||||
|
if response.output:
|
||||||
|
for item in response.output:
|
||||||
|
if hasattr(item, 'content'):
|
||||||
|
for content in item.content:
|
||||||
|
if hasattr(content, 'text'):
|
||||||
|
return content.text[:200]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _execute_single_delegation(
|
async def _execute_single_delegation(
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
task: str,
|
task: str,
|
||||||
@@ -405,45 +430,88 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
# Get or generate conversation ID
|
# Get or generate conversation ID
|
||||||
conversation_id = await _conversation_history.get_conversation_id(request)
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||||
|
|
||||||
# Strip pipeline prefix if present (e.g., "pipeline.model" -> "model")
|
# Set context for tracing
|
||||||
model_id = request.model
|
effective_user = request.user or get_default_user()
|
||||||
if "." in model_id:
|
current_user.set(effective_user)
|
||||||
model_id = model_id.split(".", 1)[1]
|
current_conversation.set(conversation_id)
|
||||||
|
|
||||||
# Get agent for model
|
# Extract user input for tracing
|
||||||
agent = ModelRegistry.get_agent(model_id)
|
user_input = _extract_user_input(request.input)
|
||||||
|
|
||||||
# Collect all output items from agent
|
# Start trace
|
||||||
output_items = []
|
trace = start_trace(
|
||||||
async for item in agent.generate_response(
|
conversation_id=conversation_id,
|
||||||
messages=request.input,
|
user=effective_user,
|
||||||
reasoning=request.reasoning,
|
request={
|
||||||
tools=request.tools,
|
"model": request.model,
|
||||||
temperature=request.temperature,
|
"input_preview": user_input[:200] if user_input else "",
|
||||||
max_tokens=request.max_output_tokens,
|
"full_input": request.input,
|
||||||
stop=request.stop,
|
"streaming": False,
|
||||||
):
|
},
|
||||||
output_items.append(item)
|
|
||||||
|
|
||||||
# Convert agent OutputItems to schema OutputItems
|
|
||||||
converted_items = _convert_output_items(output_items)
|
|
||||||
|
|
||||||
# Calculate token usage
|
|
||||||
usage = _calculate_usage(request.input, output_items)
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
id=f"resp_{generate_id()}",
|
|
||||||
created_at=int(time.time()),
|
|
||||||
model=request.model,
|
|
||||||
status="completed",
|
|
||||||
output=converted_items,
|
|
||||||
usage=usage
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track conversation history (for analytics and future vector memory)
|
# Start service span
|
||||||
await _conversation_history.add_response(conversation_id, response)
|
service_span = start_span(
|
||||||
|
"create_response",
|
||||||
|
SpanType.ROUTER,
|
||||||
|
metadata={"model": request.model, "user": effective_user},
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
try:
|
||||||
|
# Strip pipeline prefix if present (e.g., "pipeline.model" -> "model")
|
||||||
|
model_id = request.model
|
||||||
|
if "." in model_id:
|
||||||
|
model_id = model_id.split(".", 1)[1]
|
||||||
|
|
||||||
|
# Get agent for model
|
||||||
|
agent = ModelRegistry.get_agent(model_id)
|
||||||
|
|
||||||
|
# Collect all output items from agent
|
||||||
|
output_items = []
|
||||||
|
async for item in agent.generate_response(
|
||||||
|
messages=request.input,
|
||||||
|
reasoning=request.reasoning,
|
||||||
|
tools=request.tools,
|
||||||
|
temperature=request.temperature,
|
||||||
|
max_tokens=request.max_output_tokens,
|
||||||
|
stop=request.stop,
|
||||||
|
):
|
||||||
|
output_items.append(item)
|
||||||
|
|
||||||
|
# Convert agent OutputItems to schema OutputItems
|
||||||
|
converted_items = _convert_output_items(output_items)
|
||||||
|
|
||||||
|
# Calculate token usage
|
||||||
|
usage = _calculate_usage(request.input, output_items)
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
id=f"resp_{generate_id()}",
|
||||||
|
created_at=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
status="completed",
|
||||||
|
output=converted_items,
|
||||||
|
usage=usage
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track conversation history (for analytics and future vector memory)
|
||||||
|
await _conversation_history.add_response(conversation_id, response)
|
||||||
|
|
||||||
|
# End trace with response info
|
||||||
|
response_preview = _extract_response_preview(response)
|
||||||
|
end_trace(
|
||||||
|
response={
|
||||||
|
"output_preview": response_preview,
|
||||||
|
"output_count": len(response.output) if response.output else 0,
|
||||||
|
"status": response.status,
|
||||||
|
},
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
end_trace(status="error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||||
@@ -454,7 +522,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
1. Steward analyzes the request and recommends capabilities
|
1. Steward analyzes the request and recommends capabilities
|
||||||
2. Phase 1: Tatlock orchestrates tool calls and expert delegations
|
2. Phase 1: Tatlock orchestrates tool calls and expert delegations
|
||||||
3. Phase 2: Tatlock synthesizes butler-toned response from results
|
3. Phase 2: Tatlock synthesizes butler-toned response from results
|
||||||
4. Tool usage is tracked for benchmarking
|
4. Tool usage is tracked for analysis
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Response request
|
request: Response request
|
||||||
@@ -473,133 +541,176 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
# Get or generate conversation ID
|
# Get or generate conversation ID
|
||||||
conversation_id = await _conversation_history.get_conversation_id(request)
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||||
|
|
||||||
# Extract user message and conversation history
|
# Set context for tracing
|
||||||
user_message = ""
|
effective_user = request.user or get_default_user()
|
||||||
for msg in reversed(request.input):
|
current_user.set(effective_user)
|
||||||
if msg.get("role") == "user":
|
current_conversation.set(conversation_id)
|
||||||
user_message = msg.get("content", "")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Conversation history is all messages except the current one
|
# Extract user input for tracing
|
||||||
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
user_input = _extract_user_input(request.input)
|
||||||
|
|
||||||
logger.info(
|
# Start trace
|
||||||
"creating_response_with_steward",
|
trace = start_trace(
|
||||||
user_message_preview=user_message[:100],
|
|
||||||
history_length=len(conversation_history),
|
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
|
user=effective_user,
|
||||||
|
request={
|
||||||
|
"model": request.model,
|
||||||
|
"input_preview": user_input[:200] if user_input else "",
|
||||||
|
"full_input": request.input,
|
||||||
|
"streaming": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Steward preprocessing
|
# Start service span
|
||||||
enriched = await preprocess_request(
|
service_span = start_span(
|
||||||
user_message,
|
"create_response_with_steward",
|
||||||
conversation_history=conversation_history,
|
SpanType.ROUTER,
|
||||||
conversation_id=conversation_id,
|
metadata={"model": request.model, "user": effective_user},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize tool tracker
|
try:
|
||||||
tracker = ToolCallTracker(
|
# Extract user message and conversation history
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
user_message = ""
|
||||||
conversation_id=conversation_id,
|
for msg in reversed(request.input):
|
||||||
)
|
if msg.get("role") == "user":
|
||||||
|
user_message = msg.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
# Check if direct delegation is recommended
|
# Conversation history is all messages except the current one
|
||||||
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
||||||
# we still use two-phase but delegate directly in Phase 1
|
|
||||||
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
|
||||||
delegation_only = all(
|
|
||||||
cap in delegation_agents
|
|
||||||
for cap in enriched.recommendation.recommended_capabilities
|
|
||||||
) and enriched.recommendation.recommended_capabilities
|
|
||||||
|
|
||||||
from src.agents.tatlock import TatlockAgent
|
logger.info(
|
||||||
tatlock = TatlockAgent()
|
"creating_response_with_steward",
|
||||||
|
user_message_preview=user_message[:100],
|
||||||
# Use enriched query (with location/timezone context) if available
|
history_length=len(conversation_history),
|
||||||
effective_query = enriched.recommendation.enriched_query or user_message
|
conversation_id=conversation_id,
|
||||||
|
|
||||||
if delegation_only:
|
|
||||||
# Direct delegation path - collect results then synthesize
|
|
||||||
orchestration_results = await _direct_delegation_with_results(
|
|
||||||
effective_query, enriched.recommendation, tracker, conversation_id
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Phase 1: Orchestrate tool calls
|
|
||||||
orchestration_results = await tatlock.orchestrate_tool_calls(
|
|
||||||
user_message=effective_query,
|
|
||||||
steward_note=enriched.steward_note,
|
|
||||||
scoped_tools=enriched.scoped_tools,
|
|
||||||
message_history=conversation_history,
|
|
||||||
tool_tracker=tracker,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle text-based delegation fallback if present
|
# Steward preprocessing
|
||||||
if "[DELEGATE:" in orchestration_results.get("raw_output", ""):
|
enriched = await preprocess_request(
|
||||||
text_delegation_results = await _handle_text_delegation(
|
user_message,
|
||||||
orchestration_results["raw_output"], tracker, conversation_id
|
conversation_history=conversation_history,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize tool tracker
|
||||||
|
tracker = ToolCallTracker(
|
||||||
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if direct delegation is recommended
|
||||||
|
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
||||||
|
# we still use two-phase but delegate directly in Phase 1
|
||||||
|
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||||
|
delegation_only = all(
|
||||||
|
cap in delegation_agents
|
||||||
|
for cap in enriched.recommendation.recommended_capabilities
|
||||||
|
) and enriched.recommendation.recommended_capabilities
|
||||||
|
|
||||||
|
from src.agents.tatlock import TatlockAgent
|
||||||
|
tatlock = TatlockAgent()
|
||||||
|
|
||||||
|
# Use enriched query (with location/timezone context) if available
|
||||||
|
effective_query = enriched.recommendation.enriched_query or user_message
|
||||||
|
|
||||||
|
if delegation_only:
|
||||||
|
# Direct delegation path - collect results then synthesize
|
||||||
|
orchestration_results = await _direct_delegation_with_results(
|
||||||
|
effective_query, enriched.recommendation, tracker, conversation_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Phase 1: Orchestrate tool calls
|
||||||
|
orchestration_results = await tatlock.orchestrate_tool_calls(
|
||||||
|
user_message=effective_query,
|
||||||
|
steward_note=enriched.steward_note,
|
||||||
|
scoped_tools=enriched.scoped_tools,
|
||||||
|
message_history=conversation_history,
|
||||||
|
tool_tracker=tracker,
|
||||||
)
|
)
|
||||||
# Add text delegation results to expert_results
|
|
||||||
if text_delegation_results != orchestration_results["raw_output"]:
|
|
||||||
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
|
||||||
|
|
||||||
# Phase 2: Synthesize butler-toned response from all results
|
# Handle text-based delegation fallback if present
|
||||||
tatlock_response = await tatlock.synthesize_from_results(
|
if "[DELEGATE:" in orchestration_results.get("raw_output", ""):
|
||||||
user_message=user_message,
|
text_delegation_results = await _handle_text_delegation(
|
||||||
orchestration_results=orchestration_results,
|
orchestration_results["raw_output"], tracker, conversation_id
|
||||||
message_history=conversation_history,
|
)
|
||||||
)
|
# Add text delegation results to expert_results
|
||||||
|
if text_delegation_results != orchestration_results["raw_output"]:
|
||||||
|
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
||||||
|
|
||||||
# Finalize tool tracking
|
# Phase 2: Synthesize butler-toned response from all results
|
||||||
await tracker.finalize()
|
tatlock_response = await tatlock.synthesize_from_results(
|
||||||
|
user_message=user_message,
|
||||||
|
orchestration_results=orchestration_results,
|
||||||
|
message_history=conversation_history,
|
||||||
|
)
|
||||||
|
|
||||||
# Build response output items
|
# Finalize tool tracking
|
||||||
output_items = []
|
await tracker.finalize()
|
||||||
|
|
||||||
# Add Steward reasoning as a reasoning output item
|
# Build response output items
|
||||||
output_items.append(ReasoningOutputItem(
|
output_items = []
|
||||||
id=f"reasoning_{generate_id()}",
|
|
||||||
summary=[
|
|
||||||
"🎩 Steward's Analysis:",
|
|
||||||
enriched.steward_reasoning,
|
|
||||||
],
|
|
||||||
status="completed"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Add Tatlock's message
|
# Add Steward reasoning as a reasoning output item
|
||||||
output_items.append(MessageOutputItem(
|
output_items.append(ReasoningOutputItem(
|
||||||
id=f"msg_{generate_id()}",
|
id=f"reasoning_{generate_id()}",
|
||||||
role="assistant",
|
summary=[
|
||||||
content=[OutputTextContent(
|
"🎩 Steward's Analysis:",
|
||||||
type="output_text",
|
enriched.steward_reasoning,
|
||||||
text=tatlock_response,
|
],
|
||||||
annotations=[]
|
status="completed"
|
||||||
)],
|
))
|
||||||
status="completed"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Calculate usage (approximate)
|
# Add Tatlock's message
|
||||||
usage = _calculate_usage(request.input, output_items)
|
output_items.append(MessageOutputItem(
|
||||||
|
id=f"msg_{generate_id()}",
|
||||||
|
role="assistant",
|
||||||
|
content=[OutputTextContent(
|
||||||
|
type="output_text",
|
||||||
|
text=tatlock_response,
|
||||||
|
annotations=[]
|
||||||
|
)],
|
||||||
|
status="completed"
|
||||||
|
))
|
||||||
|
|
||||||
response = Response(
|
# Calculate usage (approximate)
|
||||||
id=f"resp_{generate_id()}",
|
usage = _calculate_usage(request.input, output_items)
|
||||||
created_at=int(time.time()),
|
|
||||||
model=request.model,
|
|
||||||
status="completed",
|
|
||||||
output=output_items,
|
|
||||||
usage=usage
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track conversation history
|
response = Response(
|
||||||
await _conversation_history.add_response(conversation_id, response)
|
id=f"resp_{generate_id()}",
|
||||||
|
created_at=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
status="completed",
|
||||||
|
output=output_items,
|
||||||
|
usage=usage
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
# Track conversation history
|
||||||
"response_with_steward_complete",
|
await _conversation_history.add_response(conversation_id, response)
|
||||||
response_id=response.id,
|
|
||||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
|
||||||
tool_summary=tracker.get_summary(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
logger.info(
|
||||||
|
"response_with_steward_complete",
|
||||||
|
response_id=response.id,
|
||||||
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||||
|
tool_summary=tracker.get_summary(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# End trace with response info
|
||||||
|
response_preview = _extract_response_preview(response)
|
||||||
|
end_trace(
|
||||||
|
response={
|
||||||
|
"output_preview": response_preview,
|
||||||
|
"output_count": len(response.output) if response.output else 0,
|
||||||
|
"status": response.status,
|
||||||
|
},
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
end_trace(status="error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def create_response_stream(
|
async def create_response_stream(
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ LOG_FILE="$LOGS_DIR/server.log"
|
|||||||
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
||||||
|
|
||||||
# Start the server
|
# Start the server
|
||||||
echo -e "${GREEN}Starting uvicorn server on http://localhost:8777${NC}"
|
echo -e "${GREEN}Starting uvicorn server on http://tower-of-joy:8777${NC}"
|
||||||
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
uvicorn src.main:app --reload --host localhost --port 8777 2>&1 | tee "$LOG_FILE"
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8777 2>&1 | tee "$LOG_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user