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>
This commit is contained in:
2025-12-07 15:39:20 +01:00
co-authored by Claude Sonnet 4.5
parent 2577730546
commit 6eed5f4d13
34 changed files with 6362 additions and 133 deletions
+95 -91
View File
@@ -9,7 +9,6 @@ import time
import uuid
from typing import AsyncGenerator
from src.agents.registry import ModelRegistry
from src.chat import constants
from src.chat.schemas import (
ChatCompletionChunk,
@@ -21,6 +20,8 @@ from src.chat.schemas import (
ChatCompletionUsage,
ChatMessage,
)
from src.responses.schemas import ResponseRequest
from src.responses.service import create_response, create_response_with_steward
async def create_chat_completion(
@@ -41,49 +42,45 @@ async def create_chat_completion(
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created_at = int(time.time())
# Strip pipeline prefix if present
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
# Get agent and generate response
agent = ModelRegistry.get_agent(model_id)
# Convert Chat messages to Responses format
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
# Collect output items from agent (with reasoning enabled)
output_items = []
async for item in agent.generate_response(
messages=input_messages,
response_request = ResponseRequest(
model=request.model,
input=input_messages,
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
temperature=request.temperature or 1.0,
max_tokens=request.max_tokens,
max_output_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
):
output_items.append(item)
)
# Build content with <think> tags
# Call Responses API (will use Steward for Tatlock)
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
use_steward = model_id.lower() == "tatlock"
if use_steward:
response = await create_response_with_steward(response_request)
else:
response = await create_response(response_request)
# Convert Responses API output to Chat format
content_parts = []
# Add reasoning as <think> blocks
for item in output_items:
for item in response.output:
if item.type == "reasoning":
reasoning_text = "\n".join(item.data.get("summary", []))
reasoning_text = "\n".join(item.summary)
content_parts.append(f"<think>\n{reasoning_text}\n</think>\n\n")
elif item.type == "message":
content_parts.append(item.data["content"][0]["text"])
content_parts.append(item.content[0].text)
content = "".join(content_parts)
# Calculate token usage (approximate)
prompt_text = " ".join(m.content for m in request.messages)
prompt_tokens = len(prompt_text) // 4
completion_tokens = len(content) // 4
return ChatCompletionResponse(
id=completion_id,
object=constants.CHAT_COMPLETION_OBJECT,
@@ -100,9 +97,9 @@ async def create_chat_completion(
)
],
usage=ChatCompletionUsage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
total_tokens=response.usage.total_tokens,
),
)
@@ -121,23 +118,34 @@ async def create_chat_completion_stream(
Yields:
Chat completion chunks with reasoning as <think> tags
"""
from src.responses.streaming import StreamingCoordinator, StreamEventType
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created_at = int(time.time())
# Strip pipeline prefix if present
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
# Get agent
agent = ModelRegistry.get_agent(model_id)
# Convert Chat messages to Responses format
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
response_request = ResponseRequest(
model=request.model,
input=input_messages,
reasoning={"effort": "medium", "summary": "auto"},
temperature=request.temperature or 1.0,
max_output_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
stream=True,
)
# Determine if we should use Steward
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
use_steward = model_id.lower() == "tatlock"
# First chunk with role
yield ChatCompletionChunk(
id=completion_id,
@@ -153,17 +161,18 @@ async def create_chat_completion_stream(
],
)
# Stream from agent with reasoning enabled
# Stream from Responses API
coordinator = StreamingCoordinator()
in_reasoning = False
async for item in agent.generate_response(
messages=input_messages,
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
temperature=request.temperature or 1.0,
max_tokens=request.max_tokens,
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
):
if item.type == "reasoning":
# Start <think> block
if use_steward:
stream_generator = coordinator.stream_response_with_steward(response_request)
else:
stream_generator = coordinator.stream_response(response_request)
async for event in stream_generator:
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
# Start <think> block if needed
if not in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
@@ -180,24 +189,7 @@ async def create_chat_completion_stream(
)
in_reasoning = True
# Stream reasoning summary steps
for step in item.data.get("summary", []):
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=f"{step}\n"),
finish_reason=None,
)
],
)
await asyncio.sleep(0.05) # Simulate typing
# Close <think> block
# Stream reasoning delta
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -206,20 +198,15 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
delta=ChatCompletionChunkDelta(content=event.delta),
finish_reason=None,
)
],
)
in_reasoning = False
elif item.type == "message":
# Stream message content in chunks (preserves newlines, markdown, etc.)
text = item.data["content"][0]["text"]
chunk_size = 50 # characters per chunk
for i in range(0, len(text), chunk_size):
chunk = text[i:i+chunk_size]
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
# Close <think> block
if in_reasoning:
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
@@ -228,24 +215,41 @@ async def create_chat_completion_stream(
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=chunk),
delta=ChatCompletionChunkDelta(content="</think>\n\n"),
finish_reason=None,
)
],
)
await asyncio.sleep(0.02) # Faster since chunks are larger
in_reasoning = False
# Final chunk with finish_reason
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(),
finish_reason=constants.FINISH_REASON_STOP,
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
# Stream message content
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(content=event.delta),
finish_reason=None,
)
],
)
elif event.event == StreamEventType.RESPONSE_DONE:
# Final chunk with finish_reason
yield ChatCompletionChunk(
id=completion_id,
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
created=created_at,
model=request.model,
choices=[
ChatCompletionChunkChoice(
index=0,
delta=ChatCompletionChunkDelta(),
finish_reason=constants.FINISH_REASON_STOP,
)
],
)
],
)