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
+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)