style: apply ruff's automatic fixes and formatter

Mechanical only, and separated from the judgment calls that follow so the
reviewable changes are not buried in a 98-file whitespace diff.

227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import
blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing
imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller
modernisations. Then `ruff format` over src and tests: 98 files reformatted,
35 already conforming.

No file among the unused-import findings defines __all__ or is an __init__.py,
so nothing here removes a re-export.

`make test`: 658 passed, unchanged from HEAD.

Two things observed while verifying, neither addressed here:

`pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an
`e2e` marker that is not registered, and the config is strict about markers.
This fails identically at HEAD, so it predates this change; `make test` passes
because it ignores tests/e2e, tests/integration and tests/contracts.

test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run
with these changes and passed on the next, passes in isolation with them, and
fails in isolation at HEAD. It is order- or timing-dependent, not a regression
from this commit — established by running the full suite both ways rather than
by reasoning about which change could have caused it.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:25:18 +02:00
co-authored by Claude
parent 57fa6c13fc
commit 78066fab1b
103 changed files with 1601 additions and 1749 deletions
+4 -8
View File
@@ -6,7 +6,8 @@ must implement. The interface is designed around the Responses API format.
"""
from abc import ABC, abstractmethod
from typing import AsyncGenerator, Any
from collections.abc import AsyncGenerator
from typing import Any
class OutputItem:
@@ -19,12 +20,7 @@ class OutputItem:
- message: Assistant response message
"""
def __init__(
self,
type: str,
id: str,
**kwargs: Any
):
def __init__(self, type: str, id: str, **kwargs: Any):
self.type = type
self.id = id
self.data = kwargs
@@ -48,7 +44,7 @@ class AgentInterface(ABC):
temperature: float = 1.0,
max_tokens: int | None = None,
stop: list[str] | None = None,
**kwargs: Any
**kwargs: Any,
) -> AsyncGenerator[OutputItem, None]:
"""
Generate streaming response as output items.
+1
View File
@@ -11,6 +11,7 @@ For direct key-based lookups (location, timezone, preferences),
use the memory_service instead - it's faster and doesn't require LLM.
The Biographer handles semantic, fuzzy queries.
"""
from src.agents.biographer.agent import (
get_biographer_agent,
run_biographer,
+6 -5
View File
@@ -7,7 +7,8 @@ A PydanticAI agent that serves as the household's memory keeper:
- Manages user profile and preferences
- Forgets information when requested
"""
from typing import Any, Optional
from typing import Any
from pydantic_ai import Agent
@@ -19,7 +20,6 @@ from src.agents.biographer.tools import (
update_preference,
update_profile,
)
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -97,7 +97,7 @@ When recalling:
"""
# Lazy initialization to avoid connection issues during imports
_biographer_agent: Optional[Agent[None, str]] = None
_biographer_agent: Agent[None, str] | None = None
def _create_biographer_agent() -> Agent[None, str]:
@@ -126,6 +126,7 @@ def _create_biographer_agent() -> Agent[None, str]:
agent.tool_plain(forget_memory)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"biographer_agent_created",
@@ -153,7 +154,7 @@ def get_biographer_agent() -> Agent[None, str]:
async def run_biographer(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> str:
"""
Execute a memory task with The Biographer.
@@ -216,7 +217,7 @@ async def run_biographer(
async def run_biographer_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
):
"""
Execute a memory task with streaming output.
+1
View File
@@ -4,6 +4,7 @@ Biographer capability registration for the Household Registry.
Defines The Biographer's capabilities and registers it as a
household member for coordination by the Steward and Tatlock.
"""
from src.agents.biographer.agent import get_biographer_agent
from src.agents.biographer.tools import BIOGRAPHER_TOOLS
from src.core.household_registry import (
+9 -4
View File
@@ -10,6 +10,7 @@ These tools enable The Biographer to record and recall the user's story:
For direct key-based access (get/set profile, preferences),
use memory_service directly - these tools are for semantic queries.
"""
from src.core.context import get_user
from src.core.embeddings import get_embedding_client
from src.core.logging_config import get_logger
@@ -23,6 +24,7 @@ logger = get_logger(__name__)
# Semantic Recall
# ============================================================================
async def recall_semantic(
query: str,
memory_type: str = "",
@@ -108,6 +110,7 @@ async def recall_semantic(
# Store Memory
# ============================================================================
async def store_insight(
key: str,
value: str,
@@ -158,7 +161,7 @@ async def store_insight(
f"**Keywords:** {', '.join(keywords)}",
f"**Importance:** {importance:.1f}",
"",
"_Memory is now searchable via semantic recall._"
"_Memory is now searchable via semantic recall._",
]
logger.info(
@@ -216,7 +219,7 @@ async def update_profile(
"## Profile Updated",
f"**{key}:** {value}",
"",
"_Profile data is automatically included in context._"
"_Profile data is automatically included in context._",
]
logger.info(
@@ -271,7 +274,7 @@ async def update_preference(
"## Preference Updated",
f"**{key}:** {value}",
"",
"_Preference will be applied to future responses._"
"_Preference will be applied to future responses._",
]
logger.info(
@@ -293,6 +296,7 @@ async def update_preference(
# List Memories
# ============================================================================
async def list_memories(
memory_type: str = "learned_fact",
limit: int = 20,
@@ -378,6 +382,7 @@ async def list_memories(
# Forget Memory
# ============================================================================
async def forget_memory(
key: str,
memory_type: str = "learned_fact",
@@ -420,7 +425,7 @@ async def forget_memory(
f"**Key:** {key}",
f"**Type:** {memory_type}",
"",
"_Memory has been removed._"
"_Memory has been removed._",
]
logger.info(
+13 -10
View File
@@ -8,6 +8,7 @@ returns a structured result for synthesis.
This implements the agent-as-tool pattern recommended by PydanticAI:
agents call other agents via tool wrappers, keeping each agent focused.
"""
import asyncio
from dataclasses import dataclass, field
from enum import Enum
@@ -23,6 +24,7 @@ logger = get_logger(__name__)
# Action Types for Think Slug Selection
# =============================================================================
class ActionType(Enum):
"""
Categories of actions for selecting appropriate think messages.
@@ -30,11 +32,12 @@ class ActionType(Enum):
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
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
# =============================================================================
@@ -159,8 +162,7 @@ def build_delegation_context(
if isinstance(content, list):
# Tolerate structured content parts
content = " ".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
)
content = str(content).strip()
if content:
@@ -206,6 +208,7 @@ class DelegationTask:
depends_on: List of task IDs this task depends on
result: Result from expert after execution
"""
expert_name: str
task: str
context: str = ""
@@ -219,6 +222,7 @@ class DelegationTask:
"""Generate task ID if not provided."""
if not self.task_id:
import uuid
self.task_id = f"{self.expert_name}_{uuid.uuid4().hex[:8]}"
@@ -236,6 +240,7 @@ class DelegationResult:
error: Short user-safe error label if failed. Exception detail
stays in the logs only
"""
expert_name: str
task: str
success: bool
@@ -335,9 +340,7 @@ async def delegate_to_librarian(
if span:
span.metadata["success"] = False
span.details["error"] = (
f"timed out after {config.LIBRARIAN_TIMEOUT}s"
)
span.details["error"] = f"timed out after {config.LIBRARIAN_TIMEOUT}s"
return DelegationResult(
expert_name="librarian",
+1
View File
@@ -4,6 +4,7 @@ The Housekeeper - Home Automation Agent.
Provides home automation capabilities through the core-api service,
which wraps the Home Assistant REST API into LLM-friendly endpoints.
"""
from src.agents.housekeeper.agent import run_housekeeper, run_housekeeper_stream
from src.agents.housekeeper.capability import (
HOUSEKEEPER_CAPABILITY,
+6 -5
View File
@@ -8,7 +8,8 @@ the core-api service, which wraps Home Assistant REST API, offering:
- Script execution
- Automation management
"""
from typing import Any, Optional
from typing import Any
from pydantic_ai import Agent
@@ -27,7 +28,6 @@ from src.agents.housekeeper.tools import (
turn_off,
turn_on,
)
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -98,7 +98,7 @@ After completing actions, briefly confirm:
"""
# Lazy initialization to avoid connection issues during imports
_housekeeper_agent: Optional[Agent[None, str]] = None
_housekeeper_agent: Agent[None, str] | None = None
def _create_housekeeper_agent() -> Agent[None, str]:
@@ -140,6 +140,7 @@ def _create_housekeeper_agent() -> Agent[None, str]:
agent.tool_plain(get_history)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"housekeeper_agent_created",
@@ -167,7 +168,7 @@ def get_housekeeper_agent() -> Agent[None, str]:
async def run_housekeeper(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> str:
"""
Execute a home automation task with The Housekeeper.
@@ -234,7 +235,7 @@ async def run_housekeeper(
async def run_housekeeper_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
):
"""
Execute a home automation task with streaming output.
+1
View File
@@ -4,6 +4,7 @@ Housekeeper capability registration for the Household Registry.
Defines The Housekeeper's capabilities and registers it as a
household member for coordination by the Steward and Tatlock.
"""
from src.agents.housekeeper.agent import get_housekeeper_agent
from src.agents.housekeeper.tools import HOUSEKEEPER_TOOLS
from src.core.household_registry import (
+19 -18
View File
@@ -5,7 +5,8 @@ Provides async methods for home automation operations via Home Assistant.
Core-API is a separate service that wraps the Home Assistant REST API
into LLM-friendly endpoints.
"""
from typing import Any, Optional
from typing import Any
import httpx
from pydantic import BaseModel, Field
@@ -28,7 +29,7 @@ class Device(BaseModel):
name: str
state: str
domain: str
area: Optional[str] = None
area: str | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
@@ -38,8 +39,8 @@ class DeviceState(BaseModel):
entity_id: str
state: str
attributes: dict[str, Any] = Field(default_factory=dict)
last_changed: Optional[str] = None
last_updated: Optional[str] = None
last_changed: str | None = None
last_updated: str | None = None
class Scene(BaseModel):
@@ -47,7 +48,7 @@ class Scene(BaseModel):
entity_id: str
name: str
friendly_name: Optional[str] = None
friendly_name: str | None = None
class Script(BaseModel):
@@ -55,8 +56,8 @@ class Script(BaseModel):
entity_id: str
name: str
description: Optional[str] = None
last_triggered: Optional[str] = None
description: str | None = None
last_triggered: str | None = None
class Automation(BaseModel):
@@ -64,8 +65,8 @@ class Automation(BaseModel):
entity_id: str
name: str
state: str = "on"
last_triggered: Optional[str] = None
state: str = "on"
last_triggered: str | None = None
class HistoryEntry(BaseModel):
@@ -109,8 +110,8 @@ class CoreAPIClient:
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
base_url: str | None = None,
api_key: str | None = None,
timeout: int = 30,
):
"""
@@ -124,7 +125,7 @@ class CoreAPIClient:
self.base_url = base_url or str(config.CORE_API_HOST)
self.api_key = api_key or config.CORE_API_KEY
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "CoreAPIClient":
"""Create HTTP client on context entry."""
@@ -159,8 +160,8 @@ class CoreAPIClient:
async def list_devices(
self,
domain: Optional[str] = None,
area: Optional[str] = None,
domain: str | None = None,
area: str | None = None,
) -> list[Device]:
"""
List devices, optionally filtered by domain or area.
@@ -231,9 +232,9 @@ class CoreAPIClient:
async def turn_on(
self,
entity_id: str,
brightness: Optional[int] = None,
color_temp: Optional[int] = None,
rgb_color: Optional[tuple[int, int, int]] = None,
brightness: int | None = None,
color_temp: int | None = None,
rgb_color: tuple[int, int, int] | None = None,
) -> ControlResult:
"""
Turn on a device.
@@ -399,7 +400,7 @@ class CoreAPIClient:
async def run_script(
self,
script_id: str,
variables: Optional[dict[str, Any]] = None,
variables: dict[str, Any] | None = None,
) -> ControlResult:
"""
Run a script.
+14 -3
View File
@@ -4,6 +4,7 @@ Housekeeper tools for PydanticAI agent.
These tools wrap the core-api service and are registered with
The Housekeeper agent for home automation tasks.
"""
from src.agents.housekeeper.client import CoreAPIClient
from src.core.logging_config import get_logger
@@ -72,14 +73,24 @@ async def list_devices(
return True
return False
sorted_devices = sorted(dom_devices, key=lambda d: (not is_room_group(d), d.entity_id))
sorted_devices = sorted(
dom_devices, key=lambda d: (not is_room_group(d), d.entity_id)
)
for device in sorted_devices:
state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state
state_icon = (
"on"
if device.state == "on"
else "off"
if device.state == "off"
else device.state
)
area_str = f" ({device.area})" if device.area else ""
# Mark room groups clearly using actual HA data
group_marker = " [ROOM GROUP]" if is_room_group(device) else ""
output_parts.append(f"- **{device.name}**{area_str}{group_marker}: {state_icon}")
output_parts.append(
f"- **{device.name}**{area_str}{group_marker}: {state_icon}"
)
output_parts.append(f" ID: `{device.entity_id}`")
output_parts.append("")
+1
View File
@@ -7,6 +7,7 @@ Connects to the library-desk API to provide:
- Knowledge graph queries
- Semantic search
"""
from src.agents.librarian.agent import (
get_librarian_agent,
run_librarian,
+3 -3
View File
@@ -7,6 +7,7 @@ the library-desk API, offering:
- Wiki and document management
- Semantic search and knowledge graph exploration
"""
from typing import Any
from pydantic_ai import Agent
@@ -202,6 +203,7 @@ def _create_librarian_agent() -> Agent[None, str]:
agent.tool_plain(smart_create_wiki_page)
from src.anthropic.model_selector import get_model_info
model_info = get_model_info()
logger.info(
"librarian_agent_created",
@@ -294,6 +296,4 @@ async def run_librarian(
error=str(e),
exc_info=True,
)
raise AgentError(
"Research task failed", agent_name="librarian"
) from e
raise AgentError("Research task failed", agent_name="librarian") from e
+1
View File
@@ -4,6 +4,7 @@ Librarian capability registration for the Household Registry.
Defines The Librarian's capabilities and registers it as a
household member for coordination by the Steward and Tatlock.
"""
from src.agents.librarian.agent import get_librarian_agent
from src.agents.librarian.tools import LIBRARIAN_TOOLS
from src.core.household_registry import (
+33 -12
View File
@@ -7,6 +7,7 @@ Provides async methods for all relevant library-desk endpoints:
- Vector search
- Knowledge graph queries
"""
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
@@ -38,8 +39,10 @@ _shared_http_client: ContextVar[httpx.AsyncClient | None] = ContextVar(
# Response Models
# ============================================================================
class WikiPage(BaseModel):
"""Wiki page from library-desk."""
id: int
path: str
title: str
@@ -52,6 +55,7 @@ class WikiPage(BaseModel):
class WikiSearchResult(BaseModel):
"""Search result from wiki search."""
id: int
path: str
title: str
@@ -61,6 +65,7 @@ class WikiSearchResult(BaseModel):
class VectorSearchResult(BaseModel):
"""Result from semantic vector search."""
page_id: int
page_path: str
page_title: str
@@ -71,8 +76,11 @@ class VectorSearchResult(BaseModel):
class HybridSearchResult(BaseModel):
"""Result from HybridRAG search."""
source: str # source_type: "wiki", "web", "volatile", "document"
sources: list[str] = Field(default_factory=list) # legs that found it: "vector", "graph", "web", ...
sources: list[str] = Field(
default_factory=list
) # legs that found it: "vector", "graph", "web", ...
title: str
content: str
url: str | None = None
@@ -84,6 +92,7 @@ class HybridSearchResult(BaseModel):
class HybridRAGResponse(BaseModel):
"""Full response from HybridRAG query."""
results: list[HybridSearchResult] = Field(default_factory=list)
keywords: list[str] = Field(default_factory=list)
synonyms: list[str] = Field(default_factory=list)
@@ -102,6 +111,7 @@ class HybridRAGResponse(BaseModel):
class GraphNode(BaseModel):
"""Node from knowledge graph."""
id: str
labels: list[str] = Field(default_factory=list)
properties: dict[str, Any] = Field(default_factory=dict)
@@ -109,12 +119,14 @@ class GraphNode(BaseModel):
class Dossier(BaseModel):
"""A dossier (tag-based collection)."""
name: str
page_count: int
class ResearchSummary(BaseModel):
"""Summary of research performed during smart-create."""
wiki_results: int = 0
web_results: int = 0
graph_entities: int = 0
@@ -124,6 +136,7 @@ class ResearchSummary(BaseModel):
class WebSearchResult(BaseModel):
"""Result from web search via /rag/search."""
title: str
url: str
content: str = "" # Full extracted text via Trafilatura
@@ -134,6 +147,7 @@ class WebSearchResult(BaseModel):
class WebSearchResponse(BaseModel):
"""Response from /rag/search endpoint."""
query: str
search_type: str
results: list[WebSearchResult] = Field(default_factory=list)
@@ -144,6 +158,7 @@ class WebSearchResponse(BaseModel):
class ContentExtractionResult(BaseModel):
"""Result from content extraction."""
url: str
title: str | None = None
content: str = ""
@@ -156,6 +171,7 @@ class ContentExtractionResult(BaseModel):
class BatchExtractionResponse(BaseModel):
"""Response from batch content extraction."""
results: list[ContentExtractionResult] = Field(default_factory=list)
total_urls: int = 0
successful: int = 0
@@ -165,6 +181,7 @@ class BatchExtractionResponse(BaseModel):
class EntityLinking(BaseModel):
"""Entity linking results from smart-create."""
forward_links: int = 0
backward_links: int = 0
pages_updated: int = 0
@@ -172,6 +189,7 @@ class EntityLinking(BaseModel):
class SmartCreateResponse(BaseModel):
"""Response from smart-create wiki page endpoint."""
page: WikiPage
research_summary: ResearchSummary = Field(default_factory=ResearchSummary)
sources_used: int = 0
@@ -183,6 +201,7 @@ class SmartCreateResponse(BaseModel):
# Client
# ============================================================================
class LibraryDeskClient:
"""
Async HTTP client for Library-Desk API.
@@ -398,17 +417,19 @@ class LibraryDeskClient:
# related_dossiers; older names kept as fallbacks)
results = []
for r in data.get("results", []):
results.append(HybridSearchResult(
source=r.get("source_type") or r.get("source", "unknown"),
sources=r.get("sources", []),
title=r.get("title", ""),
content=r.get("content", ""),
url=r.get("url"),
score=r.get("rrf_score", r.get("score", 0.0)),
page_id=r.get("page_id"),
related_dossiers=r.get("related_dossiers", []),
metadata=r.get("metadata", {}),
))
results.append(
HybridSearchResult(
source=r.get("source_type") or r.get("source", "unknown"),
sources=r.get("sources", []),
title=r.get("title", ""),
content=r.get("content", ""),
url=r.get("url"),
score=r.get("rrf_score", r.get("score", 0.0)),
page_id=r.get("page_id"),
related_dossiers=r.get("related_dossiers", []),
metadata=r.get("metadata", {}),
)
)
# Handle keywords being either a list or a dict with core_keywords;
# the live service nests synonyms inside the keywords dict as a
+21 -20
View File
@@ -4,6 +4,7 @@ Librarian tools for PydanticAI agent.
These tools wrap the library-desk API and are registered with
The Librarian agent for research and knowledge management tasks.
"""
import httpx
from pydantic_ai import ModelRetry
@@ -27,9 +28,8 @@ def _retry_if_transient(e: Exception, what: str) -> None:
status = e.response.status_code
retryable = status >= 500 or status == 429
if retryable:
raise ModelRetry(
f"{what} is temporarily unavailable; please retry."
) from e
raise ModelRetry(f"{what} is temporarily unavailable; please retry.") from e
# Icons keyed by the values library-desk emits in each result's `sources`
# list (search legs) and `source_type` (result origin).
@@ -64,11 +64,7 @@ def _coverage_note(
results, so their absence is normal ranking behavior, not an outage.
"""
if response.source_status:
failed = sorted(
leg
for leg, status in response.source_status.items()
if status == "failed"
)
failed = sorted(leg for leg, status in response.source_status.items() if status == "failed")
if failed:
return (
"⚠️ *Coverage note: results are partial - "
@@ -111,6 +107,7 @@ def _coverage_note(
# HybridRAG Search
# ============================================================================
async def hybrid_search(
query: str,
include_web: bool = True,
@@ -165,9 +162,7 @@ async def hybrid_search(
# Add related dossiers
if response.related_dossiers:
output_parts.append(
f"**Related Dossiers:** {', '.join(response.related_dossiers)}"
)
output_parts.append(f"**Related Dossiers:** {', '.join(response.related_dossiers)}")
output_parts.append("")
@@ -216,6 +211,7 @@ async def hybrid_search(
# Wiki Operations
# ============================================================================
async def search_wiki(
query: str,
limit: int = 10,
@@ -331,9 +327,7 @@ async def list_dossiers() -> str:
output_parts = ["## Research Dossiers\n"]
for dossier in dossiers:
output_parts.append(
f"- **{dossier.name}** ({dossier.page_count} pages)"
)
output_parts.append(f"- **{dossier.name}** ({dossier.page_count} pages)")
return "\n".join(output_parts)
@@ -389,6 +383,7 @@ async def get_dossier_pages(
# Semantic Search
# ============================================================================
async def semantic_search(
query: str,
limit: int = 10,
@@ -420,9 +415,7 @@ async def semantic_search(
output_parts = [f"## Semantic Search: {query}\n"]
for i, result in enumerate(results, 1):
output_parts.append(
f"{i}. **{result.page_title}** (score: {result.score:.2f})"
)
output_parts.append(f"{i}. **{result.page_title}** (score: {result.score:.2f})")
output_parts.append(f" Path: {result.page_path}")
output_parts.append(f" {result.chunk_text[:200]}...")
output_parts.append("")
@@ -439,6 +432,7 @@ async def semantic_search(
# Knowledge Graph
# ============================================================================
async def explore_knowledge_graph(
entity_type: str = "Document",
limit: int = 20,
@@ -565,6 +559,7 @@ async def find_related_entities(
# Web Search & Content Extraction
# ============================================================================
async def search_web(
query: str,
limit: int = 10,
@@ -605,7 +600,9 @@ async def search_web(
return f"No results found for '{query}'"
output_parts = [f"## Web Search: {query}\n"]
output_parts.append(f"*Found {response.total_results} results in {response.search_time_ms}ms*\n")
output_parts.append(
f"*Found {response.total_results} results in {response.search_time_ms}ms*\n"
)
for i, result in enumerate(response.results, 1):
output_parts.append(f"### {i}. {result.title}")
@@ -880,7 +877,9 @@ async def update_wiki_page(
if page.tags:
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
output_parts.append(
"\n*Vector embeddings and knowledge graph will be updated automatically.*"
)
logger.info(
"librarian_update_page",
@@ -954,7 +953,9 @@ async def create_wiki_page(
if page.description:
output_parts.append(f"**Description:** {page.description}")
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
output_parts.append(
"\n*Vector embeddings and knowledge graph will be updated automatically.*"
)
logger.info(
"librarian_create_page",
+25 -48
View File
@@ -12,16 +12,16 @@ infrastructure is real production code.
import asyncio
import random
import secrets
from typing import AsyncGenerator, Any
from collections.abc import AsyncGenerator
from typing import Any
from src.agents.base import AgentInterface, OutputItem
from src.core.exceptions import (
RateLimitError,
ContextLengthError,
APIError,
ContextLengthError,
RateLimitError,
)
# Mock lorem ipsum content
LOREM_PARAGRAPHS = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
@@ -46,33 +46,27 @@ MOCK_TOOLS = [
"description": "Search the knowledge base for relevant information",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
"properties": {"query": {"type": "string", "description": "Search query"}},
"required": ["query"],
},
},
{
"name": "calculate",
"description": "Perform mathematical calculations",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression"}
},
"required": ["expression"]
}
"properties": {"expression": {"type": "string", "description": "Math expression"}},
"required": ["expression"],
},
},
{
"name": "get_weather",
"description": "Get current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"}
},
"required": ["location"]
}
"properties": {"location": {"type": "string", "description": "City name"}},
"required": ["location"],
},
},
]
@@ -109,7 +103,7 @@ class LoremTesterAgent(AgentInterface):
temperature: float = 1.0,
max_tokens: int | None = None,
stop: list[str] | None = None,
**kwargs: Any
**kwargs: Any,
) -> AsyncGenerator[OutputItem, None]:
"""
Generate mock response with reasoning, tools, and content.
@@ -123,8 +117,7 @@ class LoremTesterAgent(AgentInterface):
# 1. Yield reasoning item if requested
if reasoning and reasoning.get("summary") == "auto":
yield await self._create_reasoning_item(
messages,
effort=reasoning.get("effort", "medium")
messages, effort=reasoning.get("effort", "medium")
)
# 2. Randomly yield function calls if tools available (30% chance)
@@ -150,7 +143,7 @@ class LoremTesterAgent(AgentInterface):
"reasoning": True,
"tools": True,
"vision": False, # Not yet
"audio": False, # Not yet
"audio": False, # Not yet
}
# Private helper methods
@@ -179,9 +172,7 @@ class LoremTesterAgent(AgentInterface):
raise APIError("Invalid tool call: tool 'nonexistent' not found (mock trigger)")
async def _create_reasoning_item(
self,
messages: list[dict],
effort: str = "medium"
self, messages: list[dict], effort: str = "medium"
) -> OutputItem:
"""Create a reasoning output item with mock thinking steps."""
@@ -200,23 +191,17 @@ class LoremTesterAgent(AgentInterface):
steps = random.sample(REASONING_STEPS, min(num_steps, len(REASONING_STEPS)))
return OutputItem(
type="reasoning",
id=f"rs_{generate_id()}",
summary=steps,
status="completed"
type="reasoning", id=f"rs_{generate_id()}", summary=steps, status="completed"
)
async def _create_tool_calls(
self,
tools: list[dict]
) -> AsyncGenerator[OutputItem, None]:
async def _create_tool_calls(self, tools: list[dict]) -> AsyncGenerator[OutputItem, None]:
"""Create mock function call output items."""
# Randomly select 1-2 tools to "call"
num_calls = random.randint(1, 2)
selected_tools = random.sample(
MOCK_TOOLS[:min(len(MOCK_TOOLS), len(tools))],
min(num_calls, len(MOCK_TOOLS), len(tools))
MOCK_TOOLS[: min(len(MOCK_TOOLS), len(tools))],
min(num_calls, len(MOCK_TOOLS), len(tools)),
)
for tool in selected_tools:
@@ -228,7 +213,7 @@ class LoremTesterAgent(AgentInterface):
id=f"fc_{generate_id()}",
name=tool["name"],
arguments=args,
status="completed"
status="completed",
)
def _generate_mock_args(self, tool: dict) -> str:
@@ -254,11 +239,7 @@ class LoremTesterAgent(AgentInterface):
# Generic mock arguments
return json.dumps({"input": "mock_value"})
async def _create_message_item(
self,
messages: list[dict],
temperature: float
) -> OutputItem:
async def _create_message_item(self, messages: list[dict], temperature: float) -> OutputItem:
"""Create final message output item with lorem ipsum content."""
# Select random lorem ipsum paragraphs
@@ -270,10 +251,6 @@ class LoremTesterAgent(AgentInterface):
type="message",
id=f"msg_{generate_id()}",
role="assistant",
content=[{
"type": "output_text",
"text": content,
"annotations": []
}],
status="completed"
content=[{"type": "output_text", "text": content, "annotations": []}],
status="completed",
)
+20 -11
View File
@@ -14,12 +14,13 @@ Supports:
- Result aggregation from multiple experts
- Partial failure handling
"""
import asyncio
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from enum import Enum
from typing import AsyncGenerator, Optional, Callable, Any
from src.agents.delegation import DelegationTask, DelegationResult, delegate_to_librarian
from src.agents.delegation import DelegationResult, DelegationTask, delegate_to_librarian
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -27,8 +28,9 @@ logger = get_logger(__name__)
class ExecutionMode(str, Enum):
"""Execution mode for multi-expert coordination."""
SEQUENTIAL = "sequential" # One at a time, in order
PARALLEL = "parallel" # All at once, concurrently
PARALLEL = "parallel" # All at once, concurrently
@dataclass
@@ -38,12 +40,13 @@ class OrchestrationContext:
Tracks the user's request, delegation tasks, and results.
"""
user_message: str
steward_note: str
conversation_id: Optional[str] = None
conversation_id: str | None = None
def parse_delegation_from_steward_note(steward_note: str) -> Optional[DelegationTask]:
def parse_delegation_from_steward_note(steward_note: str) -> DelegationTask | None:
"""
Parse a delegation task from Steward's note.
@@ -68,9 +71,9 @@ def parse_delegation_from_steward_note(steward_note: str) -> Optional[Delegation
# Look for DELEGATE: pattern
# Match: "DELEGATE: expert_name to action description"
match = re.search(
r'DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)',
r"DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)",
steward_note,
re.IGNORECASE | re.MULTILINE
re.IGNORECASE | re.MULTILINE,
)
if match:
@@ -135,7 +138,7 @@ async def execute_delegation(
async def orchestrate_with_think_updates(
user_message: str,
steward_note: str,
delegation_task: Optional[DelegationTask] = None,
delegation_task: DelegationTask | None = None,
) -> AsyncGenerator[str, None]:
"""
Orchestrate expert delegation with streaming think updates.
@@ -218,17 +221,21 @@ def extract_delegation_context(
}
# Extract REASON:
reason_match = re.search(r'REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)', steward_note, re.IGNORECASE)
reason_match = re.search(
r"REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)", steward_note, re.IGNORECASE
)
if reason_match:
result["reason"] = reason_match.group(1).strip()
# Extract COMPLEXITY:
complexity_match = re.search(r'COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)', steward_note, re.IGNORECASE)
complexity_match = re.search(
r"COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)", steward_note, re.IGNORECASE
)
if complexity_match:
result["complexity"] = complexity_match.group(1).strip()
# Extract CONTEXT:
context_match = re.search(r'CONTEXT:\s*(.+?)$', steward_note, re.IGNORECASE | re.MULTILINE)
context_match = re.search(r"CONTEXT:\s*(.+?)$", steward_note, re.IGNORECASE | re.MULTILINE)
if context_match:
result["context"] = context_match.group(1).strip()
@@ -239,6 +246,7 @@ def extract_delegation_context(
# Multi-Expert Coordination
# ============================================================================
@dataclass
class MultiExpertResult:
"""
@@ -250,6 +258,7 @@ class MultiExpertResult:
failed_experts: List of expert names that failed
combined_output: Aggregated output from all successful experts
"""
results: dict[str, DelegationResult] = field(default_factory=dict)
all_succeeded: bool = True
failed_experts: list[str] = field(default_factory=list)
+11 -12
View File
@@ -8,9 +8,6 @@ It provides a central place to:
- Check model capabilities
"""
import time
from typing import Type
from src.agents.base import AgentInterface
from src.agents.lorem_tester import LoremTesterAgent
from src.agents.tatlock import TatlockAgent
@@ -63,7 +60,7 @@ class ModelRegistry:
if model_id not in cls.MODELS:
raise ModelNotFoundError(model_id)
agent_class: Type[AgentInterface] = cls.MODELS[model_id]["agent_class"]
agent_class: type[AgentInterface] = cls.MODELS[model_id]["agent_class"]
return agent_class()
@classmethod
@@ -106,14 +103,16 @@ class ModelRegistry:
agent = cls.get_agent(model_id)
capabilities = await agent.get_capabilities()
models.append({
"id": model_id,
"object": "model",
"created": config["created"],
"owned_by": config["owned_by"],
"capabilities": capabilities,
"description": config["description"],
})
models.append(
{
"id": model_id,
"object": "model",
"created": config["created"],
"owned_by": config["owned_by"],
"capabilities": capabilities,
"description": config["description"],
}
)
return models
+1
View File
@@ -4,6 +4,7 @@ Steward agent package.
The Steward analyzes incoming requests and recommends relevant household
capabilities, creating a two-tier architecture with the Butler.
"""
from .agent import StewardAgent, get_steward_agent
from .schemas import ConversationContext, StewardRecommendation
from .service import analyze_request, format_steward_note
+8 -13
View File
@@ -8,8 +8,8 @@ This creates a two-tier architecture that prevents cognitive overload.
Uses plain text output (not JSON) for reliability. Supports both Claude
(preferred) and Ollama (fallback) backends via direct API calls.
"""
import httpx
from typing import Optional
from src.anthropic.model_selector import get_model_info, is_claude_available, resolve_backend
from src.core.config import config
@@ -29,9 +29,7 @@ def build_steward_prompt(query: str, conversation_history: list[dict]) -> str:
cap_list = []
for cap in capabilities:
cap_list.append(
f"{cap.name} - {cap.description} (domains: {', '.join(cap.domains)})"
)
cap_list.append(f"{cap.name} - {cap.description} (domains: {', '.join(cap.domains)})")
capabilities_text = "\n".join(cap_list)
# Format conversation history if present
@@ -114,7 +112,7 @@ class StewardAgent:
def __init__(self):
"""Initialize Steward with backend selection based on availability."""
# Ollama config (primary)
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
self.ollama_host = str(config.OLLAMA_HOST).rstrip("/")
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
# Claude config (fallback)
@@ -139,6 +137,7 @@ class StewardAgent:
"""Get or create Anthropic client (lazy initialization)."""
if self._anthropic_client is None:
from anthropic import AsyncAnthropic
self._anthropic_client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
return self._anthropic_client
@@ -167,20 +166,16 @@ class StewardAgent:
"stream": False,
"options": {
"temperature": 0.3, # Lower = more consistent
"top_p": 0.9
}
}
"top_p": 0.9,
},
},
)
response.raise_for_status()
result = response.json()
return result["response"].strip()
async def analyze(
self,
query: str,
conversation_history: Optional[list[dict]] = None
) -> str:
async def analyze(self, query: str, conversation_history: list[dict] | None = None) -> str:
"""
Analyze query and return plain text recommendation.
+14 -13
View File
@@ -4,7 +4,8 @@ Steward agent schemas.
Defines the structured output models for Steward's request analysis
and capability recommendations.
"""
from typing import Any, Literal, Optional
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -16,16 +17,16 @@ class ConversationContext(BaseModel):
The Steward analyzes the full conversation to identify references
to previous topics, helping the Butler maintain context.
"""
has_previous_context: bool = Field(
description="Whether the current request references previous conversation turns"
)
relevant_turns: list[int] = Field(
default_factory=list,
description="0-indexed turn numbers that are relevant to the current request"
description="0-indexed turn numbers that are relevant to the current request",
)
context_summary: str = Field(
default="",
description="Brief summary of relevant context for the Butler"
default="", description="Brief summary of relevant context for the Butler"
)
@@ -40,29 +41,28 @@ class StewardRecommendation(BaseModel):
- Conversation context
- Missing capabilities (if any)
"""
recommended_capabilities: list[str] = Field(
description="List of household member names to include (e.g., ['tatlock_core'])"
)
reasoning: str = Field(
description="Explanation of why these capabilities were recommended"
)
reasoning: str = Field(description="Explanation of why these capabilities were recommended")
estimated_complexity: Literal["simple", "moderate", "complex"] = Field(
description="Complexity assessment: simple (1 tool), moderate (2-3 tools), complex (multiple tools/steps)"
)
conversation_context: ConversationContext = Field(
description="Contextual information from conversation history"
)
missing_capabilities: Optional[str] = Field(
missing_capabilities: str | None = Field(
default=None,
description="Description of capabilities that would be helpful but aren't available"
description="Description of capabilities that would be helpful but aren't available",
)
memory_context: dict[str, Any] = Field(
default_factory=dict,
description="Pre-fetched user context from memory (profile, preferences)"
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"
description="User query with auto-filled context (location, timezone) when not specified",
)
def format_for_butler(self) -> str:
@@ -114,8 +114,9 @@ class StewardRecommendation(BaseModel):
lines.append(f" • preferences: {prefs_str}")
# Add delegation instructions when expert agents are recommended
delegation_agents = [c for c in self.recommended_capabilities
if c in ("biographer", "librarian")]
delegation_agents = [
c for c in self.recommended_capabilities if c in ("biographer", "librarian")
]
if delegation_agents:
lines.append("-" * 40)
lines.append("DELEGATION REQUIRED:")
+83 -45
View File
@@ -7,12 +7,14 @@ and error handling.
Parses plain text recommendations into structured data.
Includes memory pre-fetch for user context injection.
"""
import re
from typing import Any, Optional
from typing import Any
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger, log_operation
from src.core.memory_service import memory_service
from .agent import get_steward_agent
from .schemas import ConversationContext, StewardRecommendation
@@ -126,8 +128,7 @@ def _extract_complexity(text: str) -> str:
def _extract_conversation_context(
text: str,
conversation_history: list[dict]
text: str, conversation_history: list[dict]
) -> ConversationContext:
"""
Extract conversation context analysis from text.
@@ -142,13 +143,15 @@ def _extract_conversation_context(
text_lower = text.lower()
# Check if conversation history is referenced
has_context = bool(conversation_history) and any([
"previous" in text_lower,
"earlier" in text_lower,
"context" in text_lower,
"turn" in text_lower,
"history" in text_lower,
])
has_context = bool(conversation_history) and any(
[
"previous" in text_lower,
"earlier" in text_lower,
"context" in text_lower,
"turn" in text_lower,
"history" in text_lower,
]
)
# Extract turn numbers if mentioned (e.g., "turn 0", "turn 1")
relevant_turns = []
@@ -160,21 +163,23 @@ def _extract_conversation_context(
context_summary = ""
if has_context:
# Extract sentence(s) mentioning context
sentences = text.split('.')
context_sentences = [s for s in sentences if any(
word in s.lower() for word in ["previous", "earlier", "context", "history"]
)]
sentences = text.split(".")
context_sentences = [
s
for s in sentences
if any(word in s.lower() for word in ["previous", "earlier", "context", "history"])
]
if context_sentences:
context_summary = context_sentences[0].strip()
return ConversationContext(
has_previous_context=has_context,
relevant_turns=relevant_turns,
context_summary=context_summary
context_summary=context_summary,
)
def _extract_missing_capabilities(text: str) -> Optional[str]:
def _extract_missing_capabilities(text: str) -> str | None:
"""
Extract missing capability notes from text.
@@ -187,15 +192,16 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
text_lower = text.lower()
# Look for indicators of missing capabilities
if any(word in text_lower for word in [
"missing", "unavailable", "not available", "don't have", "doesn't have"
]):
if any(
word in text_lower
for word in ["missing", "unavailable", "not available", "don't have", "doesn't have"]
):
# Find the sentence mentioning missing capabilities
sentences = text.split('.')
sentences = text.split(".")
for sentence in sentences:
if any(word in sentence.lower() for word in [
"missing", "unavailable", "not available"
]):
if any(
word in sentence.lower() for word in ["missing", "unavailable", "not available"]
):
return sentence.strip()
return None
@@ -236,7 +242,7 @@ def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) ->
# 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_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):
@@ -287,32 +293,65 @@ async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
profile_keys = []
# Location-related queries
if any(word in request_lower for word in [
"weather", "temperature", "forecast", "nearby", "local",
"directions", "distance", "map", "here",
# Direct location questions
"live", "where", "home", "reside", "location", "address",
]):
if any(
word in request_lower
for word in [
"weather",
"temperature",
"forecast",
"nearby",
"local",
"directions",
"distance",
"map",
"here",
# Direct location questions
"live",
"where",
"home",
"reside",
"location",
"address",
]
):
profile_keys.append("location")
# Time-related queries
if any(word in request_lower for word in [
"time", "schedule", "meeting", "appointment", "reminder",
"alarm", "when", "today", "tomorrow"
]):
if any(
word in request_lower
for word in [
"time",
"schedule",
"meeting",
"appointment",
"reminder",
"alarm",
"when",
"today",
"tomorrow",
]
):
profile_keys.append("timezone")
# Personal queries
if any(word in request_lower for word in [
"my name", "who am i", "about me"
]):
if any(word in request_lower for word in ["my name", "who am i", "about me"]):
profile_keys.append("name")
# Always fetch preferences if they might affect response format
include_preferences = any(word in request_lower for word in [
"temperature", "weather", "convert", "unit", "format",
"celsius", "fahrenheit", "metric", "imperial"
])
include_preferences = any(
word in request_lower
for word in [
"temperature",
"weather",
"convert",
"unit",
"format",
"celsius",
"fahrenheit",
"metric",
"imperial",
]
)
try:
return await memory_service.prefetch_context(
@@ -331,7 +370,7 @@ async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
async def analyze_request(
user_request: str,
conversation_history: list[dict],
conversation_id: Optional[str] = None,
conversation_id: str | None = None,
) -> StewardRecommendation:
"""
Analyze user request with full conversation context.
@@ -363,7 +402,7 @@ async def analyze_request(
"request_preview": user_request[:100],
"conversation_id": conversation_id,
"history_length": len(conversation_history),
}
},
) as log_ctx:
try:
# Pre-fetch user context from memory (fast, no LLM)
@@ -382,8 +421,7 @@ async def analyze_request(
# Get plain text analysis from Steward
analysis_text = await steward.analyze(
user_request,
conversation_history=conversation_history
user_request, conversation_history=conversation_history
)
# Parse plain text into structured recommendation
+68 -72
View File
@@ -6,24 +6,25 @@ The agent embodies a witty, capable British butler personality.
"""
import secrets
from typing import AsyncGenerator, Any
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Any
from pydantic_ai import Agent, RunContext
from src.agents.base import AgentInterface, OutputItem
from src.agents.tatlock_core.tools import (
calculate,
get_current_datetime,
calculate_time_offset,
get_current_datetime,
time_difference,
)
from src.core.config import config
from src.core.logging_config import get_logger
from src.core.tracing import (
start_span, end_span, get_current_span,
SpanType,
add_tool_spans_from_messages,
SpanType, SpanStatus,
end_span,
start_span,
)
logger = get_logger(__name__)
@@ -32,6 +33,7 @@ logger = get_logger(__name__)
@dataclass
class ToolCallTracker:
"""Tracks tool calls for reporting to reasoning output."""
calls: list[str] = field(default_factory=list)
def log_call(self, message: str):
@@ -239,7 +241,9 @@ class TatlockAgent(AgentInterface):
# Time difference calculator
@self._agent.tool
def calculate_time_difference(ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now") -> str:
def calculate_time_difference(
ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now"
) -> str:
"""
Calculate the difference between two dates.
@@ -251,7 +255,9 @@ class TatlockAgent(AgentInterface):
Human-readable description of the time difference
"""
if ctx.deps:
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
ctx.deps.log_call(
f"🕐 Calculating time difference between {date1_str} and {date2_str}"
)
return time_difference(date1_str, date2_str)
# NOTE: Web search has been moved to The Librarian agent.
@@ -271,7 +277,7 @@ class TatlockAgent(AgentInterface):
temperature: float = 1.0,
max_tokens: int | None = None,
stop: list[str] | None = None,
**kwargs: Any
**kwargs: Any,
) -> AsyncGenerator[OutputItem, None]:
"""
Generate response using PydanticAI with Ollama.
@@ -305,18 +311,20 @@ class TatlockAgent(AgentInterface):
type="message",
id=f"msg_{generate_id()}",
role="assistant",
content=[{
"type": "output_text",
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
"annotations": []
}],
status="completed"
content=[
{
"type": "output_text",
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
"annotations": [],
}
],
status="completed",
)
return
# Build message history (all messages except the last user message)
# PydanticAI expects history as list of ModelRequest/ModelResponse objects
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart
message_history = []
for i, msg in enumerate(messages[:-1]): # All messages except the last one
@@ -330,7 +338,9 @@ class TatlockAgent(AgentInterface):
# Debug: Check for problematic content
if '"' in content or "'" in content:
logger.debug(f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}...")
logger.debug(
f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}..."
)
# Convert to PydanticAI message format
try:
@@ -339,9 +349,7 @@ class TatlockAgent(AgentInterface):
ModelRequest(parts=[UserPromptPart(content=content)])
)
elif role == "assistant":
message_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
message_history.append(ModelResponse(parts=[TextPart(content=content)]))
except Exception as e:
logger.error(f"Error creating message history item {i}: {e}")
logger.error(f"Problematic content: {repr(content)}")
@@ -352,7 +360,9 @@ class TatlockAgent(AgentInterface):
if message_history:
for i, hist_msg in enumerate(message_history):
msg_type = type(hist_msg).__name__
content_preview = str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
content_preview = (
str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
)
logger.info(f" History[{i}]: {msg_type} - {content_preview}...")
# Generate reasoning output if requested
@@ -362,10 +372,10 @@ class TatlockAgent(AgentInterface):
id=f"reasoning_{generate_id()}",
summary=[
"Analyzing your request, sir...",
"Formulating response based on available knowledge..."
"Formulating response based on available knowledge...",
],
thinking="", # PydanticAI doesn't expose internal reasoning yet
status="completed"
status="completed",
)
# Create a tool call tracker for this request
@@ -382,7 +392,7 @@ class TatlockAgent(AgentInterface):
result = await self.agent.run(
user_message,
message_history=message_history if message_history else None,
deps=tracker
deps=tracker,
)
final_text = result.output
@@ -393,7 +403,7 @@ class TatlockAgent(AgentInterface):
id=f"reasoning_tools_{generate_id()}",
summary=tracker.calls,
thinking="",
status="completed"
status="completed",
)
# Yield the complete message
@@ -402,12 +412,8 @@ class TatlockAgent(AgentInterface):
type="message",
id=msg_id,
role="assistant",
content=[{
"type": "output_text",
"text": final_text,
"annotations": []
}],
status="completed"
content=[{"type": "output_text", "text": final_text, "annotations": []}],
status="completed",
)
except Exception as e:
@@ -416,12 +422,14 @@ class TatlockAgent(AgentInterface):
type="message",
id=f"msg_{generate_id()}",
role="assistant",
content=[{
"type": "output_text",
"text": f"My apologies, sir. I encountered an error: {str(e)}",
"annotations": []
}],
status="failed"
content=[
{
"type": "output_text",
"text": f"My apologies, sir. I encountered an error: {str(e)}",
"annotations": [],
}
],
status="failed",
)
async def supports_tools(self) -> bool:
@@ -490,7 +498,7 @@ class TatlockAgent(AgentInterface):
enriched_message = f"{steward_note}\n\n{user_message}"
# Convert message history to PydanticAI format
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart
pydantic_history = []
for msg in message_history:
@@ -501,17 +509,14 @@ class TatlockAgent(AgentInterface):
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run with scoped tools and tracker
# Force tool_choice to make LLM actually call tools
from src.anthropic.model_selector import get_tool_choice_settings
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
@@ -575,7 +580,7 @@ class TatlockAgent(AgentInterface):
enriched_message = f"{steward_note}\n\n{user_message}"
# Convert message history to PydanticAI format
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart
pydantic_history = []
for msg in message_history:
@@ -586,13 +591,9 @@ class TatlockAgent(AgentInterface):
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Use run() instead of run_stream() to avoid Ollama 400 bug
# with streaming + tool calls (PydanticAI issues #1292, #2256)
@@ -600,7 +601,7 @@ class TatlockAgent(AgentInterface):
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
deps=tool_tracker
deps=tool_tracker,
)
# Stream the final response in chunks to maintain UX
@@ -608,7 +609,7 @@ class TatlockAgent(AgentInterface):
chunk_size = 50 # characters per chunk
for i in range(0, len(response_text), chunk_size):
yield response_text[i:i + chunk_size]
yield response_text[i : i + chunk_size]
logger.info("tatlock_scoped_run_complete")
@@ -643,11 +644,12 @@ class TatlockAgent(AgentInterface):
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
UserPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from src.anthropic.model_selector import get_model
logger.info(
@@ -663,7 +665,7 @@ class TatlockAgent(AgentInterface):
SpanType.TATLOCK,
metadata={
"scoped_tool_count": len(scoped_tools),
"tool_names": [getattr(t, '__name__', str(t)) for t in scoped_tools[:5]],
"tool_names": [getattr(t, "__name__", str(t)) for t in scoped_tools[:5]],
},
)
@@ -690,16 +692,13 @@ class TatlockAgent(AgentInterface):
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run with scoped tools and tracker
from src.anthropic.model_selector import get_tool_choice_settings
result = await scoped_agent.run(
enriched_message,
message_history=pydantic_history if pydantic_history else None,
@@ -782,7 +781,8 @@ class TatlockAgent(AgentInterface):
Returns:
str: Butler-toned response synthesized from all results
"""
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart
from src.anthropic.model_selector import get_model
logger.info(
@@ -849,13 +849,9 @@ class TatlockAgent(AgentInterface):
continue
if role == "user":
pydantic_history.append(
ModelRequest(parts=[UserPromptPart(content=content)])
)
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
elif role == "assistant":
pydantic_history.append(
ModelResponse(parts=[TextPart(content=content)])
)
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
# Run synthesis
result = await synthesis_agent.run(
@@ -885,9 +881,9 @@ class TatlockAgent(AgentInterface):
async def get_capabilities(self) -> dict:
"""Return current capabilities."""
return {
"streaming": True, # Streaming implemented
"reasoning": True, # Basic reasoning summaries
"tools": True, # Permanent tools: calculator, date/time, search
"vision": False, # Future
"audio": False, # Future
"streaming": True, # Streaming implemented
"reasoning": True, # Basic reasoning summaries
"tools": True, # Permanent tools: calculator, date/time, search
"vision": False, # Future
"audio": False, # Future
}
+2 -1
View File
@@ -5,14 +5,15 @@ Provides calculator and date/time capabilities.
Web search has been moved to The Librarian agent.
Organized as a household member with toolset and capability registration.
"""
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
from .toolset import get_core_tools, tatlock_core_tools
from .tools import (
calculate,
calculate_time_offset,
get_current_datetime,
time_difference,
)
from .toolset import get_core_tools, tatlock_core_tools
__all__ = [
# Tools
+1 -1
View File
@@ -4,8 +4,8 @@ Household capability definition for Tatlock's core tools.
Provides the executive summary that the Steward and Butler see
for coordinating household capabilities.
"""
from src.core.household_registry import HouseholdCapability
from src.core.household_registry import HouseholdCapability
TATLOCK_CORE_CAPABILITY = HouseholdCapability(
name="tatlock_core",
+23 -27
View File
@@ -6,13 +6,11 @@ These tools are always available to the butler agent:
- Date/Time toolkit: For current time and time calculations
- SearXNG search: For searching the web for current information
"""
import math
import re
from datetime import datetime, timedelta
import httpx
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -22,6 +20,7 @@ logger = get_logger(__name__)
# Calculator Tool
# ============================================================================
def calculate(expression: str) -> str:
"""
Safely evaluate mathematical expressions.
@@ -50,33 +49,29 @@ def calculate(expression: str) -> str:
# Create safe namespace with math functions
safe_dict = {
# Basic math functions
'sqrt': math.sqrt,
'pow': math.pow,
'abs': abs,
'round': round,
"sqrt": math.sqrt,
"pow": math.pow,
"abs": abs,
"round": round,
# Trigonometric
'sin': math.sin,
'cos': math.cos,
'tan': math.tan,
'asin': math.asin,
'acos': math.acos,
'atan': math.atan,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"asin": math.asin,
"acos": math.acos,
"atan": math.atan,
# Logarithmic
'log': math.log,
'log10': math.log10,
'log2': math.log2,
'exp': math.exp,
"log": math.log,
"log10": math.log10,
"log2": math.log2,
"exp": math.exp,
# Other
'ceil': math.ceil,
'floor': math.floor,
'factorial': math.factorial,
"ceil": math.ceil,
"floor": math.floor,
"factorial": math.factorial,
# Constants
'pi': math.pi,
'e': math.e,
"pi": math.pi,
"e": math.e,
}
# Evaluate the expression safely
@@ -101,6 +96,7 @@ def calculate(expression: str) -> str:
# Date/Time Toolkit
# ============================================================================
def get_current_datetime(format_str: str = "full") -> str:
"""
Get the current date and time.
@@ -161,7 +157,7 @@ def calculate_time_offset(offset_description: str) -> str:
# Parse the offset description
# Pattern: "N unit(s) ago/from now"
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
pattern = r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)"
match = re.match(pattern, offset_description.lower().strip())
if not match:
+1 -1
View File
@@ -4,11 +4,11 @@ PydanticAI toolset for Tatlock's core tools.
Converts the core tool functions into PydanticAI tool definitions
that can be registered with agents and the household registry.
"""
from pydantic_ai.tools import Tool
from . import tools
# Create tool definitions for PydanticAI
calculator_tool = Tool(
function=tools.calculate,
+22 -25
View File
@@ -13,11 +13,11 @@ import math
import re
from datetime import datetime, timedelta
# ============================================================================
# Calculator Tool
# ============================================================================
def calculate(expression: str) -> str:
"""
Safely evaluate mathematical expressions.
@@ -46,33 +46,29 @@ def calculate(expression: str) -> str:
# Create safe namespace with math functions
safe_dict = {
# Basic math functions
'sqrt': math.sqrt,
'pow': math.pow,
'abs': abs,
'round': round,
"sqrt": math.sqrt,
"pow": math.pow,
"abs": abs,
"round": round,
# Trigonometric
'sin': math.sin,
'cos': math.cos,
'tan': math.tan,
'asin': math.asin,
'acos': math.acos,
'atan': math.atan,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"asin": math.asin,
"acos": math.acos,
"atan": math.atan,
# Logarithmic
'log': math.log,
'log10': math.log10,
'log2': math.log2,
'exp': math.exp,
"log": math.log,
"log10": math.log10,
"log2": math.log2,
"exp": math.exp,
# Other
'ceil': math.ceil,
'floor': math.floor,
'factorial': math.factorial,
"ceil": math.ceil,
"floor": math.floor,
"factorial": math.factorial,
# Constants
'pi': math.pi,
'e': math.e,
"pi": math.pi,
"e": math.e,
}
# Evaluate the expression safely
@@ -97,6 +93,7 @@ def calculate(expression: str) -> str:
# Date/Time Toolkit
# ============================================================================
def get_current_datetime(format_str: str = "full") -> str:
"""
Get the current date and time.
@@ -157,7 +154,7 @@ def calculate_time_offset(offset_description: str) -> str:
# Parse the offset description
# Pattern: "N unit(s) ago/from now"
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
pattern = r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)"
match = re.match(pattern, offset_description.lower().strip())
if not match:
+2 -1
View File
@@ -2,9 +2,10 @@
Chat completion router.
OpenAI-compatible /v1/chat/completions endpoint.
"""
import json
import logging
from typing import AsyncGenerator
from collections.abc import AsyncGenerator
from fastapi import APIRouter
from starlette.responses import StreamingResponse
+9
View File
@@ -2,6 +2,7 @@
OpenAI-compatible chat completion schemas.
Following OpenAI API specification for compatibility.
"""
from typing import Literal
from pydantic import Field
@@ -11,6 +12,7 @@ from src.core.models import CustomBaseModel
class ChatMessage(CustomBaseModel):
"""OpenAI-compatible chat message."""
role: Literal["system", "user", "assistant"]
content: str
name: str | None = None
@@ -18,6 +20,7 @@ class ChatMessage(CustomBaseModel):
class ChatCompletionRequest(CustomBaseModel):
"""OpenAI-compatible chat completion request."""
model: str = Field(..., description="Model to use for completion")
messages: list[ChatMessage] = Field(..., description="List of messages")
temperature: float | None = Field(default=0.7, ge=0.0, le=2.0)
@@ -29,6 +32,7 @@ class ChatCompletionRequest(CustomBaseModel):
class ChatCompletionChoice(CustomBaseModel):
"""Choice in chat completion response."""
index: int
message: ChatMessage
finish_reason: str | None
@@ -36,6 +40,7 @@ class ChatCompletionChoice(CustomBaseModel):
class ChatCompletionUsage(CustomBaseModel):
"""Token usage information."""
prompt_tokens: int
completion_tokens: int
total_tokens: int
@@ -43,6 +48,7 @@ class ChatCompletionUsage(CustomBaseModel):
class ChatCompletionResponse(CustomBaseModel):
"""OpenAI-compatible chat completion response."""
id: str
object: str = "chat.completion"
created: int
@@ -53,6 +59,7 @@ class ChatCompletionResponse(CustomBaseModel):
class ChatCompletionChunkDelta(CustomBaseModel):
"""Delta in streaming chunk."""
role: str | None = None
content: str | None = None
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
@@ -60,6 +67,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
class ChatCompletionChunkChoice(CustomBaseModel):
"""Choice in streaming chunk."""
index: int
delta: ChatCompletionChunkDelta
finish_reason: str | None = None
@@ -67,6 +75,7 @@ class ChatCompletionChunkChoice(CustomBaseModel):
class ChatCompletionChunk(CustomBaseModel):
"""OpenAI-compatible streaming chunk."""
id: str
object: str = "chat.completion.chunk"
created: int
+12 -14
View File
@@ -4,17 +4,17 @@ Chat completion service.
Wrapper around Responses API that converts to Chat Completions format.
Embeds reasoning in <think> tags for Open WebUI compatibility.
"""
import asyncio
import time
import uuid
from typing import AsyncGenerator
from collections.abc import AsyncGenerator
from src.chat import constants
from src.chat.schemas import (
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionChunkChoice,
ChatCompletionChunkDelta,
ChatCompletionChoice,
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionUsage,
@@ -43,10 +43,7 @@ async def create_chat_completion(
created_at = int(time.time())
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
response_request = ResponseRequest(
model=request.model,
@@ -54,7 +51,9 @@ async def create_chat_completion(
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
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),
stop=request.stop
if isinstance(request.stop, list)
else ([request.stop] if request.stop else None),
)
# Call Responses API (will use Steward for Tatlock)
@@ -118,16 +117,13 @@ async def create_chat_completion_stream(
Yields:
Chat completion chunks with reasoning as <think> tags
"""
from src.responses.streaming import StreamingCoordinator, StreamEventType
from src.responses.streaming import StreamEventType, StreamingCoordinator
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created_at = int(time.time())
# Convert Chat request to Responses request
input_messages = [
{"role": msg.role, "content": msg.content}
for msg in request.messages
]
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
response_request = ResponseRequest(
model=request.model,
@@ -135,7 +131,9 @@ async def create_chat_completion_stream(
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),
stop=request.stop
if isinstance(request.stop, list)
else ([request.stop] if request.stop else None),
stream=True,
)
+34 -86
View File
@@ -2,6 +2,7 @@
Global application configuration.
Following best practice of splitting config across domains.
"""
from enum import Enum
from functools import lru_cache
from pathlib import Path
@@ -42,6 +43,7 @@ def _get_version_from_pyproject() -> str:
class Environment(str, Enum):
"""Application environment."""
DEVELOPMENT = "development"
PRODUCTION = "production"
TESTING = "testing"
@@ -54,6 +56,7 @@ class Config(BaseSettings):
Loads from environment variables and .env file.
Domain-specific configs should be in their respective modules.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
@@ -74,143 +77,90 @@ class Config(BaseSettings):
# Anthropic Configuration (Claude - cloud fallback)
ANTHROPIC_API_KEY: str | None = Field(
default=None,
description="Anthropic API key for the Claude fallback backend"
default=None, description="Anthropic API key for the Claude fallback backend"
)
ANTHROPIC_MODEL: str = Field(
default="claude-sonnet-5",
description="Claude model for the fallback backend"
default="claude-sonnet-5", description="Claude model for the fallback backend"
)
PREFER_CLOUD_BACKEND: bool = Field(
default=False,
description="Prefer Claude over Ollama (default: local-first)"
default=False, description="Prefer Claude over Ollama (default: local-first)"
)
# Ollama Configuration (local - primary backend)
OLLAMA_HOST: HttpUrl = Field(
default="http://localhost:11434",
description="Ollama server URL"
)
OLLAMA_DEFAULT_MODEL: str = Field(
default="gemma4:e2b",
description="Default Ollama model"
)
OLLAMA_TIMEOUT: int = Field(
default=120,
description="Ollama request timeout in seconds"
)
OLLAMA_HOST: HttpUrl = Field(default="http://localhost:11434", description="Ollama server URL")
OLLAMA_DEFAULT_MODEL: str = Field(default="gemma4:e2b", description="Default Ollama model")
OLLAMA_TIMEOUT: int = Field(default=120, description="Ollama request timeout in seconds")
STEWARD_TIMEOUT: int = Field(
default=60,
description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
default=60, description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
)
STREAM_TIMEOUT: int = Field(
default=20,
description="Timeout for each streaming turn in seconds"
default=20, description="Timeout for each streaming turn in seconds"
)
# SearXNG Configuration
SEARXNG_HOST: HttpUrl = Field(
default="http://searxng:8080",
description="SearXNG server URL (container name; internal port 8080)"
)
SEARXNG_TIMEOUT: int = Field(
default=30,
description="SearXNG request timeout in seconds"
description="SearXNG server URL (container name; internal port 8080)",
)
SEARXNG_TIMEOUT: int = Field(default=30, description="SearXNG request timeout in seconds")
# Redis Configuration
REDIS_HOST: str = Field(
default="localhost",
description="Redis server host"
)
REDIS_PORT: int = Field(
default=6379,
description="Redis server port"
)
REDIS_TIMEOUT: int = Field(
default=5,
description="Redis connection timeout in seconds"
)
REDIS_HOST: str = Field(default="localhost", description="Redis server host")
REDIS_PORT: int = Field(default=6379, description="Redis server port")
REDIS_TIMEOUT: int = Field(default=5, description="Redis connection timeout in seconds")
# Library-Desk Configuration (The Librarian backend)
LIBRARIAN_TIMEOUT: int = Field(
default=180,
description="Total time budget for a librarian delegation in seconds"
default=180, description="Total time budget for a librarian delegation in seconds"
)
LIBRARY_DESK_HOST: HttpUrl = Field(
default="http://library-desk:8089",
description="Library-Desk API URL (container name; internal port 8089)"
description="Library-Desk API URL (container name; internal port 8089)",
)
LIBRARY_DESK_API_KEY: str = Field(
default="",
description="API key for Library-Desk authentication"
default="", description="API key for Library-Desk authentication"
)
LIBRARY_DESK_TIMEOUT: int = Field(
default=60,
description="Library-Desk request timeout in seconds"
default=60, description="Library-Desk request timeout in seconds"
)
# Core-API Configuration (The Housekeeper backend)
CORE_API_HOST: HttpUrl = Field(
default="http://core-api:8083",
description="Core-API URL for Home Assistant integration (container name; internal port 8083)"
)
CORE_API_KEY: str = Field(
default="",
description="API key for Core-API authentication"
)
CORE_API_TIMEOUT: int = Field(
default=30,
description="Core-API request timeout in seconds"
description="Core-API URL for Home Assistant integration (container name; internal port 8083)",
)
CORE_API_KEY: str = Field(default="", description="API key for Core-API authentication")
CORE_API_TIMEOUT: int = Field(default=30, description="Core-API request timeout in seconds")
# Qdrant Configuration (Memory vector storage)
QDRANT_HOST: str = Field(
default="localhost",
description="Qdrant server host"
)
QDRANT_PORT: int = Field(
default=6333,
description="Qdrant server port"
)
QDRANT_HOST: str = Field(default="localhost", description="Qdrant server host")
QDRANT_PORT: int = Field(default=6333, description="Qdrant server port")
QDRANT_EMBEDDING_DIM: int = Field(
default=768,
description="Embedding dimension (768 for nomic-embed-text)"
default=768, description="Embedding dimension (768 for nomic-embed-text)"
)
# Ollama Embedding Configuration
OLLAMA_EMBEDDING_MODEL: str = Field(
default="nomic-embed-text",
description="Ollama model for embeddings"
default="nomic-embed-text", description="Ollama model for embeddings"
)
# Redis Memory Database
REDIS_MEMORY_DB: int = Field(
default=1,
description="Redis database number for memory cache"
)
REDIS_MEMORY_TTL_HOURS: int = Field(
default=24,
description="TTL for session context in hours"
)
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")
REDIS_MEMORY_TTL_HOURS: int = Field(default=24, description="TTL for session context in hours")
# Logging
LOG_LEVEL: str | None = Field(
default=None,
description="Logging level (auto-set based on environment if not specified)"
default=None, description="Logging level (auto-set based on environment if not specified)"
)
# User Configuration
DEFAULT_USER: str | None = Field(
default=None,
description="Default user for single-user setup (auto-set based on environment if not specified)"
description="Default user for single-user setup (auto-set based on environment if not specified)",
)
# CORS
CORS_ORIGINS: list[str] = Field(
default=["*"],
description="Allowed CORS origins"
)
CORS_ORIGINS: list[str] = Field(default=["*"], description="Allowed CORS origins")
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: list[str] = ["*"]
CORS_ALLOW_HEADERS: list[str] = ["*"]
@@ -235,8 +185,7 @@ class Config(BaseSettings):
if (
self.ENVIRONMENT != Environment.PRODUCTION
and self.DEFAULT_USER is not None
and sanitize_user_id(self.DEFAULT_USER)
== sanitize_user_id(PRODUCTION_TENANT)
and sanitize_user_id(self.DEFAULT_USER) == sanitize_user_id(PRODUCTION_TENANT)
):
raise ValueError(
f"Refusing to start: ENVIRONMENT={self.ENVIRONMENT.value} is "
@@ -299,8 +248,7 @@ class Config(BaseSettings):
return self.DEFAULT_USER or PRODUCTION_TENANT
if self.DEFAULT_USER is not None and (
self.DEFAULT_USER == TEST_TENANT
or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
self.DEFAULT_USER == TEST_TENANT or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
):
return self.DEFAULT_USER
return TEST_TENANT
+5 -6
View File
@@ -16,6 +16,7 @@ Usage:
from src.core.context import get_user
user = get_user() # Returns current request's user
"""
from contextvars import ContextVar
@@ -28,6 +29,7 @@ def get_default_user() -> str:
"""
# Import here to avoid circular dependency
from src.core.config import config
return config.effective_default_user
@@ -36,9 +38,7 @@ def get_default_user() -> str:
# and resolve the real default in get_user()
_USER_NOT_SET = "__user_not_set__"
current_user: ContextVar[str] = ContextVar("current_user", default=_USER_NOT_SET)
current_conversation: ContextVar[str | None] = ContextVar(
"current_conversation", default=None
)
current_conversation: ContextVar[str | None] = ContextVar("current_conversation", default=None)
def apply_tenant_guard(user: str) -> str:
@@ -60,9 +60,8 @@ def apply_tenant_guard(user: str) -> str:
from src.core.config import PRODUCTION_TENANT, TEST_TENANT, Environment, config
from src.core.multi_tenancy import sanitize_user_id
if (
config.ENVIRONMENT != Environment.PRODUCTION
and sanitize_user_id(user) == sanitize_user_id(PRODUCTION_TENANT)
if config.ENVIRONMENT != Environment.PRODUCTION and sanitize_user_id(user) == sanitize_user_id(
PRODUCTION_TENANT
):
from src.core.logging_config import get_logger
-1
View File
@@ -8,7 +8,6 @@ Provides async embedding operations via Ollama API:
Adapted from library-desk patterns.
"""
from typing import Optional
import httpx
+6 -5
View File
@@ -2,12 +2,13 @@
Global exception definitions.
Domain-specific exceptions should be in their respective modules.
"""
from typing import Any
class AppException(Exception):
"""Base exception for all application errors."""
def __init__(
self,
message: str = "An error occurred",
@@ -22,26 +23,26 @@ class AppException(Exception):
class OllamaConnectionError(AppException):
"""Raised when cannot connect to Ollama service."""
def __init__(self, message: str = "Cannot connect to Ollama service"):
super().__init__(message=message, status_code=503)
class OllamaTimeoutError(AppException):
"""Raised when Ollama request times out."""
def __init__(self, message: str = "Ollama request timed out"):
super().__init__(message=message, status_code=504)
class ModelNotFoundError(AppException):
"""Raised when requested model is not available."""
def __init__(self, model_name: str):
super().__init__(
message=f"Model '{model_name}' not found",
status_code=404,
details={"model": model_name}
details={"model": model_name},
)
+8 -8
View File
@@ -5,10 +5,10 @@ Provides centralized registry of household members (agents) with their
capabilities and tools. Supports two-tier abstraction: executive summaries
for coordination and full toolsets for execution.
"""
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, ConfigDict
from pydantic_ai import Agent
from .logging_config import get_logger
@@ -22,6 +22,7 @@ class HouseholdCapability(BaseModel):
This is what the Steward and Butler see for coordination.
High-level description without implementation details.
"""
name: str # Unique identifier: "tatlock_core", "librarian", "developer"
role: str # Display name: "Butler's Core Tools", "The Librarian"
category: str # "core", "research", "technical", "automation"
@@ -38,11 +39,12 @@ class HouseholdMember(BaseModel):
Contains both the executive summary (for coordination) and
implementation details (tools/agent).
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
capability: HouseholdCapability
tools: list[Any] # PydanticAI tool definitions (any type since Tool is a dataclass)
agent: Optional[Any] = None # For expert agents (Phase 4)
agent: Any | None = None # For expert agents (Phase 4)
class HouseholdRegistry:
@@ -65,7 +67,7 @@ class HouseholdRegistry:
name: str,
capability: HouseholdCapability,
tools: list[Any],
agent: Optional[Any] = None,
agent: Any | None = None,
) -> None:
"""
Register a household member.
@@ -95,9 +97,7 @@ class HouseholdRegistry:
... )
"""
if name != capability.name:
raise ValueError(
f"Name mismatch: '{name}' != '{capability.name}'"
)
raise ValueError(f"Name mismatch: '{name}' != '{capability.name}'")
self._members[name] = HouseholdMember(
capability=capability,
@@ -132,7 +132,7 @@ class HouseholdRegistry:
role=member.capability.role,
)
def get_member(self, name: str) -> Optional[HouseholdMember]:
def get_member(self, name: str) -> HouseholdMember | None:
"""
Get full household member specification.
+33 -13
View File
@@ -4,12 +4,14 @@ Structured logging configuration using structlog.
Deeply integrates with FastAPI/uvicorn's built-in logging to provide
seamless structured logs across the entire application stack.
"""
import logging
import logging.config
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, AsyncIterator
from datetime import UTC, datetime
from typing import Any
import structlog
from structlog.types import EventDict, Processor
@@ -19,7 +21,7 @@ from .config import config
def add_timestamp(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
"""Add ISO 8601 timestamp to log entries."""
event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
event_dict["timestamp"] = datetime.now(UTC).isoformat()
return event_dict
@@ -41,11 +43,28 @@ def extract_from_record(logger: Any, method_name: str, event_dict: EventDict) ->
# Extract custom fields from record
for key, value in record.__dict__.items():
if key not in {
"name", "msg", "args", "created", "filename", "funcName",
"levelname", "levelno", "lineno", "module", "msecs",
"message", "pathname", "process", "processName", "relativeCreated",
"thread", "threadName", "exc_info", "exc_text", "stack_info",
"taskName"
"name",
"msg",
"args",
"created",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"pathname",
"process",
"processName",
"relativeCreated",
"thread",
"threadName",
"exc_info",
"exc_text",
"stack_info",
"taskName",
}:
event_dict[key] = value
@@ -164,7 +183,7 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
async def log_operation(
operation: str,
initial_context: dict[str, Any] | None = None,
logger_name: str = "tatlock.operations"
logger_name: str = "tatlock.operations",
) -> AsyncIterator[dict[str, Any]]:
"""
Context manager for automatic operation timing and logging.
@@ -187,21 +206,21 @@ async def log_operation(
context = initial_context or {}
context["operation"] = operation
start_time = datetime.now(timezone.utc)
start_time = datetime.now(UTC)
logger.info("operation_started", **context)
try:
yield context
# Success case
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
duration = (datetime.now(UTC) - start_time).total_seconds()
context["duration_seconds"] = duration
context["success"] = True
logger.info("operation_completed", **context)
except Exception as e:
# Error case
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
duration = (datetime.now(UTC) - start_time).total_seconds()
context["duration_seconds"] = duration
context["success"] = False
context["error"] = str(e)
@@ -228,7 +247,8 @@ def get_uvicorn_log_config() -> dict[str, Any]:
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer() if config.log_format == "json"
structlog.processors.JSONRenderer()
if config.log_format == "json"
else structlog.dev.ConsoleRenderer(colors=True),
],
},
+2 -1
View File
@@ -8,6 +8,7 @@ Provides short-term memory storage with TTL:
Uses Redis DB 1.
"""
import json
from typing import Any
@@ -15,7 +16,7 @@ import redis.asyncio as redis
from .config import config
from .logging_config import get_logger
from .multi_tenancy import get_session_key, get_entities_key
from .multi_tenancy import get_entities_key, get_session_key
logger = get_logger(__name__)
+15 -13
View File
@@ -21,14 +21,14 @@ Usage:
# Get session context
ctx = await memory_service.get_session_context(conversation_id)
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
from .config import config
from .context import get_user, get_conversation_id
from .context import get_conversation_id, get_user
from .embeddings import get_embedding_client
from .logging_config import get_logger
from .memory_cache import get_memory_cache
@@ -40,22 +40,24 @@ logger = get_logger(__name__)
class MemoryType(str, Enum):
"""Types of memories stored in Qdrant."""
USER_PROFILE = "user_profile" # Name, location, timezone
PREFERENCE = "preference" # Units, language, theme
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
USER_PROFILE = "user_profile" # Name, location, timezone
PREFERENCE = "preference" # Units, language, theme
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
class MemoryRecord(BaseModel):
"""A memory record stored in Qdrant."""
id: str
type: MemoryType
key: str # e.g., "location", "timezone", "car"
value: str # The actual content
key: str # e.g., "location", "timezone", "car"
value: str # The actual content
keywords: list[str] = Field(default_factory=list)
importance: float = 0.5 # 0.0 - 1.0
source: str = "explicit" # "explicit" | "inferred" | "conversation"
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
importance: float = 0.5 # 0.0 - 1.0
source: str = "explicit" # "explicit" | "inferred" | "conversation"
created_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
class MemoryService:
@@ -528,7 +530,7 @@ class MemoryService:
"keywords": keywords,
"importance": importance,
"source": source,
"updated_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(UTC).isoformat(),
}
result = await self.qdrant.upsert_memory(
+6 -5
View File
@@ -2,6 +2,7 @@
Custom Pydantic base models for consistent serialization.
Following best practice of having a global base model.
"""
from datetime import datetime
from typing import Any
@@ -17,12 +18,13 @@ def datetime_to_iso_str(dt: datetime) -> str:
class CustomBaseModel(BaseModel):
"""
Custom base model with consistent configuration.
All domain models should inherit from this for:
- Consistent JSON serialization
- Timezone-aware datetime handling
- Alias population support
"""
model_config = ConfigDict(
json_encoders={datetime: datetime_to_iso_str},
populate_by_name=True,
@@ -30,14 +32,13 @@ class CustomBaseModel(BaseModel):
validate_assignment=True,
arbitrary_types_allowed=True,
)
def serializable_dict(self, **kwargs: Any) -> dict[str, Any]:
"""
Return dict with only JSON-serializable fields.
Useful for logging and debugging.
"""
return jsonable_encoder(
self.model_dump(**kwargs),
custom_encoder={datetime: datetime_to_iso_str}
self.model_dump(**kwargs), custom_encoder={datetime: datetime_to_iso_str}
)
+5 -4
View File
@@ -7,6 +7,7 @@ Provides utilities for user namespace management across:
Adapted from library-desk patterns.
"""
import re
@@ -39,13 +40,13 @@ def sanitize_user_id(user_id: str) -> str:
sanitized = sanitized.replace(".", "_")
# Replace any non-alphanumeric characters with underscores
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
sanitized = re.sub(r"[^a-z0-9_]", "_", sanitized)
# Remove consecutive underscores
sanitized = re.sub(r'_+', '_', sanitized)
sanitized = re.sub(r"_+", "_", sanitized)
# Remove leading/trailing underscores
sanitized = sanitized.strip('_')
sanitized = sanitized.strip("_")
return sanitized
@@ -141,7 +142,7 @@ def validate_user_id(user_id: str) -> bool:
return False
# Must contain at least one alphanumeric character
if not re.search(r'[a-zA-Z0-9]', user_id):
if not re.search(r"[a-zA-Z0-9]", user_id):
return False
return True
+14 -12
View File
@@ -3,15 +3,16 @@ Request preprocessing pipeline.
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional
from typing import Any
from src.agents.steward import analyze_request, format_steward_note
from src.agents.steward.schemas import StewardRecommendation
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
from src.core.tracing import trace_span, SpanType
from src.core.tracing import SpanType, trace_span
logger = get_logger(__name__)
@@ -45,6 +46,7 @@ class EnrichedRequest:
recommendation: Full Steward recommendation
steward_reasoning: Plain text reasoning for streaming to user
"""
original_request: str
steward_note: str
scoped_tools: list[Any] # PydanticAI tool definitions
@@ -55,7 +57,7 @@ class EnrichedRequest:
async def preprocess_request(
user_request: str,
conversation_history: list[dict],
conversation_id: Optional[str] = None,
conversation_id: str | None = None,
) -> EnrichedRequest:
"""
Analyze request via Steward and prepare scoped context for Tatlock.
@@ -111,12 +113,14 @@ async def preprocess_request(
# 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.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
@@ -128,9 +132,7 @@ async def preprocess_request(
# Uses agent-as-tool pattern: expert agents get delegation wrappers,
# core tools are returned directly
registry = get_household_registry()
scoped_tools = registry.get_delegation_tools(
recommendation.recommended_capabilities
)
scoped_tools = registry.get_delegation_tools(recommendation.recommended_capabilities)
logger.info(
"preprocessing_complete",
+2 -1
View File
@@ -8,8 +8,9 @@ Provides async operations for storing and retrieving memory embeddings:
Adapted from library-desk patterns.
"""
from typing import Any
from uuid import uuid4, uuid5, NAMESPACE_DNS
from uuid import NAMESPACE_DNS, uuid4, uuid5
from qdrant_client import QdrantClient
from qdrant_client.http import models as qdrant_models
+3 -2
View File
@@ -1,6 +1,7 @@
"""
Core router for health and root endpoints.
"""
import logging
from fastapi import APIRouter
@@ -16,7 +17,7 @@ router = APIRouter(tags=["core"])
async def health_check() -> dict[str, str]:
"""
Health check endpoint.
Returns:
Health status
"""
@@ -27,7 +28,7 @@ async def health_check() -> dict[str, str]:
async def root() -> dict[str, str]:
"""
Root endpoint.
Returns:
API information
"""
+1
View File
@@ -5,6 +5,7 @@ Handles initialization of household registry and other startup tasks.
This module should be called during application startup to register
all household members.
"""
from src.agents.biographer import register_biographer
from src.agents.housekeeper import register_housekeeper
from src.agents.librarian import register_librarian
+5 -18
View File
@@ -4,7 +4,6 @@ Tool call tracking.
Tracks which tools are recommended by the Steward versus which tools
are actually used by Tatlock for debugging and analysis.
"""
from typing import Optional
from src.core.logging_config import get_logger
@@ -19,11 +18,7 @@ class ToolCallTracker:
to measure recommendation accuracy.
"""
def __init__(
self,
recommended_capabilities: list[str],
conversation_id: Optional[str] = None
):
def __init__(self, recommended_capabilities: list[str], conversation_id: str | None = None):
"""
Initialize tool call tracker.
@@ -95,9 +90,7 @@ class ToolCallTracker:
tools that were recommended but never used.
"""
# Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
# Find tools that were recommended but not used
unused_tools = self.recommended_capabilities - used_capabilities
@@ -128,9 +121,7 @@ class ToolCallTracker:
"""
total_calls = sum(len(durations) for durations in self.actual_calls.values())
# Normalize actual tool names to capabilities for comparison
used_capabilities = {
self._extract_capability(tool) for tool in self.actual_calls.keys()
}
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
unused = self.recommended_capabilities - used_capabilities
return {
@@ -139,12 +130,8 @@ class ToolCallTracker:
"tools_unused": list(unused),
"total_calls": total_calls,
"accuracy": {
"recommended_and_used": len(
self.recommended_capabilities & used_capabilities
),
"recommended_and_used": len(self.recommended_capabilities & used_capabilities),
"recommended_but_unused": len(unused),
"not_recommended_but_used": len(
used_capabilities - self.recommended_capabilities
),
"not_recommended_but_used": len(used_capabilities - self.recommended_capabilities),
},
}
+23 -14
View File
@@ -9,16 +9,16 @@ Enable via DEBUG=true environment variable.
Traces are written to logs/traces/{trace_id}.json
View with logs/traces/viewer.html
"""
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any
import json
import secrets
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Any
from src.core.logging_config import get_logger
@@ -27,6 +27,7 @@ logger = get_logger(__name__)
class SpanType(str, Enum):
"""Types of traced operations."""
ROUTER = "router"
STEWARD = "steward"
TATLOCK = "tatlock"
@@ -36,6 +37,7 @@ class SpanType(str, Enum):
class SpanStatus(str, Enum):
"""Span completion status."""
OK = "ok"
ERROR = "error"
@@ -43,6 +45,7 @@ class SpanStatus(str, Enum):
@dataclass
class Span:
"""A single traced operation."""
span_id: str
name: str
type: SpanType
@@ -88,6 +91,7 @@ class Span:
@dataclass
class Trace:
"""Complete trace of a request."""
trace_id: str
conversation_id: str | None
user: str
@@ -116,7 +120,9 @@ class Trace:
"conversation_id": self.conversation_id,
"user": self.user,
"timestamp": self.timestamp.isoformat(),
"total_duration_ms": round(self.total_duration_ms, 2) if self.total_duration_ms else None,
"total_duration_ms": round(self.total_duration_ms, 2)
if self.total_duration_ms
else None,
"status": self.status,
"request": self.request,
"response": self.response,
@@ -132,6 +138,7 @@ _current_span: ContextVar[Span | None] = ContextVar("current_span", default=None
def tracing_enabled() -> bool:
"""Check if tracing is enabled (requires DEBUG=true)."""
from src.core.config import config
return config.DEBUG
@@ -163,7 +170,7 @@ def start_trace(
trace_id=_generate_id("trace_"),
conversation_id=conversation_id,
user=user,
timestamp=datetime.now(timezone.utc),
timestamp=datetime.now(UTC),
request=request,
)
_current_trace.set(trace)
@@ -209,7 +216,7 @@ def start_span(
span_id=_generate_id("span_"),
name=name,
type=span_type,
start_time=datetime.now(timezone.utc),
start_time=datetime.now(UTC),
parent_id=parent.span_id if parent else None,
metadata=metadata or {},
details=details or {},
@@ -254,7 +261,7 @@ def end_span(
if not span:
return
span.end_time = datetime.now(timezone.utc)
span.end_time = datetime.now(UTC)
span.status = status
if error:
span.error = error
@@ -405,7 +412,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
if isinstance(part, ToolCallPart):
tool_calls[part.tool_call_id] = {
"name": part.tool_name,
"args": part.args if hasattr(part, 'args') else {},
"args": part.args if hasattr(part, "args") else {},
}
elif isinstance(msg, ModelRequest):
for part in msg.parts:
@@ -418,7 +425,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
name=tool_info["name"],
type=SpanType.TOOL,
start_time=parent_span.start_time, # Approximate
end_time=parent_span.end_time or datetime.now(timezone.utc),
end_time=parent_span.end_time or datetime.now(UTC),
parent_id=parent_span.span_id,
status=SpanStatus.OK,
metadata={
@@ -427,7 +434,9 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
},
details={
"args": tool_info.get("args", {}),
"result": part.content[:2000] if isinstance(part.content, str) else str(part.content)[:2000],
"result": part.content[:2000]
if isinstance(part.content, str)
else str(part.content)[:2000],
},
)
parent_span.children.append(span.span_id)
+17 -12
View File
@@ -4,6 +4,8 @@ Trace viewer router.
Serves the trace viewer UI and trace files when tracing is enabled.
Only available when DEBUG=true.
"""
from datetime import UTC
from pathlib import Path
from fastapi import APIRouter, HTTPException
@@ -66,12 +68,12 @@ async def list_traces(
return {"traces": [], "total": 0}
import json
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta
# Calculate cutoff time if filtering by time
cutoff_time = None
if since_minutes:
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
cutoff_time = datetime.now(UTC) - timedelta(minutes=since_minutes)
# Get all trace files, sorted by modification time (newest first)
trace_files = sorted(
@@ -93,7 +95,7 @@ async def list_traces(
trace_timestamp = data.get("timestamp")
if cutoff_time and trace_timestamp:
try:
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
ts = datetime.fromisoformat(trace_timestamp.replace("Z", "+00:00"))
if ts < cutoff_time:
continue
except (ValueError, TypeError):
@@ -109,15 +111,17 @@ async def list_traces(
if search and search.lower() not in request_preview.lower():
continue
traces.append({
"trace_id": data.get("trace_id"),
"timestamp": trace_timestamp,
"user": data.get("user"),
"status": trace_status,
"total_duration_ms": data.get("total_duration_ms"),
"span_count": len(data.get("spans", [])),
"request_preview": request_preview[:100],
})
traces.append(
{
"trace_id": data.get("trace_id"),
"timestamp": trace_timestamp,
"user": data.get("user"),
"status": trace_status,
"total_duration_ms": data.get("total_duration_ms"),
"span_count": len(data.get("spans", [])),
"request_preview": request_preview[:100],
}
)
except Exception as e:
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
@@ -145,6 +149,7 @@ async def get_trace(trace_id: str):
try:
import json
with open(trace_path) as f:
data = json.load(f)
return JSONResponse(content=data)
+8 -7
View File
@@ -9,8 +9,9 @@ Main responsibilities:
- Router registration
- Lifecycle management
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
@@ -64,7 +65,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
def create_application() -> FastAPI:
"""
Application factory.
Creates and configures the FastAPI application.
Following best practice of using factory pattern.
"""
@@ -75,7 +76,7 @@ def create_application() -> FastAPI:
lifespan=lifespan,
debug=config.DEBUG,
)
# Add middleware
application.add_middleware(
CORSMiddleware,
@@ -84,10 +85,10 @@ def create_application() -> FastAPI:
allow_methods=config.CORS_ALLOW_METHODS,
allow_headers=config.CORS_ALLOW_HEADERS,
)
# Register exception handlers
register_exception_handlers(application)
# Include routers
application.include_router(core_router) # Health and root endpoints
application.include_router(chat_router, prefix=config.API_PREFIX)
@@ -105,10 +106,10 @@ def create_application() -> FastAPI:
def register_exception_handlers(application: FastAPI) -> None:
"""
Register global exception handlers.
Provides consistent error responses compatible with OpenAI API.
"""
@application.exception_handler(AppException)
async def app_exception_handler(
request: Request,
+3 -2
View File
@@ -2,6 +2,7 @@
Models router.
OpenAI-compatible /v1/models endpoint.
"""
import logging
from fastapi import APIRouter
@@ -18,9 +19,9 @@ router = APIRouter(prefix="/models", tags=["models"])
async def list_models() -> ModelsResponse:
"""
List available models (OpenAI-compatible).
Currently returns mock model list.
Returns:
List of available models
"""
+3
View File
@@ -1,11 +1,13 @@
"""
OpenAI-compatible models schemas.
"""
from src.core.models import CustomBaseModel
class Model(CustomBaseModel):
"""OpenAI-compatible model object."""
id: str
object: str = "model"
created: int
@@ -14,5 +16,6 @@ class Model(CustomBaseModel):
class ModelsResponse(CustomBaseModel):
"""OpenAI-compatible models list response."""
object: str = "list"
data: list[Model]
+33 -30
View File
@@ -2,8 +2,10 @@
Ollama HTTP client.
Handles all communication with the Ollama service.
"""
import logging
from typing import Any, AsyncGenerator
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from httpx import ConnectError, TimeoutException
@@ -22,14 +24,14 @@ logger = logging.getLogger(__name__)
class OllamaClient:
"""
Async client for Ollama API.
Follows best practice of using async for I/O operations.
"""
def __init__(self, base_url: str | None = None, timeout: int | None = None):
"""
Initialize Ollama client.
Args:
base_url: Ollama server URL (defaults to config)
timeout: Request timeout in seconds (defaults to config)
@@ -37,7 +39,7 @@ class OllamaClient:
self.base_url = base_url or str(config.OLLAMA_HOST)
self.timeout = timeout or config.OLLAMA_TIMEOUT
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "OllamaClient":
"""Async context manager entry."""
self._client = httpx.AsyncClient(
@@ -45,32 +47,32 @@ class OllamaClient:
timeout=self.timeout,
)
return self
async def __aexit__(self, *args: Any) -> None:
"""Async context manager exit."""
if self._client:
await self._client.aclose()
async def chat(
self,
request: OllamaChatRequest,
) -> OllamaChatResponse:
"""
Send chat request to Ollama (non-streaming).
Args:
request: Chat request with model and messages
Returns:
Complete chat response
Raises:
OllamaConnectionError: Cannot connect to Ollama
OllamaTimeoutError: Request timed out
"""
if not self._client:
raise RuntimeError("Client not initialized. Use async with context.")
try:
response = await self._client.post(
"/api/chat",
@@ -78,38 +80,38 @@ class OllamaClient:
)
response.raise_for_status()
return OllamaChatResponse(**response.json())
except ConnectError as e:
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
raise OllamaConnectionError() from e
except TimeoutException as e:
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
raise OllamaTimeoutError() from e
async def chat_stream(
self,
request: OllamaChatRequest,
) -> AsyncGenerator[dict[str, Any], None]:
"""
Send streaming chat request to Ollama.
Args:
request: Chat request with stream=True
Yields:
Streaming response chunks
Raises:
OllamaConnectionError: Cannot connect to Ollama
OllamaTimeoutError: Request timed out
"""
if not self._client:
raise RuntimeError("Client not initialized. Use async with context.")
# Ensure streaming is enabled
request.stream = True
try:
async with self._client.stream(
"POST",
@@ -120,48 +122,49 @@ class OllamaClient:
async for line in response.aiter_lines():
if line.strip():
import json
yield json.loads(line)
except ConnectError as e:
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
raise OllamaConnectionError() from e
except TimeoutException as e:
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
raise OllamaTimeoutError() from e
async def list_models(self) -> OllamaModelsResponse:
"""
List available models from Ollama.
Returns:
List of available models
Raises:
OllamaConnectionError: Cannot connect to Ollama
"""
if not self._client:
raise RuntimeError("Client not initialized. Use async with context.")
try:
response = await self._client.get("/api/tags")
response.raise_for_status()
return OllamaModelsResponse(**response.json())
except ConnectError as e:
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
raise OllamaConnectionError() from e
async def health_check(self) -> bool:
"""
Check if Ollama service is healthy.
Returns:
True if healthy, False otherwise
"""
if not self._client:
raise RuntimeError("Client not initialized. Use async with context.")
try:
response = await self._client.get("/")
return response.status_code == 200
@@ -174,7 +177,7 @@ class OllamaClient:
async def get_ollama_client() -> AsyncGenerator[OllamaClient, None]:
"""
FastAPI dependency to provide Ollama client.
Follows best practice of dependency injection.
"""
async with OllamaClient() as client:
+1
View File
@@ -5,6 +5,7 @@ Ollama's OpenAI-compatible API rejects messages with `content: null`,
which PydanticAI sends for assistant messages that only contain tool calls.
This provider sanitizes messages to use empty strings instead of null.
"""
from typing import Any
from openai import AsyncOpenAI
+6
View File
@@ -2,6 +2,7 @@
Ollama API schemas.
Internal models for Ollama API communication.
"""
from typing import Any
from src.core.models import CustomBaseModel
@@ -9,12 +10,14 @@ from src.core.models import CustomBaseModel
class OllamaMessage(CustomBaseModel):
"""Message format for Ollama API."""
role: str
content: str
class OllamaChatRequest(CustomBaseModel):
"""Chat request to Ollama API."""
model: str
messages: list[OllamaMessage]
stream: bool = False
@@ -23,6 +26,7 @@ class OllamaChatRequest(CustomBaseModel):
class OllamaChatResponse(CustomBaseModel):
"""Chat response from Ollama API."""
model: str
created_at: str
message: OllamaMessage
@@ -31,6 +35,7 @@ class OllamaChatResponse(CustomBaseModel):
class OllamaModelInfo(CustomBaseModel):
"""Model information from Ollama."""
name: str
modified_at: str
size: int
@@ -39,4 +44,5 @@ class OllamaModelInfo(CustomBaseModel):
class OllamaModelsResponse(CustomBaseModel):
"""Response from Ollama models list endpoint."""
models: list[OllamaModelInfo]
+4 -4
View File
@@ -9,12 +9,12 @@ This module implements the OpenAI Responses API format:
"""
from src.responses.schemas import (
FunctionCallOutputItem,
MessageOutputItem,
OutputItem,
ReasoningOutputItem,
Response,
ResponseRequest,
OutputItem,
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
)
__all__ = [
+5 -16
View File
@@ -6,6 +6,7 @@ Handles:
- Context trimming to fit model limits
- Reserve tokens for output generation
"""
from typing import Any
@@ -59,11 +60,7 @@ class ContextWindow:
# Approximate: 4 characters per token
return total_chars // 4
async def trim_to_fit(
self,
items: list[Any],
reserve_tokens: int = 512
) -> list[Any]:
async def trim_to_fit(self, items: list[Any], reserve_tokens: int = 512) -> list[Any]:
"""
Trim items to fit within context window.
@@ -102,11 +99,7 @@ class ContextWindow:
return kept_items
async def fits_in_context(
self,
items: list[Any],
reserve_tokens: int = 512
) -> bool:
async def fits_in_context(self, items: list[Any], reserve_tokens: int = 512) -> bool:
"""
Check if items fit within context window.
@@ -121,11 +114,7 @@ class ContextWindow:
available_tokens = self.max_tokens - reserve_tokens
return total_tokens <= available_tokens
async def get_usage_stats(
self,
items: list[Any],
reserve_tokens: int = 512
) -> dict:
async def get_usage_stats(self, items: list[Any], reserve_tokens: int = 512) -> dict:
"""
Get context window usage statistics.
@@ -153,7 +142,7 @@ class ContextWindow:
"reserved_tokens": reserve_tokens,
"available_tokens": available_tokens,
"usage_percent": round(usage_percent, 2),
"fits": total_tokens <= available_tokens
"fits": total_tokens <= available_tokens,
}
# ========================================================================
+9 -16
View File
@@ -6,8 +6,8 @@ Supports hybrid approach:
- Optional conversation_id in metadata for server-side grouping
- Server can augment with vector memories (future)
"""
import hashlib
from typing import Dict, List
from src.responses.schemas import Response, ResponseRequest
@@ -36,7 +36,7 @@ class ConversationHistory:
Args:
max_turns: Maximum number of response turns to keep per conversation
"""
self._conversations: Dict[str, List[Response]] = {}
self._conversations: dict[str, list[Response]] = {}
self._max_turns = max_turns
async def get_conversation_id(self, request: ResponseRequest) -> str:
@@ -61,11 +61,7 @@ class ConversationHistory:
first_msg = str(request.input[0]) if request.input else ""
return hashlib.sha256(first_msg.encode()).hexdigest()[:16]
async def add_response(
self,
conversation_id: str,
response: Response
) -> None:
async def add_response(self, conversation_id: str, response: Response) -> None:
"""
Add response to conversation history.
@@ -81,7 +77,7 @@ class ConversationHistory:
# Trim old turns to stay within limit
await self._trim_history(conversation_id)
async def get_history(self, conversation_id: str) -> List[Response]:
async def get_history(self, conversation_id: str) -> list[Response]:
"""
Retrieve conversation history.
@@ -125,20 +121,17 @@ class ConversationHistory:
conversation_id: Conversation identifier
"""
if len(self._conversations[conversation_id]) > self._max_turns:
self._conversations[conversation_id] = (
self._conversations[conversation_id][-self._max_turns:]
)
self._conversations[conversation_id] = self._conversations[conversation_id][
-self._max_turns :
]
# ========================================================================
# Future: Vector Memory Integration
# ========================================================================
async def get_relevant_memories(
self,
conversation_id: str,
query: str,
limit: int = 5
) -> List[dict]:
self, conversation_id: str, query: str, limit: int = 5
) -> list[dict]:
"""
Retrieve relevant memories from vector store.
+6 -9
View File
@@ -7,10 +7,10 @@ OpenAI-compatible /v1/responses endpoint with streaming support.
from fastapi import APIRouter, HTTPException
from sse_starlette.sse import EventSourceResponse
from src.responses import service
from src.responses.schemas import ResponseRequest, Response
from src.core.exceptions import ModelNotFoundError, AppException
from src.core.exceptions import AppException, ModelNotFoundError
from src.core.logging_config import get_logger
from src.responses import service
from src.responses.schemas import Response, ResponseRequest
logger = get_logger(__name__)
@@ -58,14 +58,11 @@ async def create_response(
if use_steward:
logger.info("Streaming with Steward preprocessing for Tatlock request")
from src.responses.streaming import StreamingCoordinator
coordinator = StreamingCoordinator()
return EventSourceResponse(
coordinator.stream_response_with_steward(request)
)
return EventSourceResponse(coordinator.stream_response_with_steward(request))
else:
return EventSourceResponse(
service.create_response_stream(request)
)
return EventSourceResponse(service.create_response_stream(request))
# Non-streaming response
if use_steward:
+37 -45
View File
@@ -8,18 +8,20 @@ OpenAI Responses API format with support for:
- Streaming and non-streaming modes
"""
from typing import Literal, Any
from typing import Any, Literal
from pydantic import Field, field_validator
from src.core.models import CustomBaseModel
# ============================================================================
# Output Item Schemas (appear in response.output array)
# ============================================================================
class OutputTextContent(CustomBaseModel):
"""Text content in message output."""
type: Literal["output_text"] = "output_text"
text: str
annotations: list[dict] = Field(default_factory=list)
@@ -31,6 +33,7 @@ class MessageOutputItem(CustomBaseModel):
Represents the assistant's final response message.
"""
type: Literal["message"] = "message"
id: str
role: Literal["assistant"] = "assistant"
@@ -45,6 +48,7 @@ class ReasoningOutputItem(CustomBaseModel):
Represents the model's thinking/reasoning process.
Displayed separately from the final answer.
"""
type: Literal["reasoning"] = "reasoning"
id: str
summary: list[str] # List of reasoning steps
@@ -57,6 +61,7 @@ class FunctionCallOutputItem(CustomBaseModel):
Represents a tool/function that the model wants to execute.
"""
type: Literal["function_call"] = "function_call"
id: str
name: str
@@ -73,8 +78,10 @@ OutputItem = MessageOutputItem | ReasoningOutputItem | FunctionCallOutputItem #
# Usage Tracking
# ============================================================================
class ResponseUsage(CustomBaseModel):
"""Token usage statistics for the response."""
input_tokens: int
output_tokens: int
reasoning_tokens: int = 0
@@ -85,8 +92,10 @@ class ResponseUsage(CustomBaseModel):
# Request Schema
# ============================================================================
class Tool(CustomBaseModel):
"""Tool/function definition."""
name: str
description: str
parameters: dict[str, Any]
@@ -94,6 +103,7 @@ class Tool(CustomBaseModel):
class ReasoningConfig(CustomBaseModel):
"""Reasoning configuration."""
effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] = "medium"
summary: Literal["auto", "off"] = "auto"
@@ -104,46 +114,25 @@ class ResponseRequest(CustomBaseModel):
OpenAI Responses API format with optional extensions.
"""
model: str = Field(description="Model ID to use")
input: list[dict] = Field(
description="Input messages or previous responses"
)
input: list[dict] = Field(description="Input messages or previous responses")
reasoning: dict[str, Any] | None = Field(
default=None,
description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
)
tools: list[dict] | None = Field(
default=None,
description="Available tools/functions"
default=None, description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
)
tools: list[dict] | None = Field(default=None, description="Available tools/functions")
metadata: dict[str, Any] | None = Field(
default=None,
description="Custom metadata (e.g., conversation_id for server-side tracking)"
)
stream: bool = Field(
default=False,
description="Enable streaming mode"
)
max_output_tokens: int | None = Field(
default=None,
description="Maximum tokens to generate"
)
temperature: float = Field(
default=1.0,
ge=0.0,
le=2.0,
description="Sampling temperature"
)
stop: list[str] | None = Field(
default=None,
description="Stop sequences"
default=None, description="Custom metadata (e.g., conversation_id for server-side tracking)"
)
stream: bool = Field(default=False, description="Enable streaming mode")
max_output_tokens: int | None = Field(default=None, description="Maximum tokens to generate")
temperature: float = Field(default=1.0, ge=0.0, le=2.0, description="Sampling temperature")
stop: list[str] | None = Field(default=None, description="Stop sequences")
user: str | None = Field(
default=None,
description="Unique identifier for end-user (OpenAI standard)"
default=None, description="Unique identifier for end-user (OpenAI standard)"
)
@field_validator('reasoning')
@field_validator("reasoning")
@classmethod
def validate_reasoning(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
"""
@@ -154,21 +143,21 @@ class ResponseRequest(CustomBaseModel):
- summary must be 'auto' or 'off'
"""
if v is not None:
if 'effort' in v:
allowed_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
if v['effort'] not in allowed_efforts:
if "effort" in v:
allowed_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
if v["effort"] not in allowed_efforts:
raise ValueError(
f"reasoning.effort must be one of {allowed_efforts}, got '{v['effort']}'"
)
if 'summary' in v:
allowed_summaries = ['auto', 'off']
if v['summary'] not in allowed_summaries:
if "summary" in v:
allowed_summaries = ["auto", "off"]
if v["summary"] not in allowed_summaries:
raise ValueError(
f"reasoning.summary must be one of {allowed_summaries}, got '{v['summary']}'"
)
return v
@field_validator('max_output_tokens')
@field_validator("max_output_tokens")
@classmethod
def validate_max_output_tokens(cls, v: int | None) -> int | None:
"""
@@ -180,7 +169,7 @@ class ResponseRequest(CustomBaseModel):
raise ValueError(f"max_output_tokens must be positive, got {v}")
return v
@field_validator('stop')
@field_validator("stop")
@classmethod
def validate_stop_sequences(cls, v: list[str] | None) -> list[str] | None:
"""
@@ -203,20 +192,20 @@ class ResponseRequest(CustomBaseModel):
# Response Schema
# ============================================================================
class Response(CustomBaseModel):
"""
Complete response object.
Contains output array with reasoning, function calls, and messages.
"""
id: str = Field(description="Unique response ID")
object: Literal["response"] = "response"
created_at: int = Field(description="Unix timestamp")
model: str = Field(description="Model used")
status: Literal["completed", "in_progress", "failed", "cancelled"]
output: list[OutputItem] = Field(
description="Output items (reasoning, function_call, message)"
)
output: list[OutputItem] = Field(description="Output items (reasoning, function_call, message)")
usage: ResponseUsage = Field(description="Token usage statistics")
@@ -224,8 +213,10 @@ class Response(CustomBaseModel):
# Error Schema
# ============================================================================
class ErrorDetail(CustomBaseModel):
"""Error detail object."""
type: str
message: str
code: int | None = None
@@ -233,4 +224,5 @@ class ErrorDetail(CustomBaseModel):
class ErrorResponse(CustomBaseModel):
"""Error response format."""
error: ErrorDetail
+67 -64
View File
@@ -52,9 +52,9 @@ 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'):
if hasattr(item, "content"):
for content in item.content:
if hasattr(content, 'text'):
if hasattr(content, "text"):
return content.text[:200]
return ""
@@ -79,10 +79,12 @@ async def _execute_single_delegation(
result summary is a curated user-safe sentence.
"""
import time
start_time = time.time()
if agent_name == "biographer":
from src.agents.delegation import delegate_to_biographer
result = await delegate_to_biographer(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_biographer", duration)
@@ -90,6 +92,7 @@ async def _execute_single_delegation(
elif agent_name == "librarian":
from src.agents.delegation import delegate_to_librarian
result = await delegate_to_librarian(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_librarian", duration)
@@ -97,6 +100,7 @@ async def _execute_single_delegation(
elif agent_name == "housekeeper":
from src.agents.delegation import delegate_to_housekeeper
result = await delegate_to_housekeeper(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_housekeeper", duration)
@@ -107,9 +111,7 @@ async def _execute_single_delegation(
async def _handle_text_delegation(
response: str,
tracker: "ToolCallTracker",
conversation_id: str
response: str, tracker: "ToolCallTracker", conversation_id: str
) -> str:
"""
Handle text-based delegation fallback.
@@ -173,8 +175,7 @@ async def _handle_text_delegation(
conversation_id=conversation_id,
)
tasks = [
_execute_single_delegation(agent.lower(), task, tracker)
for agent, task in matches
_execute_single_delegation(agent.lower(), task, tracker) for agent, task in matches
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -188,10 +189,7 @@ async def _handle_text_delegation(
got=len(results),
conversation_id=conversation_id,
)
return (
"I apologize, sir. I was unable to complete the "
"requested delegations."
)
return "I apologize, sir. I was unable to complete the " "requested delegations."
# Combine results (failures carry curated user-safe sentences)
summaries = []
@@ -205,8 +203,7 @@ async def _handle_text_delegation(
conversation_id=conversation_id,
)
summaries.append(
f"**{agent_name}**: "
f"{get_think_message(agent_name, task, 'error')}"
f"**{agent_name}**: " f"{get_think_message(agent_name, task, 'error')}"
)
else:
_, output, _ = item
@@ -226,9 +223,7 @@ async def _handle_text_delegation(
conversation_id=conversation_id,
)
try:
_, output, _ = await _execute_single_delegation(
agent_name, task, tracker
)
_, output, _ = await _execute_single_delegation(agent_name, task, tracker)
summaries.append(output)
except Exception as e:
logger.error(
@@ -421,7 +416,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
elif isinstance(item, FunctionCallOutputItem):
func_text = item.arguments
output_tokens += len(func_text) // 4
elif hasattr(item, 'type'):
elif hasattr(item, "type"):
# Agent OutputItem objects (backward compatibility)
if item.type == "reasoning":
reasoning_text = " ".join(item.data.get("summary", []))
@@ -439,7 +434,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
input_tokens=input_tokens,
output_tokens=output_tokens,
reasoning_tokens=reasoning_tokens,
total_tokens=total_tokens
total_tokens=total_tokens,
)
@@ -527,7 +522,7 @@ async def create_response(request: ResponseRequest) -> Response:
model=request.model,
status="completed",
output=converted_items,
usage=usage
usage=usage,
)
# Track conversation history (for analytics and future vector memory)
@@ -640,12 +635,15 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
# 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
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
@@ -677,7 +675,9 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
)
# Add text delegation results to expert_results
if text_delegation_results != orchestration_results["raw_output"]:
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
orchestration_results["expert_results"]["text_delegation"] = (
text_delegation_results
)
# Phase 2: Synthesize butler-toned response from all results
tatlock_response = await tatlock.synthesize_from_results(
@@ -694,23 +694,25 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
# Add Steward reasoning as reasoning output
if enriched.steward_reasoning:
output_items.append(ReasoningOutputItem(
id=f"rs_{generate_id()}",
summary=[enriched.steward_reasoning],
status="completed"
))
output_items.append(
ReasoningOutputItem(
id=f"rs_{generate_id()}",
summary=[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"
))
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)
@@ -721,7 +723,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
model=request.model,
status="completed",
output=output_items,
usage=usage
usage=usage,
)
# Track conversation history
@@ -752,9 +754,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
raise
async def create_response_stream(
request: ResponseRequest
) -> AsyncGenerator[dict, None]:
async def create_response_stream(request: ResponseRequest) -> AsyncGenerator[dict, None]:
"""
Create streaming response.
@@ -774,10 +774,7 @@ async def create_response_stream(
coordinator = StreamingCoordinator()
async for event in coordinator.stream_response(request):
yield {
"event": event.event,
"data": event.model_dump_json()
}
yield {"event": event.event, "data": event.model_dump_json()}
async def get_conversation_history(conversation_id: str) -> list[Response]:
@@ -802,7 +799,7 @@ async def get_conversation_stats() -> dict:
"""
return {
"total_conversations": await _conversation_history.get_conversation_count(),
"max_turns_per_conversation": _conversation_history._max_turns
"max_turns_per_conversation": _conversation_history._max_turns,
}
@@ -843,23 +840,29 @@ def _convert_output_items(items: list) -> list:
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")
))
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")
))
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")
))
converted.append(
FunctionCallOutputItem(
id=item.id,
name=item.data["name"],
arguments=item.data["arguments"],
status=item.data.get("status", "completed"),
)
)
return converted
+83 -83
View File
@@ -30,8 +30,10 @@ logger = get_logger(__name__)
# Stream Event Types
# ============================================================================
class StreamEventType(str, Enum):
"""Streaming event types for Responses API."""
REASONING_SUMMARY_DELTA = "response.reasoning_summary_text.delta"
REASONING_SUMMARY_DONE = "response.reasoning_summary_text.done"
OUTPUT_TEXT_DELTA = "response.output_text.delta"
@@ -46,30 +48,38 @@ class StreamEventType(str, Enum):
# Stream Event Schemas
# ============================================================================
class ReasoningSummaryDelta(CustomBaseModel):
"""Reasoning summary text delta event."""
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = StreamEventType.REASONING_SUMMARY_DELTA
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = (
StreamEventType.REASONING_SUMMARY_DELTA
)
delta: str
class ReasoningSummaryDone(CustomBaseModel):
"""Reasoning summary completion event."""
event: Literal[StreamEventType.REASONING_SUMMARY_DONE] = StreamEventType.REASONING_SUMMARY_DONE
class OutputTextDelta(CustomBaseModel):
"""Output text delta event."""
event: Literal[StreamEventType.OUTPUT_TEXT_DELTA] = StreamEventType.OUTPUT_TEXT_DELTA
delta: str
class OutputTextDone(CustomBaseModel):
"""Output text completion event."""
event: Literal[StreamEventType.OUTPUT_TEXT_DONE] = StreamEventType.OUTPUT_TEXT_DONE
class FunctionCallDelta(CustomBaseModel):
"""Function call arguments delta event."""
event: Literal[StreamEventType.FUNCTION_CALL_DELTA] = StreamEventType.FUNCTION_CALL_DELTA
delta: str
name: str | None = None # Only in first chunk
@@ -77,31 +87,34 @@ class FunctionCallDelta(CustomBaseModel):
class FunctionCallDone(CustomBaseModel):
"""Function call completion event."""
event: Literal[StreamEventType.FUNCTION_CALL_DONE] = StreamEventType.FUNCTION_CALL_DONE
class ResponseDone(CustomBaseModel):
"""Response completion event with full response."""
event: Literal[StreamEventType.RESPONSE_DONE] = StreamEventType.RESPONSE_DONE
response: Response
class ErrorEvent(CustomBaseModel):
"""Error event."""
event: Literal[StreamEventType.ERROR] = StreamEventType.ERROR
error: dict
# Union type for all stream events
StreamEvent = (
ReasoningSummaryDelta |
ReasoningSummaryDone |
OutputTextDelta |
OutputTextDone |
FunctionCallDelta |
FunctionCallDone |
ResponseDone |
ErrorEvent
ReasoningSummaryDelta
| ReasoningSummaryDone
| OutputTextDelta
| OutputTextDone
| FunctionCallDelta
| FunctionCallDone
| ResponseDone
| ErrorEvent
)
@@ -109,6 +122,7 @@ StreamEvent = (
# Streaming Coordinator
# ============================================================================
class StreamingCoordinator:
"""
Coordinates streaming from agents to SSE format.
@@ -123,7 +137,7 @@ class StreamingCoordinator:
async def stream_response_with_steward(
self,
request: "ResponseRequest" # type: ignore # Forward reference
request: "ResponseRequest", # type: ignore # Forward reference
) -> AsyncGenerator[StreamEvent, None]:
"""
Stream response with Steward preprocessing and two-phase Tatlock execution.
@@ -181,10 +195,13 @@ class StreamingCoordinator:
# Check if direct delegation is recommended
delegation_agents = {"biographer", "librarian", "housekeeper"}
delegation_only = all(
cap in delegation_agents
for cap in enriched.recommendation.recommended_capabilities
) and enriched.recommendation.recommended_capabilities
delegation_only = (
all(
cap in delegation_agents
for cap in enriched.recommendation.recommended_capabilities
)
and enriched.recommendation.recommended_capabilities
)
tatlock = TatlockAgent()
@@ -222,7 +239,7 @@ class StreamingCoordinator:
# Stream the synthesized response
chunk_size = 50
for i in range(0, len(tatlock_response), chunk_size):
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
yield OutputTextDelta(delta=tatlock_response[i : i + chunk_size])
await asyncio.sleep(0.02)
yield OutputTextDone()
@@ -231,12 +248,10 @@ class StreamingCoordinator:
message_item = MessageOutputItem(
id=f"msg_{generate_id()}",
role="assistant",
content=[OutputTextContent(
type="output_text",
text=tatlock_response,
annotations=[]
)],
status="completed"
content=[
OutputTextContent(type="output_text", text=tatlock_response, annotations=[])
],
status="completed",
)
output_items.append(message_item)
@@ -252,7 +267,7 @@ class StreamingCoordinator:
model=request.model,
status="completed",
output=output_items,
usage=usage
usage=usage,
)
# Track conversation history
@@ -319,17 +334,11 @@ class StreamingCoordinator:
try:
# Execute delegation
if agent == "librarian":
result = await delegate_to_librarian(
task=user_message, context=context
)
result = await delegate_to_librarian(task=user_message, context=context)
elif agent == "biographer":
result = await delegate_to_biographer(
task=user_message, context=context
)
result = await delegate_to_biographer(task=user_message, context=context)
elif agent == "housekeeper":
result = await delegate_to_housekeeper(
task=user_message, context=context
)
result = await delegate_to_housekeeper(task=user_message, context=context)
else:
result = None
@@ -366,17 +375,19 @@ class StreamingCoordinator:
yield ReasoningSummaryDone()
if results is not None:
results.update({
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {},
"raw_output": "",
"think_messages": think_messages,
})
results.update(
{
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {},
"raw_output": "",
"think_messages": think_messages,
}
)
async def stream_response(
self,
request: "ResponseRequest" # type: ignore # Forward reference
request: "ResponseRequest", # type: ignore # Forward reference
) -> AsyncGenerator[StreamEvent, None]:
"""
Coordinate streaming from agent to SSE events.
@@ -435,18 +446,13 @@ class StreamingCoordinator:
elif item.type == "function_call":
# Stream function call arguments
# First chunk includes name
yield FunctionCallDelta(
name=item.data["name"],
delta=""
)
yield FunctionCallDelta(name=item.data["name"], delta="")
# Stream arguments in chunks
args = item.data["arguments"]
chunk_size = 20
for i in range(0, len(args), chunk_size):
yield FunctionCallDelta(
delta=args[i:i+chunk_size]
)
yield FunctionCallDelta(delta=args[i : i + chunk_size])
await asyncio.sleep(0.03)
yield FunctionCallDone()
@@ -458,7 +464,7 @@ class StreamingCoordinator:
# Only stream the NEW text (delta) since last update
if current_text.startswith(last_message_text):
# Extract only the new portion
delta_text = current_text[len(last_message_text):]
delta_text = current_text[len(last_message_text) :]
if delta_text:
# Stream the delta text in chunks while preserving formatting
@@ -466,17 +472,16 @@ class StreamingCoordinator:
chunk_size = 50 # characters per chunk
for i in range(0, len(delta_text), chunk_size):
chunk = delta_text[i:i+chunk_size]
chunk = delta_text[i : i + chunk_size]
# Check stop sequences on full accumulated text
stop_found, text_before_stop = self._check_stop_sequence(
current_text,
request.stop
current_text, request.stop
)
if stop_found:
# Only emit remaining delta before stop
remaining = text_before_stop[len(last_message_text):]
remaining = text_before_stop[len(last_message_text) :]
if remaining:
yield OutputTextDelta(delta=remaining)
yield OutputTextDone()
@@ -508,11 +513,12 @@ class StreamingCoordinator:
model=request.model,
status="completed",
output=self._convert_output_items(output_items),
usage=usage
usage=usage,
)
# Track conversation history (import here to avoid circular dependency)
from src.responses.service import _conversation_history
conversation_id = await _conversation_history.get_conversation_id(request)
await _conversation_history.add_response(conversation_id, final_response)
@@ -534,24 +540,30 @@ class StreamingCoordinator:
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")
))
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")
))
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")
))
converted.append(
FunctionCallOutputItem(
id=item.id,
name=item.data["name"],
arguments=item.data["arguments"],
status=item.data.get("status", "completed"),
)
)
return converted
@@ -576,18 +588,10 @@ class StreamingCoordinator:
error_type = "internal_error"
code = 500
return ErrorEvent(
error={
"type": error_type,
"message": str(error),
"code": code
}
)
return ErrorEvent(error={"type": error_type, "message": str(error), "code": code})
def _check_stop_sequence(
self,
accumulated_text: str,
stop_sequences: list[str] | None
self, accumulated_text: str, stop_sequences: list[str] | None
) -> tuple[bool, str]:
"""
Check if any stop sequence is encountered.
@@ -624,11 +628,7 @@ class StreamingCoordinator:
"""
return len(text) // 4
def _check_max_tokens(
self,
current_tokens: int,
max_tokens: int | None
) -> bool:
def _check_max_tokens(self, current_tokens: int, max_tokens: int | None) -> bool:
"""
Check if max tokens limit reached.