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:
+4
-8
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user