feat: two-phase execution, think slugs, query enrichment (v1.6.0)
Build and Push / build (release) Successful in 1m14s

Two-Phase Tatlock Execution:
- orchestrate_tool_calls() for Phase 1 coordination
- synthesize_from_results() for Phase 2 butler-toned synthesis
- Guarantees butler personality in all responses

Automatic Think Slugs:
- Deterministic butler-perspective messages during expert delegation
- ActionType enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
- HOUSEHOLD_THINK_MESSAGES mapping for all experts
- Streaming delegation wrappers with automatic think messages

Steward Query Enrichment:
- Auto-fill user context (location, timezone) when not specified
- _build_enriched_query() with regex word boundary matching
- enriched_query field in StewardRecommendation schema

Documentation:
- ORCHESTRATION_SCENARIOS.md rewritten with Mermaid diagrams
- New Housekeeper and Biographer scenarios
- TESTING_IMPROVEMENTS.md for future LLM testing patterns

🤖 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-15 14:00:32 +01:00
co-authored by Claude Opus 4.5
parent a1b8fe46e8
commit 49f0da8068
13 changed files with 1919 additions and 263 deletions
+221 -1
View File
@@ -9,13 +9,136 @@ This implements the agent-as-tool pattern recommended by PydanticAI:
agents call other agents via tool wrappers, keeping each agent focused.
"""
from dataclasses import dataclass, field
from typing import Callable, Optional, Any
from enum import Enum
from typing import AsyncGenerator, Callable, Optional, Any
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# =============================================================================
# Action Types for Think Slug Selection
# =============================================================================
class ActionType(Enum):
"""
Categories of actions for selecting appropriate think messages.
Each expert has different action types that warrant different
butler-perspective messages to the user.
"""
RETRIEVE = "retrieve" # Looking up existing information
RESEARCH = "research" # Conducting new research (web search, etc.)
CREATE = "create" # Creating new content (pages, notes)
CONTROL = "control" # Controlling devices/automations
RECORD = "record" # Recording memories/notes
# =============================================================================
# Household Think Messages (Butler's Perspective)
# =============================================================================
HOUSEHOLD_THINK_MESSAGES: dict[str, dict[ActionType, dict[str, str]]] = {
"librarian": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to consult the archives, sir.</think>",
"success": "<think>The Librarian has compiled the relevant findings.</think>",
"error": "<think>I'm afraid the archives proved difficult to access.</think>",
},
ActionType.RESEARCH: {
"start": "<think>I've dispatched the Librarian to conduct some fresh research.</think>",
"success": "<think>The Librarian has returned with findings, sir.</think>",
"error": "<think>The research proved inconclusive, I'm afraid.</think>",
},
ActionType.CREATE: {
"start": "<think>I'm having the Librarian prepare a new entry.</think>",
"success": "<think>The new material has been properly catalogued, sir.</think>",
"error": "<think>I'm afraid there was difficulty filing the entry.</think>",
},
},
"biographer": {
ActionType.RETRIEVE: {
"start": "<think>Let me consult the household records.</think>",
"success": "<think>The Biographer has located the relevant information, sir.</think>",
"error": "<think>I'm unable to locate those particular records.</think>",
},
ActionType.RECORD: {
"start": "<think>I've asked the Biographer to take note of this, sir.</think>",
"success": "<think>The household records have been updated accordingly.</think>",
"error": "<think>I'm afraid there was difficulty recording the entry.</think>",
},
},
"housekeeper": {
ActionType.RETRIEVE: {
"start": "<think>Allow me to inquire with the household staff.</think>",
"success": "<think>The staff reports the current status, sir.</think>",
"error": "<think>The household staff is momentarily unavailable, I'm afraid.</think>",
},
ActionType.CONTROL: {
"start": "<think>I'm instructing the household staff now, sir.</think>",
"success": "<think>The household has been configured as requested.</think>",
"error": "<think>I'm afraid the staff reports an issue with that request.</think>",
},
},
}
def _detect_action_type(expert: str, task: str) -> ActionType:
"""
Detect action type from expert name and task description.
Used to select appropriate butler-perspective think messages.
Args:
expert: Name of the expert (librarian, biographer, housekeeper)
task: Task description
Returns:
ActionType: Detected action type for message selection
"""
task_lower = task.lower()
if expert == "librarian":
if any(w in task_lower for w in ["search", "find", "look up", "research"]):
if any(w in task_lower for w in ["web", "online", "internet"]):
return ActionType.RESEARCH
return ActionType.RETRIEVE
if any(w in task_lower for w in ["create", "write", "add", "make", "new"]):
return ActionType.CREATE
return ActionType.RETRIEVE
elif expert == "biographer":
if any(w in task_lower for w in ["remember", "note", "record", "save", "store"]):
return ActionType.RECORD
return ActionType.RETRIEVE
elif expert == "housekeeper":
if any(w in task_lower for w in ["turn", "set", "activate", "enable", "disable", "toggle"]):
return ActionType.CONTROL
return ActionType.RETRIEVE
return ActionType.RETRIEVE
def get_think_message(expert: str, task: str, phase: str) -> str:
"""
Get the appropriate think message for an expert delegation.
Args:
expert: Name of the expert
task: Task description (used to detect action type)
phase: One of "start", "success", "error"
Returns:
str: Butler-perspective think message
"""
action_type = _detect_action_type(expert, task)
expert_messages = HOUSEHOLD_THINK_MESSAGES.get(expert, {})
action_messages = expert_messages.get(action_type, expert_messages.get(ActionType.RETRIEVE, {}))
return action_messages.get(phase, f"<think>Consulting {expert}...</think>")
@dataclass
class DelegationTask:
"""
@@ -301,6 +424,103 @@ async def delegate_to_housekeeper(
)
# =============================================================================
# Streaming Delegation Wrappers (with Think Messages)
# =============================================================================
async def stream_delegate_to_librarian(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Librarian with automatic think messages.
Yields butler-perspective think messages before and after the delegation,
allowing the UI to show progress to the user.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
# Yield start message (deterministic)
yield get_think_message("librarian", task, "start") + "\n"
# Execute delegation
result = await delegate_to_librarian(task, context)
# Yield completion message (deterministic)
if result.success:
yield get_think_message("librarian", task, "success") + "\n"
else:
yield get_think_message("librarian", task, "error") + "\n"
# Yield result marker for extraction
yield f"__DELEGATION_RESULT__:librarian:{result.output}"
async def stream_delegate_to_biographer(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Biographer with automatic think messages.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
yield get_think_message("biographer", task, "start") + "\n"
result = await delegate_to_biographer(task, context)
if result.success:
yield get_think_message("biographer", task, "success") + "\n"
else:
yield get_think_message("biographer", task, "error") + "\n"
yield f"__DELEGATION_RESULT__:biographer:{result.output}"
async def stream_delegate_to_housekeeper(
task: str,
context: str = "",
) -> AsyncGenerator[str, None]:
"""
Stream delegation to Housekeeper with automatic think messages.
Args:
task: Task description
context: Additional context
Yields:
str: Think messages and final result marker
"""
yield get_think_message("housekeeper", task, "start") + "\n"
result = await delegate_to_housekeeper(task, context)
if result.success:
yield get_think_message("housekeeper", task, "success") + "\n"
else:
yield get_think_message("housekeeper", task, "error") + "\n"
yield f"__DELEGATION_RESULT__:housekeeper:{result.output}"
# Mapping of streaming delegation wrappers
STREAMING_DELEGATION_WRAPPERS = {
"librarian": stream_delegate_to_librarian,
"biographer": stream_delegate_to_biographer,
"housekeeper": stream_delegate_to_housekeeper,
}
# Future expert delegation wrappers will be added here:
# - delegate_to_developer(task, context) -> DelegationResult
# - delegate_to_secretary(task, context) -> DelegationResult
+4
View File
@@ -60,6 +60,10 @@ class StewardRecommendation(BaseModel):
default_factory=dict,
description="Pre-fetched user context from memory (profile, preferences)"
)
enriched_query: str = Field(
default="",
description="User query with auto-filled context (location, timezone) when not specified"
)
def format_for_butler(self) -> str:
"""
+66
View File
@@ -149,6 +149,68 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
return None
def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) -> str:
"""
Build an enriched query by appending user context when not specified.
When the user asks location-dependent questions (weather, nearby, etc.)
without specifying a location, this appends their known location.
Similarly for timezone-dependent queries.
Args:
user_request: The user's original request
memory_context: Pre-fetched memory context with profile/preferences
Returns:
str: Query with context appended, or original query if no enrichment needed
Example:
>>> query = _build_enriched_query(
... "What's the weather?",
... {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
... )
>>> query
"What's the weather?\n\n[User Context: location=Amsterdam, timezone=Europe/Amsterdam]"
"""
if not memory_context:
return user_request
request_lower = user_request.lower()
profile = memory_context.get("profile", {})
preferences = memory_context.get("preferences", {})
context_parts = []
# Check if location is needed and not specified
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
# Use word boundary pattern to avoid false positives like "at" in "what"
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
if any(word in request_lower for word in location_keywords):
if not location_specified and profile.get("location"):
context_parts.append(f"location={profile['location']}")
# Check if timezone is needed and not specified
time_keywords = ["time", "schedule", "meeting", "appointment", "when", "today", "tomorrow"]
timezone_specified = any(word in request_lower for word in ["timezone", "tz", "utc", "gmt"])
if any(word in request_lower for word in time_keywords):
if not timezone_specified and profile.get("timezone"):
context_parts.append(f"timezone={profile['timezone']}")
# Add preferences if relevant
if preferences.get("temperature_unit") and "weather" in request_lower:
context_parts.append(f"temperature_unit={preferences['temperature_unit']}")
# Build enriched query
if context_parts:
context_str = ", ".join(context_parts)
return f"{user_request}\n\n[User Context: {context_str}]"
return user_request
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
"""
Pre-fetch user context that might be needed for this request.
@@ -277,6 +339,9 @@ async def analyze_request(
context = _extract_conversation_context(analysis_text, conversation_history)
missing = _extract_missing_capabilities(analysis_text)
# Build enriched query with auto-filled context
enriched_query = _build_enriched_query(user_request, memory_context)
recommendation = StewardRecommendation(
recommended_capabilities=capabilities,
reasoning=analysis_text,
@@ -284,6 +349,7 @@ async def analyze_request(
conversation_context=context,
missing_capabilities=missing,
memory_context=memory_context,
enriched_query=enriched_query,
)
# Update log context with results
+235
View File
@@ -630,6 +630,241 @@ class TatlockAgent(AgentInterface):
logger.info("tatlock_scoped_run_complete")
async def orchestrate_tool_calls(
self,
user_message: str,
steward_note: str,
scoped_tools: list[Any],
message_history: list[dict],
tool_tracker: Any = None,
) -> dict[str, Any]:
"""
Phase 1: Execute tool calls and delegations, return structured results.
This is the coordination phase where Tatlock orchestrates tool calls
and expert delegations. The raw output is captured for Phase 2 synthesis.
Args:
user_message: The user's original message
steward_note: Note from Steward (invisible to user)
scoped_tools: List of tool definitions from household registry
message_history: Conversation history
tool_tracker: Optional tool call tracker for benchmarking
Returns:
dict with:
- tools_called: List of tool names that were called
- expert_results: Dict mapping expert names to their outputs
- tool_outputs: Dict mapping tool names to their outputs
- raw_output: The agent's raw text output
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.settings import ModelSettings
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
UserPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
)
logger.info(
"tatlock_orchestrate_tool_calls",
user_message_preview=user_message[:100],
scoped_tool_count=len(scoped_tools),
history_length=len(message_history),
)
# Create a fresh agent instance with scoped tools only
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
)
# Create agent with scoped tools
scoped_agent = Agent(
ollama_model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
tools=scoped_tools,
)
# Prepend Steward's note to the request
enriched_message = f"{steward_note}\n\n{user_message}"
# Convert message history to PydanticAI format
pydantic_history = []
for msg in message_history:
role = msg.get("role")
content = msg.get("content", "")
if not content or not content.strip():
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
# Run with scoped tools and tracker
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker,
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
)
# Extract tool calls and results from the agent's messages
tools_called = []
expert_results = {}
tool_outputs = {}
# Parse through new messages to find tool calls and returns
for msg in result.new_messages():
if isinstance(msg, ModelResponse):
for part in msg.parts:
if isinstance(part, ToolCallPart):
tools_called.append(part.tool_name)
elif isinstance(msg, ModelRequest):
for part in msg.parts:
if isinstance(part, ToolReturnPart):
tool_name = part.tool_name
content = part.content
# Categorize as expert result or tool output
if tool_name.startswith("delegate_to_"):
expert_name = tool_name.replace("delegate_to_", "")
expert_results[expert_name] = content
else:
tool_outputs[tool_name] = content
logger.info(
"tatlock_orchestration_complete",
tools_called=tools_called,
expert_count=len(expert_results),
tool_output_count=len(tool_outputs),
)
return {
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": tool_outputs,
"raw_output": result.output,
}
async def synthesize_from_results(
self,
user_message: str,
orchestration_results: dict[str, Any],
message_history: list[dict],
) -> str:
"""
Phase 2: Synthesize butler-toned response from gathered results.
This is the synthesis phase where Tatlock takes the coordination
results and produces a properly butler-toned response.
Args:
user_message: The user's original message
orchestration_results: Results from orchestrate_tool_calls()
message_history: Conversation history
Returns:
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
logger.info(
"tatlock_synthesize_from_results",
user_message_preview=user_message[:100],
expert_count=len(orchestration_results.get("expert_results", {})),
tool_count=len(orchestration_results.get("tool_outputs", {})),
)
# Build synthesis prompt with all available information
synthesis_parts = []
synthesis_parts.append(f"The user asked: {user_message}")
synthesis_parts.append("")
# Add expert findings if any
if orchestration_results.get("expert_results"):
synthesis_parts.append("Expert findings:")
for expert, result in orchestration_results["expert_results"].items():
synthesis_parts.append(f"- {expert.title()}: {result}")
synthesis_parts.append("")
# Add tool outputs if any
if orchestration_results.get("tool_outputs"):
synthesis_parts.append("Tool results:")
for tool, result in orchestration_results["tool_outputs"].items():
synthesis_parts.append(f"- {tool}: {result}")
synthesis_parts.append("")
synthesis_parts.append(
"Based on this information, provide a response to the user. "
"Maintain your butler personality - address them as 'sir', "
"use formal but personable language, and be helpful."
)
synthesis_prompt = "\n".join(synthesis_parts)
# Create synthesis agent (no tools needed)
clean_host = self.ollama_host.rstrip('/')
base_url = f"{clean_host}/v1"
ollama_model = OpenAIChatModel(
model_name=self.model_name,
provider=OllamaProvider(base_url=base_url)
)
# Synthesis agent uses butler prompt but no tools
synthesis_agent = Agent(
ollama_model,
system_prompt=TATLOCK_SYSTEM_PROMPT,
# No tools for synthesis phase
)
# Convert message history to PydanticAI format
pydantic_history = []
for msg in message_history:
role = msg.get("role")
content = msg.get("content", "")
if not content or not content.strip():
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
# Run synthesis
result = await synthesis_agent.run(
synthesis_prompt,
message_history=pydantic_history if pydantic_history else None,
)
logger.info(
"tatlock_synthesis_complete",
response_preview=result.output[:100],
)
return result.output
async def get_capabilities(self) -> dict:
"""Return current capabilities."""
return {