Files
tatlock/src/responses/service.py
T
jpmschweitzerandClaude Sonnet 4.5 6eed5f4d13 feat: implement Phase 2 two-tier architecture with Steward
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>
2025-12-07 15:39:20 +01:00

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