feat: integrate tracing throughout request pipeline

Instrument the full request flow with trace spans for debugging:

- Wrap expert delegations (librarian/biographer/housekeeper) in spans
- Add orchestrate and synthesize spans to TatlockAgent
- Trace Steward analysis in preprocessing
- Start/end traces in response service with context management
- Simplify router by moving context handling to service layer
- Include tracing router in debug mode
- Remove benchmark recording from tool_tracking and steward service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 10:26:37 +01:00
co-authored by Claude Opus 4.5
parent 2a9449bc81
commit 87f2926db2
8 changed files with 502 additions and 369 deletions
+145 -84
View File
@@ -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),
)
# ============================================================================= # =============================================================================
+3 -22
View File
@@ -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
View File
@@ -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:
+27 -6
View File
@@ -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)
+7 -40
View File
@@ -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(
+8 -2
View File
@@ -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,
) )
@@ -90,7 +91,12 @@ def create_application() -> FastAPI:
application.include_router(chat_router, prefix=config.API_PREFIX) application.include_router(chat_router, prefix=config.API_PREFIX)
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
View File
@@ -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
View File
@@ -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(