Add comprehensive two-tier architecture where Steward analyzes requests and Tatlock executes with scoped tools. Includes full infrastructure for request preprocessing, tool tracking, benchmarking, and streaming. **Added:** - Steward agent for request analysis and capability recommendation - Household Registry for centralized capability management - Request preprocessing pipeline (Steward → Tatlock flow) - Tool usage tracking and benchmarking system - Streaming transparency (Steward reasoning visible in streams) - Structured logging with operation timing - Redis benchmark storage with 30-day expiry - Benchmark analysis CLI tools **Infrastructure:** - src/agents/steward/ - Steward agent implementation - src/agents/tatlock_core/ - Tatlock capability domain - src/core/preprocessing.py - Request preprocessing pipeline - src/core/tool_tracking.py - Tool call tracking - src/core/benchmarks.py - Benchmark recording system - src/core/household_registry.py - Capability registry - src/core/startup.py - Application startup coordination - src/core/logging_config.py - Structured logging setup **Integration:** - Responses API uses Steward for Tatlock requests - Chat Completions wraps Responses API for OpenAI compatibility - Streaming coordinator supports Steward + Tatlock flow - Tool scoping per request based on Steward recommendations **Testing:** - Integration tests for Steward-Tatlock flow - Benchmark and registry unit tests - Steward streaming tests See PHASE2_PLAN.md and PHASE2_COMPLETE.md for detailed documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
397 lines
12 KiB
Python
397 lines
12 KiB
Python
"""
|
|
Response service for creating responses.
|
|
|
|
Handles both streaming and non-streaming response generation.
|
|
Tracks conversation history for analytics and future vector memory.
|
|
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
|
|
"""
|
|
|
|
import time
|
|
import secrets
|
|
from typing import AsyncGenerator
|
|
|
|
from src.agents.registry import ModelRegistry
|
|
from src.responses.schemas import (
|
|
Response,
|
|
ResponseRequest,
|
|
ResponseUsage,
|
|
MessageOutputItem,
|
|
ReasoningOutputItem,
|
|
FunctionCallOutputItem,
|
|
OutputTextContent,
|
|
)
|
|
from src.responses.streaming import StreamingCoordinator
|
|
from src.responses.history import ConversationHistory
|
|
from src.responses.context import ContextWindow
|
|
from src.core.preprocessing import preprocess_request
|
|
from src.core.tool_tracking import ToolCallTracker
|
|
from src.core.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Global conversation history tracker
|
|
# In production, this would be backed by a database or Redis
|
|
_conversation_history = ConversationHistory(max_turns=20)
|
|
|
|
# Global context window manager (4096 token default)
|
|
_context_window = ContextWindow(max_tokens=4096)
|
|
|
|
|
|
def generate_id() -> str:
|
|
"""Generate unique ID for responses."""
|
|
return secrets.token_hex(16)
|
|
|
|
|
|
def _calculate_usage(input_messages: list[dict], output_items: list) -> ResponseUsage:
|
|
"""
|
|
Calculate token usage for the response.
|
|
|
|
For now: approximate token counting.
|
|
Future: Use tiktoken or similar for accurate counting.
|
|
|
|
Args:
|
|
input_messages: Input messages
|
|
output_items: Output items generated
|
|
|
|
Returns:
|
|
ResponseUsage: Token usage statistics
|
|
"""
|
|
# Approximate input tokens (chars / 4)
|
|
input_text = " ".join(str(msg) for msg in input_messages)
|
|
input_tokens = len(input_text) // 4
|
|
|
|
# Approximate output tokens
|
|
output_tokens = 0
|
|
reasoning_tokens = 0
|
|
|
|
for item in output_items:
|
|
# Check if it's a schema object (has summary/content attributes directly)
|
|
if isinstance(item, ReasoningOutputItem):
|
|
reasoning_text = " ".join(item.summary)
|
|
reasoning_tokens += len(reasoning_text) // 4
|
|
elif isinstance(item, MessageOutputItem):
|
|
message_text = item.content[0].text
|
|
output_tokens += len(message_text) // 4
|
|
elif isinstance(item, FunctionCallOutputItem):
|
|
func_text = item.arguments
|
|
output_tokens += len(func_text) // 4
|
|
elif hasattr(item, 'type'):
|
|
# Agent OutputItem objects (backward compatibility)
|
|
if item.type == "reasoning":
|
|
reasoning_text = " ".join(item.data.get("summary", []))
|
|
reasoning_tokens += len(reasoning_text) // 4
|
|
elif item.type == "message":
|
|
message_text = item.data["content"][0]["text"]
|
|
output_tokens += len(message_text) // 4
|
|
elif item.type == "function_call":
|
|
func_text = item.data["arguments"]
|
|
output_tokens += len(func_text) // 4
|
|
|
|
total_tokens = input_tokens + output_tokens + reasoning_tokens
|
|
|
|
return ResponseUsage(
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
reasoning_tokens=reasoning_tokens,
|
|
total_tokens=total_tokens
|
|
)
|
|
|
|
|
|
async def create_response(request: ResponseRequest) -> Response:
|
|
"""
|
|
Create non-streaming response.
|
|
|
|
Tracks conversation history if conversation_id is provided in metadata.
|
|
|
|
Args:
|
|
request: Response request
|
|
|
|
Returns:
|
|
Response: Complete response object
|
|
|
|
Example:
|
|
request = ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
reasoning={"effort": "medium", "summary": "auto"},
|
|
metadata={"conversation_id": "conv_abc123"} # Optional
|
|
)
|
|
response = await create_response(request)
|
|
"""
|
|
# Get or generate conversation ID
|
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
|
|
|
# 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)
|
|
|
|
return response
|
|
|
|
|
|
async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|
"""
|
|
Create response using Steward preprocessing (Phase 2 flow).
|
|
|
|
This is the two-tier architecture where:
|
|
1. Steward analyzes the request and recommends capabilities
|
|
2. Tatlock runs with scoped tools based on recommendations
|
|
3. Tool usage is tracked for benchmarking
|
|
|
|
Args:
|
|
request: Response request
|
|
|
|
Returns:
|
|
Response: Complete response object with Steward analysis included
|
|
|
|
Example:
|
|
request = ResponseRequest(
|
|
model="tatlock",
|
|
input=[{"role": "user", "content": "What's sqrt(144)?"}],
|
|
metadata={"conversation_id": "conv_abc123"}
|
|
)
|
|
response = await create_response_with_steward(request)
|
|
"""
|
|
# Get or generate conversation ID
|
|
conversation_id = await _conversation_history.get_conversation_id(request)
|
|
|
|
# Extract user message and conversation history
|
|
user_message = ""
|
|
for msg in reversed(request.input):
|
|
if msg.get("role") == "user":
|
|
user_message = msg.get("content", "")
|
|
break
|
|
|
|
# Conversation history is all messages except the current one
|
|
conversation_history = request.input[:-1] if len(request.input) > 1 else []
|
|
|
|
logger.info(
|
|
"creating_response_with_steward",
|
|
user_message_preview=user_message[:100],
|
|
history_length=len(conversation_history),
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
# Phase 1: Steward preprocessing
|
|
enriched = await preprocess_request(
|
|
user_message,
|
|
conversation_history=conversation_history,
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
# Phase 2: Initialize tool tracker
|
|
tracker = ToolCallTracker(
|
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
# Phase 3: Run Tatlock with scoped tools
|
|
from src.agents.tatlock import TatlockAgent
|
|
tatlock = TatlockAgent()
|
|
|
|
tatlock_response = await tatlock.run_with_scoped_tools(
|
|
user_message=user_message,
|
|
steward_note=enriched.steward_note,
|
|
scoped_tools=enriched.scoped_tools,
|
|
message_history=conversation_history,
|
|
tool_tracker=tracker,
|
|
)
|
|
|
|
# Phase 4: Finalize tool tracking
|
|
await tracker.finalize()
|
|
|
|
# Build response output items
|
|
output_items = []
|
|
|
|
# Add Steward reasoning as a reasoning output item
|
|
output_items.append(ReasoningOutputItem(
|
|
id=f"reasoning_{generate_id()}",
|
|
summary=[
|
|
"🎩 Steward's Analysis:",
|
|
enriched.steward_reasoning,
|
|
],
|
|
status="completed"
|
|
))
|
|
|
|
# Add Tatlock's message
|
|
output_items.append(MessageOutputItem(
|
|
id=f"msg_{generate_id()}",
|
|
role="assistant",
|
|
content=[OutputTextContent(
|
|
type="output_text",
|
|
text=tatlock_response,
|
|
annotations=[]
|
|
)],
|
|
status="completed"
|
|
))
|
|
|
|
# Calculate usage (approximate)
|
|
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=output_items,
|
|
usage=usage
|
|
)
|
|
|
|
# Track conversation history
|
|
await _conversation_history.add_response(conversation_id, response)
|
|
|
|
logger.info(
|
|
"response_with_steward_complete",
|
|
response_id=response.id,
|
|
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
|
tool_summary=tracker.get_summary(),
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
async def create_response_stream(
|
|
request: ResponseRequest
|
|
) -> AsyncGenerator[dict, None]:
|
|
"""
|
|
Create streaming response.
|
|
|
|
Yields SSE-formatted events for streaming to client.
|
|
|
|
Args:
|
|
request: Response request
|
|
|
|
Yields:
|
|
dict: SSE event dict with 'event' and 'data' keys
|
|
|
|
Example:
|
|
async for event in create_response_stream(request):
|
|
# event = {"event": "response.output_text.delta", "data": "..."}
|
|
yield event
|
|
"""
|
|
coordinator = StreamingCoordinator()
|
|
|
|
async for event in coordinator.stream_response(request):
|
|
yield {
|
|
"event": event.event,
|
|
"data": event.model_dump_json()
|
|
}
|
|
|
|
|
|
async def get_conversation_history(conversation_id: str) -> list[Response]:
|
|
"""
|
|
Get conversation history for a given conversation ID.
|
|
|
|
Args:
|
|
conversation_id: Conversation identifier
|
|
|
|
Returns:
|
|
list: List of Response objects
|
|
"""
|
|
return await _conversation_history.get_history(conversation_id)
|
|
|
|
|
|
async def get_conversation_stats() -> dict:
|
|
"""
|
|
Get conversation history statistics.
|
|
|
|
Returns:
|
|
dict: Statistics including total conversations tracked
|
|
"""
|
|
return {
|
|
"total_conversations": await _conversation_history.get_conversation_count(),
|
|
"max_turns_per_conversation": _conversation_history._max_turns
|
|
}
|
|
|
|
|
|
async def clear_conversation(conversation_id: str) -> bool:
|
|
"""
|
|
Clear a specific conversation history.
|
|
|
|
Args:
|
|
conversation_id: Conversation identifier
|
|
|
|
Returns:
|
|
bool: True if conversation was cleared
|
|
"""
|
|
return await _conversation_history.clear_conversation(conversation_id)
|
|
|
|
|
|
def get_context_window() -> ContextWindow:
|
|
"""
|
|
Get the global context window manager.
|
|
|
|
Returns:
|
|
ContextWindow: Context window manager instance
|
|
"""
|
|
return _context_window
|
|
|
|
|
|
def _convert_output_items(items: list) -> list:
|
|
"""
|
|
Convert agent OutputItem objects to schema OutputItem objects.
|
|
|
|
Args:
|
|
items: List of agent OutputItem objects
|
|
|
|
Returns:
|
|
list: List of schema OutputItem objects
|
|
"""
|
|
converted = []
|
|
|
|
for item in items:
|
|
if item.type == "message":
|
|
converted.append(MessageOutputItem(
|
|
id=item.id,
|
|
content=[OutputTextContent(**c) for c in item.data["content"]],
|
|
status=item.data.get("status", "completed")
|
|
))
|
|
elif item.type == "reasoning":
|
|
converted.append(ReasoningOutputItem(
|
|
id=item.id,
|
|
summary=item.data["summary"],
|
|
status=item.data.get("status", "completed")
|
|
))
|
|
elif item.type == "function_call":
|
|
converted.append(FunctionCallOutputItem(
|
|
id=item.id,
|
|
name=item.data["name"],
|
|
arguments=item.data["arguments"],
|
|
status=item.data.get("status", "completed")
|
|
))
|
|
|
|
return converted
|