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: