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:
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@
|
||||
Chat completion router.
|
||||
OpenAI-compatible /v1/chat/completions endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
OpenAI-compatible chat completion schemas.
|
||||
Following OpenAI API specification for compatibility.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
@@ -11,6 +12,7 @@ from src.core.models import CustomBaseModel
|
||||
|
||||
class ChatMessage(CustomBaseModel):
|
||||
"""OpenAI-compatible chat message."""
|
||||
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
name: str | None = None
|
||||
@@ -18,6 +20,7 @@ class ChatMessage(CustomBaseModel):
|
||||
|
||||
class ChatCompletionRequest(CustomBaseModel):
|
||||
"""OpenAI-compatible chat completion request."""
|
||||
|
||||
model: str = Field(..., description="Model to use for completion")
|
||||
messages: list[ChatMessage] = Field(..., description="List of messages")
|
||||
temperature: float | None = Field(default=0.7, ge=0.0, le=2.0)
|
||||
@@ -29,6 +32,7 @@ class ChatCompletionRequest(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChoice(CustomBaseModel):
|
||||
"""Choice in chat completion response."""
|
||||
|
||||
index: int
|
||||
message: ChatMessage
|
||||
finish_reason: str | None
|
||||
@@ -36,6 +40,7 @@ class ChatCompletionChoice(CustomBaseModel):
|
||||
|
||||
class ChatCompletionUsage(CustomBaseModel):
|
||||
"""Token usage information."""
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
@@ -43,6 +48,7 @@ class ChatCompletionUsage(CustomBaseModel):
|
||||
|
||||
class ChatCompletionResponse(CustomBaseModel):
|
||||
"""OpenAI-compatible chat completion response."""
|
||||
|
||||
id: str
|
||||
object: str = "chat.completion"
|
||||
created: int
|
||||
@@ -53,6 +59,7 @@ class ChatCompletionResponse(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunkDelta(CustomBaseModel):
|
||||
"""Delta in streaming chunk."""
|
||||
|
||||
role: str | None = None
|
||||
content: str | None = None
|
||||
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
|
||||
@@ -60,6 +67,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunkChoice(CustomBaseModel):
|
||||
"""Choice in streaming chunk."""
|
||||
|
||||
index: int
|
||||
delta: ChatCompletionChunkDelta
|
||||
finish_reason: str | None = None
|
||||
@@ -67,6 +75,7 @@ class ChatCompletionChunkChoice(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunk(CustomBaseModel):
|
||||
"""OpenAI-compatible streaming chunk."""
|
||||
|
||||
id: str
|
||||
object: str = "chat.completion.chunk"
|
||||
created: int
|
||||
|
||||
+12
-14
@@ -4,17 +4,17 @@ Chat completion service.
|
||||
Wrapper around Responses API that converts to Chat Completions format.
|
||||
Embeds reasoning in <think> tags for Open WebUI compatibility.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from src.chat import constants
|
||||
from src.chat.schemas import (
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionChunkChoice,
|
||||
ChatCompletionChunkDelta,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionUsage,
|
||||
@@ -43,10 +43,7 @@ async def create_chat_completion(
|
||||
created_at = int(time.time())
|
||||
|
||||
# Convert Chat request to Responses request
|
||||
input_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
||||
|
||||
response_request = ResponseRequest(
|
||||
model=request.model,
|
||||
@@ -54,7 +51,9 @@ async def create_chat_completion(
|
||||
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
||||
temperature=request.temperature or 1.0,
|
||||
max_output_tokens=request.max_tokens,
|
||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||
stop=request.stop
|
||||
if isinstance(request.stop, list)
|
||||
else ([request.stop] if request.stop else None),
|
||||
)
|
||||
|
||||
# Call Responses API (will use Steward for Tatlock)
|
||||
@@ -118,16 +117,13 @@ async def create_chat_completion_stream(
|
||||
Yields:
|
||||
Chat completion chunks with reasoning as <think> tags
|
||||
"""
|
||||
from src.responses.streaming import StreamingCoordinator, StreamEventType
|
||||
from src.responses.streaming import StreamEventType, StreamingCoordinator
|
||||
|
||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Convert Chat request to Responses request
|
||||
input_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
||||
|
||||
response_request = ResponseRequest(
|
||||
model=request.model,
|
||||
@@ -135,7 +131,9 @@ async def create_chat_completion_stream(
|
||||
reasoning={"effort": "medium", "summary": "auto"},
|
||||
temperature=request.temperature or 1.0,
|
||||
max_output_tokens=request.max_tokens,
|
||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||
stop=request.stop
|
||||
if isinstance(request.stop, list)
|
||||
else ([request.stop] if request.stop else None),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
+34
-86
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ Provides async embedding operations via Ollama API:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -4,6 +4,8 @@ Trace viewer router.
|
||||
Serves the trace viewer UI and trace files when tracing is enabled.
|
||||
Only available when DEBUG=true.
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -66,12 +68,12 @@ async def list_traces(
|
||||
return {"traces": [], "total": 0}
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Calculate cutoff time if filtering by time
|
||||
cutoff_time = None
|
||||
if since_minutes:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(minutes=since_minutes)
|
||||
|
||||
# Get all trace files, sorted by modification time (newest first)
|
||||
trace_files = sorted(
|
||||
@@ -93,7 +95,7 @@ async def list_traces(
|
||||
trace_timestamp = data.get("timestamp")
|
||||
if cutoff_time and trace_timestamp:
|
||||
try:
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace("Z", "+00:00"))
|
||||
if ts < cutoff_time:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
@@ -109,15 +111,17 @@ async def list_traces(
|
||||
if search and search.lower() not in request_preview.lower():
|
||||
continue
|
||||
|
||||
traces.append({
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
})
|
||||
traces.append(
|
||||
{
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
|
||||
|
||||
@@ -145,6 +149,7 @@ async def get_trace(trace_id: str):
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(trace_path) as f:
|
||||
data = json.load(f)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
+8
-7
@@ -9,8 +9,9 @@ Main responsibilities:
|
||||
- Router registration
|
||||
- Lifecycle management
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -64,7 +65,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
def create_application() -> FastAPI:
|
||||
"""
|
||||
Application factory.
|
||||
|
||||
|
||||
Creates and configures the FastAPI application.
|
||||
Following best practice of using factory pattern.
|
||||
"""
|
||||
@@ -75,7 +76,7 @@ def create_application() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
debug=config.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
# Add middleware
|
||||
application.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -84,10 +85,10 @@ def create_application() -> FastAPI:
|
||||
allow_methods=config.CORS_ALLOW_METHODS,
|
||||
allow_headers=config.CORS_ALLOW_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
# Register exception handlers
|
||||
register_exception_handlers(application)
|
||||
|
||||
|
||||
# Include routers
|
||||
application.include_router(core_router) # Health and root endpoints
|
||||
application.include_router(chat_router, prefix=config.API_PREFIX)
|
||||
@@ -105,10 +106,10 @@ def create_application() -> FastAPI:
|
||||
def register_exception_handlers(application: FastAPI) -> None:
|
||||
"""
|
||||
Register global exception handlers.
|
||||
|
||||
|
||||
Provides consistent error responses compatible with OpenAI API.
|
||||
"""
|
||||
|
||||
|
||||
@application.exception_handler(AppException)
|
||||
async def app_exception_handler(
|
||||
request: Request,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Models router.
|
||||
OpenAI-compatible /v1/models endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -18,9 +19,9 @@ router = APIRouter(prefix="/models", tags=["models"])
|
||||
async def list_models() -> ModelsResponse:
|
||||
"""
|
||||
List available models (OpenAI-compatible).
|
||||
|
||||
|
||||
Currently returns mock model list.
|
||||
|
||||
|
||||
Returns:
|
||||
List of available models
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
OpenAI-compatible models schemas.
|
||||
"""
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
|
||||
|
||||
class Model(CustomBaseModel):
|
||||
"""OpenAI-compatible model object."""
|
||||
|
||||
id: str
|
||||
object: str = "model"
|
||||
created: int
|
||||
@@ -14,5 +16,6 @@ class Model(CustomBaseModel):
|
||||
|
||||
class ModelsResponse(CustomBaseModel):
|
||||
"""OpenAI-compatible models list response."""
|
||||
|
||||
object: str = "list"
|
||||
data: list[Model]
|
||||
|
||||
+33
-30
@@ -2,8 +2,10 @@
|
||||
Ollama HTTP client.
|
||||
Handles all communication with the Ollama service.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import ConnectError, TimeoutException
|
||||
@@ -22,14 +24,14 @@ logger = logging.getLogger(__name__)
|
||||
class OllamaClient:
|
||||
"""
|
||||
Async client for Ollama API.
|
||||
|
||||
|
||||
Follows best practice of using async for I/O operations.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: int | None = None):
|
||||
"""
|
||||
Initialize Ollama client.
|
||||
|
||||
|
||||
Args:
|
||||
base_url: Ollama server URL (defaults to config)
|
||||
timeout: Request timeout in seconds (defaults to config)
|
||||
@@ -37,7 +39,7 @@ class OllamaClient:
|
||||
self.base_url = base_url or str(config.OLLAMA_HOST)
|
||||
self.timeout = timeout or config.OLLAMA_TIMEOUT
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def __aenter__(self) -> "OllamaClient":
|
||||
"""Async context manager entry."""
|
||||
self._client = httpx.AsyncClient(
|
||||
@@ -45,32 +47,32 @@ class OllamaClient:
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
request: OllamaChatRequest,
|
||||
) -> OllamaChatResponse:
|
||||
"""
|
||||
Send chat request to Ollama (non-streaming).
|
||||
|
||||
|
||||
Args:
|
||||
request: Chat request with model and messages
|
||||
|
||||
|
||||
Returns:
|
||||
Complete chat response
|
||||
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
OllamaTimeoutError: Request timed out
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
"/api/chat",
|
||||
@@ -78,38 +80,38 @@ class OllamaClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
return OllamaChatResponse(**response.json())
|
||||
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
|
||||
except TimeoutException as e:
|
||||
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
|
||||
raise OllamaTimeoutError() from e
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
request: OllamaChatRequest,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""
|
||||
Send streaming chat request to Ollama.
|
||||
|
||||
|
||||
Args:
|
||||
request: Chat request with stream=True
|
||||
|
||||
|
||||
Yields:
|
||||
Streaming response chunks
|
||||
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
OllamaTimeoutError: Request timed out
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
|
||||
# Ensure streaming is enabled
|
||||
request.stream = True
|
||||
|
||||
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST",
|
||||
@@ -120,48 +122,49 @@ class OllamaClient:
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip():
|
||||
import json
|
||||
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
|
||||
except TimeoutException as e:
|
||||
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
|
||||
raise OllamaTimeoutError() from e
|
||||
|
||||
|
||||
async def list_models(self) -> OllamaModelsResponse:
|
||||
"""
|
||||
List available models from Ollama.
|
||||
|
||||
|
||||
Returns:
|
||||
List of available models
|
||||
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
|
||||
try:
|
||||
response = await self._client.get("/api/tags")
|
||||
response.raise_for_status()
|
||||
return OllamaModelsResponse(**response.json())
|
||||
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Ollama service is healthy.
|
||||
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
|
||||
try:
|
||||
response = await self._client.get("/")
|
||||
return response.status_code == 200
|
||||
@@ -174,7 +177,7 @@ class OllamaClient:
|
||||
async def get_ollama_client() -> AsyncGenerator[OllamaClient, None]:
|
||||
"""
|
||||
FastAPI dependency to provide Ollama client.
|
||||
|
||||
|
||||
Follows best practice of dependency injection.
|
||||
"""
|
||||
async with OllamaClient() as client:
|
||||
|
||||
@@ -5,6 +5,7 @@ Ollama's OpenAI-compatible API rejects messages with `content: null`,
|
||||
which PydanticAI sends for assistant messages that only contain tool calls.
|
||||
This provider sanitizes messages to use empty strings instead of null.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Ollama API schemas.
|
||||
Internal models for Ollama API communication.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
@@ -9,12 +10,14 @@ from src.core.models import CustomBaseModel
|
||||
|
||||
class OllamaMessage(CustomBaseModel):
|
||||
"""Message format for Ollama API."""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class OllamaChatRequest(CustomBaseModel):
|
||||
"""Chat request to Ollama API."""
|
||||
|
||||
model: str
|
||||
messages: list[OllamaMessage]
|
||||
stream: bool = False
|
||||
@@ -23,6 +26,7 @@ class OllamaChatRequest(CustomBaseModel):
|
||||
|
||||
class OllamaChatResponse(CustomBaseModel):
|
||||
"""Chat response from Ollama API."""
|
||||
|
||||
model: str
|
||||
created_at: str
|
||||
message: OllamaMessage
|
||||
@@ -31,6 +35,7 @@ class OllamaChatResponse(CustomBaseModel):
|
||||
|
||||
class OllamaModelInfo(CustomBaseModel):
|
||||
"""Model information from Ollama."""
|
||||
|
||||
name: str
|
||||
modified_at: str
|
||||
size: int
|
||||
@@ -39,4 +44,5 @@ class OllamaModelInfo(CustomBaseModel):
|
||||
|
||||
class OllamaModelsResponse(CustomBaseModel):
|
||||
"""Response from Ollama models list endpoint."""
|
||||
|
||||
models: list[OllamaModelInfo]
|
||||
|
||||
@@ -9,12 +9,12 @@ This module implements the OpenAI Responses API format:
|
||||
"""
|
||||
|
||||
from src.responses.schemas import (
|
||||
FunctionCallOutputItem,
|
||||
MessageOutputItem,
|
||||
OutputItem,
|
||||
ReasoningOutputItem,
|
||||
Response,
|
||||
ResponseRequest,
|
||||
OutputItem,
|
||||
MessageOutputItem,
|
||||
ReasoningOutputItem,
|
||||
FunctionCallOutputItem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles:
|
||||
- Context trimming to fit model limits
|
||||
- Reserve tokens for output generation
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -59,11 +60,7 @@ class ContextWindow:
|
||||
# Approximate: 4 characters per token
|
||||
return total_chars // 4
|
||||
|
||||
async def trim_to_fit(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> list[Any]:
|
||||
async def trim_to_fit(self, items: list[Any], reserve_tokens: int = 512) -> list[Any]:
|
||||
"""
|
||||
Trim items to fit within context window.
|
||||
|
||||
@@ -102,11 +99,7 @@ class ContextWindow:
|
||||
|
||||
return kept_items
|
||||
|
||||
async def fits_in_context(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> bool:
|
||||
async def fits_in_context(self, items: list[Any], reserve_tokens: int = 512) -> bool:
|
||||
"""
|
||||
Check if items fit within context window.
|
||||
|
||||
@@ -121,11 +114,7 @@ class ContextWindow:
|
||||
available_tokens = self.max_tokens - reserve_tokens
|
||||
return total_tokens <= available_tokens
|
||||
|
||||
async def get_usage_stats(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> dict:
|
||||
async def get_usage_stats(self, items: list[Any], reserve_tokens: int = 512) -> dict:
|
||||
"""
|
||||
Get context window usage statistics.
|
||||
|
||||
@@ -153,7 +142,7 @@ class ContextWindow:
|
||||
"reserved_tokens": reserve_tokens,
|
||||
"available_tokens": available_tokens,
|
||||
"usage_percent": round(usage_percent, 2),
|
||||
"fits": total_tokens <= available_tokens
|
||||
"fits": total_tokens <= available_tokens,
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -6,8 +6,8 @@ Supports hybrid approach:
|
||||
- Optional conversation_id in metadata for server-side grouping
|
||||
- Server can augment with vector memories (future)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Dict, List
|
||||
|
||||
from src.responses.schemas import Response, ResponseRequest
|
||||
|
||||
@@ -36,7 +36,7 @@ class ConversationHistory:
|
||||
Args:
|
||||
max_turns: Maximum number of response turns to keep per conversation
|
||||
"""
|
||||
self._conversations: Dict[str, List[Response]] = {}
|
||||
self._conversations: dict[str, list[Response]] = {}
|
||||
self._max_turns = max_turns
|
||||
|
||||
async def get_conversation_id(self, request: ResponseRequest) -> str:
|
||||
@@ -61,11 +61,7 @@ class ConversationHistory:
|
||||
first_msg = str(request.input[0]) if request.input else ""
|
||||
return hashlib.sha256(first_msg.encode()).hexdigest()[:16]
|
||||
|
||||
async def add_response(
|
||||
self,
|
||||
conversation_id: str,
|
||||
response: Response
|
||||
) -> None:
|
||||
async def add_response(self, conversation_id: str, response: Response) -> None:
|
||||
"""
|
||||
Add response to conversation history.
|
||||
|
||||
@@ -81,7 +77,7 @@ class ConversationHistory:
|
||||
# Trim old turns to stay within limit
|
||||
await self._trim_history(conversation_id)
|
||||
|
||||
async def get_history(self, conversation_id: str) -> List[Response]:
|
||||
async def get_history(self, conversation_id: str) -> list[Response]:
|
||||
"""
|
||||
Retrieve conversation history.
|
||||
|
||||
@@ -125,20 +121,17 @@ class ConversationHistory:
|
||||
conversation_id: Conversation identifier
|
||||
"""
|
||||
if len(self._conversations[conversation_id]) > self._max_turns:
|
||||
self._conversations[conversation_id] = (
|
||||
self._conversations[conversation_id][-self._max_turns:]
|
||||
)
|
||||
self._conversations[conversation_id] = self._conversations[conversation_id][
|
||||
-self._max_turns :
|
||||
]
|
||||
|
||||
# ========================================================================
|
||||
# Future: Vector Memory Integration
|
||||
# ========================================================================
|
||||
|
||||
async def get_relevant_memories(
|
||||
self,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
limit: int = 5
|
||||
) -> List[dict]:
|
||||
self, conversation_id: str, query: str, limit: int = 5
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Retrieve relevant memories from vector store.
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ OpenAI-compatible /v1/responses endpoint with streaming support.
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from src.responses import service
|
||||
from src.responses.schemas import ResponseRequest, Response
|
||||
from src.core.exceptions import ModelNotFoundError, AppException
|
||||
from src.core.exceptions import AppException, ModelNotFoundError
|
||||
from src.core.logging_config import get_logger
|
||||
from src.responses import service
|
||||
from src.responses.schemas import Response, ResponseRequest
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -58,14 +58,11 @@ async def create_response(
|
||||
if use_steward:
|
||||
logger.info("Streaming with Steward preprocessing for Tatlock request")
|
||||
from src.responses.streaming import StreamingCoordinator
|
||||
|
||||
coordinator = StreamingCoordinator()
|
||||
return EventSourceResponse(
|
||||
coordinator.stream_response_with_steward(request)
|
||||
)
|
||||
return EventSourceResponse(coordinator.stream_response_with_steward(request))
|
||||
else:
|
||||
return EventSourceResponse(
|
||||
service.create_response_stream(request)
|
||||
)
|
||||
return EventSourceResponse(service.create_response_stream(request))
|
||||
|
||||
# Non-streaming response
|
||||
if use_steward:
|
||||
|
||||
+37
-45
@@ -8,18 +8,20 @@ OpenAI Responses API format with support for:
|
||||
- Streaming and non-streaming modes
|
||||
"""
|
||||
|
||||
from typing import Literal, Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Output Item Schemas (appear in response.output array)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OutputTextContent(CustomBaseModel):
|
||||
"""Text content in message output."""
|
||||
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str
|
||||
annotations: list[dict] = Field(default_factory=list)
|
||||
@@ -31,6 +33,7 @@ class MessageOutputItem(CustomBaseModel):
|
||||
|
||||
Represents the assistant's final response message.
|
||||
"""
|
||||
|
||||
type: Literal["message"] = "message"
|
||||
id: str
|
||||
role: Literal["assistant"] = "assistant"
|
||||
@@ -45,6 +48,7 @@ class ReasoningOutputItem(CustomBaseModel):
|
||||
Represents the model's thinking/reasoning process.
|
||||
Displayed separately from the final answer.
|
||||
"""
|
||||
|
||||
type: Literal["reasoning"] = "reasoning"
|
||||
id: str
|
||||
summary: list[str] # List of reasoning steps
|
||||
@@ -57,6 +61,7 @@ class FunctionCallOutputItem(CustomBaseModel):
|
||||
|
||||
Represents a tool/function that the model wants to execute.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"] = "function_call"
|
||||
id: str
|
||||
name: str
|
||||
@@ -73,8 +78,10 @@ OutputItem = MessageOutputItem | ReasoningOutputItem | FunctionCallOutputItem #
|
||||
# Usage Tracking
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ResponseUsage(CustomBaseModel):
|
||||
"""Token usage statistics for the response."""
|
||||
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
reasoning_tokens: int = 0
|
||||
@@ -85,8 +92,10 @@ class ResponseUsage(CustomBaseModel):
|
||||
# Request Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Tool(CustomBaseModel):
|
||||
"""Tool/function definition."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
@@ -94,6 +103,7 @@ class Tool(CustomBaseModel):
|
||||
|
||||
class ReasoningConfig(CustomBaseModel):
|
||||
"""Reasoning configuration."""
|
||||
|
||||
effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] = "medium"
|
||||
summary: Literal["auto", "off"] = "auto"
|
||||
|
||||
@@ -104,46 +114,25 @@ class ResponseRequest(CustomBaseModel):
|
||||
|
||||
OpenAI Responses API format with optional extensions.
|
||||
"""
|
||||
|
||||
model: str = Field(description="Model ID to use")
|
||||
input: list[dict] = Field(
|
||||
description="Input messages or previous responses"
|
||||
)
|
||||
input: list[dict] = Field(description="Input messages or previous responses")
|
||||
reasoning: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
|
||||
)
|
||||
tools: list[dict] | None = Field(
|
||||
default=None,
|
||||
description="Available tools/functions"
|
||||
default=None, description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
|
||||
)
|
||||
tools: list[dict] | None = Field(default=None, description="Available tools/functions")
|
||||
metadata: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Custom metadata (e.g., conversation_id for server-side tracking)"
|
||||
)
|
||||
stream: bool = Field(
|
||||
default=False,
|
||||
description="Enable streaming mode"
|
||||
)
|
||||
max_output_tokens: int | None = Field(
|
||||
default=None,
|
||||
description="Maximum tokens to generate"
|
||||
)
|
||||
temperature: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
le=2.0,
|
||||
description="Sampling temperature"
|
||||
)
|
||||
stop: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Stop sequences"
|
||||
default=None, description="Custom metadata (e.g., conversation_id for server-side tracking)"
|
||||
)
|
||||
stream: bool = Field(default=False, description="Enable streaming mode")
|
||||
max_output_tokens: int | None = Field(default=None, description="Maximum tokens to generate")
|
||||
temperature: float = Field(default=1.0, ge=0.0, le=2.0, description="Sampling temperature")
|
||||
stop: list[str] | None = Field(default=None, description="Stop sequences")
|
||||
user: str | None = Field(
|
||||
default=None,
|
||||
description="Unique identifier for end-user (OpenAI standard)"
|
||||
default=None, description="Unique identifier for end-user (OpenAI standard)"
|
||||
)
|
||||
|
||||
@field_validator('reasoning')
|
||||
@field_validator("reasoning")
|
||||
@classmethod
|
||||
def validate_reasoning(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""
|
||||
@@ -154,21 +143,21 @@ class ResponseRequest(CustomBaseModel):
|
||||
- summary must be 'auto' or 'off'
|
||||
"""
|
||||
if v is not None:
|
||||
if 'effort' in v:
|
||||
allowed_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
|
||||
if v['effort'] not in allowed_efforts:
|
||||
if "effort" in v:
|
||||
allowed_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
if v["effort"] not in allowed_efforts:
|
||||
raise ValueError(
|
||||
f"reasoning.effort must be one of {allowed_efforts}, got '{v['effort']}'"
|
||||
)
|
||||
if 'summary' in v:
|
||||
allowed_summaries = ['auto', 'off']
|
||||
if v['summary'] not in allowed_summaries:
|
||||
if "summary" in v:
|
||||
allowed_summaries = ["auto", "off"]
|
||||
if v["summary"] not in allowed_summaries:
|
||||
raise ValueError(
|
||||
f"reasoning.summary must be one of {allowed_summaries}, got '{v['summary']}'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('max_output_tokens')
|
||||
@field_validator("max_output_tokens")
|
||||
@classmethod
|
||||
def validate_max_output_tokens(cls, v: int | None) -> int | None:
|
||||
"""
|
||||
@@ -180,7 +169,7 @@ class ResponseRequest(CustomBaseModel):
|
||||
raise ValueError(f"max_output_tokens must be positive, got {v}")
|
||||
return v
|
||||
|
||||
@field_validator('stop')
|
||||
@field_validator("stop")
|
||||
@classmethod
|
||||
def validate_stop_sequences(cls, v: list[str] | None) -> list[str] | None:
|
||||
"""
|
||||
@@ -203,20 +192,20 @@ class ResponseRequest(CustomBaseModel):
|
||||
# Response Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Response(CustomBaseModel):
|
||||
"""
|
||||
Complete response object.
|
||||
|
||||
Contains output array with reasoning, function calls, and messages.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Unique response ID")
|
||||
object: Literal["response"] = "response"
|
||||
created_at: int = Field(description="Unix timestamp")
|
||||
model: str = Field(description="Model used")
|
||||
status: Literal["completed", "in_progress", "failed", "cancelled"]
|
||||
output: list[OutputItem] = Field(
|
||||
description="Output items (reasoning, function_call, message)"
|
||||
)
|
||||
output: list[OutputItem] = Field(description="Output items (reasoning, function_call, message)")
|
||||
usage: ResponseUsage = Field(description="Token usage statistics")
|
||||
|
||||
|
||||
@@ -224,8 +213,10 @@ class Response(CustomBaseModel):
|
||||
# Error Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ErrorDetail(CustomBaseModel):
|
||||
"""Error detail object."""
|
||||
|
||||
type: str
|
||||
message: str
|
||||
code: int | None = None
|
||||
@@ -233,4 +224,5 @@ class ErrorDetail(CustomBaseModel):
|
||||
|
||||
class ErrorResponse(CustomBaseModel):
|
||||
"""Error response format."""
|
||||
|
||||
error: ErrorDetail
|
||||
|
||||
+67
-64
@@ -52,9 +52,9 @@ def _extract_response_preview(response: Response) -> str:
|
||||
"""Extract response preview text for tracing."""
|
||||
if response.output:
|
||||
for item in response.output:
|
||||
if hasattr(item, 'content'):
|
||||
if hasattr(item, "content"):
|
||||
for content in item.content:
|
||||
if hasattr(content, 'text'):
|
||||
if hasattr(content, "text"):
|
||||
return content.text[:200]
|
||||
return ""
|
||||
|
||||
@@ -79,10 +79,12 @@ async def _execute_single_delegation(
|
||||
result summary is a curated user-safe sentence.
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if agent_name == "biographer":
|
||||
from src.agents.delegation import delegate_to_biographer
|
||||
|
||||
result = await delegate_to_biographer(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_biographer", duration)
|
||||
@@ -90,6 +92,7 @@ async def _execute_single_delegation(
|
||||
|
||||
elif agent_name == "librarian":
|
||||
from src.agents.delegation import delegate_to_librarian
|
||||
|
||||
result = await delegate_to_librarian(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_librarian", duration)
|
||||
@@ -97,6 +100,7 @@ async def _execute_single_delegation(
|
||||
|
||||
elif agent_name == "housekeeper":
|
||||
from src.agents.delegation import delegate_to_housekeeper
|
||||
|
||||
result = await delegate_to_housekeeper(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_housekeeper", duration)
|
||||
@@ -107,9 +111,7 @@ async def _execute_single_delegation(
|
||||
|
||||
|
||||
async def _handle_text_delegation(
|
||||
response: str,
|
||||
tracker: "ToolCallTracker",
|
||||
conversation_id: str
|
||||
response: str, tracker: "ToolCallTracker", conversation_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle text-based delegation fallback.
|
||||
@@ -173,8 +175,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
tasks = [
|
||||
_execute_single_delegation(agent.lower(), task, tracker)
|
||||
for agent, task in matches
|
||||
_execute_single_delegation(agent.lower(), task, tracker) for agent, task in matches
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -188,10 +189,7 @@ async def _handle_text_delegation(
|
||||
got=len(results),
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
return (
|
||||
"I apologize, sir. I was unable to complete the "
|
||||
"requested delegations."
|
||||
)
|
||||
return "I apologize, sir. I was unable to complete the " "requested delegations."
|
||||
|
||||
# Combine results (failures carry curated user-safe sentences)
|
||||
summaries = []
|
||||
@@ -205,8 +203,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
summaries.append(
|
||||
f"**{agent_name}**: "
|
||||
f"{get_think_message(agent_name, task, 'error')}"
|
||||
f"**{agent_name}**: " f"{get_think_message(agent_name, task, 'error')}"
|
||||
)
|
||||
else:
|
||||
_, output, _ = item
|
||||
@@ -226,9 +223,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
try:
|
||||
_, output, _ = await _execute_single_delegation(
|
||||
agent_name, task, tracker
|
||||
)
|
||||
_, output, _ = await _execute_single_delegation(agent_name, task, tracker)
|
||||
summaries.append(output)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -421,7 +416,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
||||
elif isinstance(item, FunctionCallOutputItem):
|
||||
func_text = item.arguments
|
||||
output_tokens += len(func_text) // 4
|
||||
elif hasattr(item, 'type'):
|
||||
elif hasattr(item, "type"):
|
||||
# Agent OutputItem objects (backward compatibility)
|
||||
if item.type == "reasoning":
|
||||
reasoning_text = " ".join(item.data.get("summary", []))
|
||||
@@ -439,7 +434,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
total_tokens=total_tokens
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -527,7 +522,7 @@ async def create_response(request: ResponseRequest) -> Response:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=converted_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history (for analytics and future vector memory)
|
||||
@@ -640,12 +635,15 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
||||
# we still use two-phase but delegate directly in Phase 1
|
||||
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||
delegation_only = all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
) and enriched.recommendation.recommended_capabilities
|
||||
delegation_only = (
|
||||
all(
|
||||
cap in delegation_agents for cap in enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
and enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
|
||||
from src.agents.tatlock import TatlockAgent
|
||||
|
||||
tatlock = TatlockAgent()
|
||||
|
||||
# Use enriched query (with location/timezone context) if available
|
||||
@@ -677,7 +675,9 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
)
|
||||
# Add text delegation results to expert_results
|
||||
if text_delegation_results != orchestration_results["raw_output"]:
|
||||
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
||||
orchestration_results["expert_results"]["text_delegation"] = (
|
||||
text_delegation_results
|
||||
)
|
||||
|
||||
# Phase 2: Synthesize butler-toned response from all results
|
||||
tatlock_response = await tatlock.synthesize_from_results(
|
||||
@@ -694,23 +694,25 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
|
||||
# Add Steward reasoning as reasoning output
|
||||
if enriched.steward_reasoning:
|
||||
output_items.append(ReasoningOutputItem(
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=[enriched.steward_reasoning],
|
||||
status="completed"
|
||||
))
|
||||
output_items.append(
|
||||
ReasoningOutputItem(
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=[enriched.steward_reasoning],
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Add Tatlock's message
|
||||
output_items.append(MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[OutputTextContent(
|
||||
type="output_text",
|
||||
text=tatlock_response,
|
||||
annotations=[]
|
||||
)],
|
||||
status="completed"
|
||||
))
|
||||
output_items.append(
|
||||
MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[
|
||||
OutputTextContent(type="output_text", text=tatlock_response, annotations=[])
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate usage (approximate)
|
||||
usage = _calculate_usage(request.input, output_items)
|
||||
@@ -721,7 +723,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=output_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history
|
||||
@@ -752,9 +754,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
raise
|
||||
|
||||
|
||||
async def create_response_stream(
|
||||
request: ResponseRequest
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
async def create_response_stream(request: ResponseRequest) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
Create streaming response.
|
||||
|
||||
@@ -774,10 +774,7 @@ async def create_response_stream(
|
||||
coordinator = StreamingCoordinator()
|
||||
|
||||
async for event in coordinator.stream_response(request):
|
||||
yield {
|
||||
"event": event.event,
|
||||
"data": event.model_dump_json()
|
||||
}
|
||||
yield {"event": event.event, "data": event.model_dump_json()}
|
||||
|
||||
|
||||
async def get_conversation_history(conversation_id: str) -> list[Response]:
|
||||
@@ -802,7 +799,7 @@ async def get_conversation_stats() -> dict:
|
||||
"""
|
||||
return {
|
||||
"total_conversations": await _conversation_history.get_conversation_count(),
|
||||
"max_turns_per_conversation": _conversation_history._max_turns
|
||||
"max_turns_per_conversation": _conversation_history._max_turns,
|
||||
}
|
||||
|
||||
|
||||
@@ -843,23 +840,29 @@ def _convert_output_items(items: list) -> list:
|
||||
|
||||
for item in items:
|
||||
if item.type == "message":
|
||||
converted.append(MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "reasoning":
|
||||
converted.append(ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "function_call":
|
||||
converted.append(FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
+83
-83
@@ -30,8 +30,10 @@ logger = get_logger(__name__)
|
||||
# Stream Event Types
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StreamEventType(str, Enum):
|
||||
"""Streaming event types for Responses API."""
|
||||
|
||||
REASONING_SUMMARY_DELTA = "response.reasoning_summary_text.delta"
|
||||
REASONING_SUMMARY_DONE = "response.reasoning_summary_text.done"
|
||||
OUTPUT_TEXT_DELTA = "response.output_text.delta"
|
||||
@@ -46,30 +48,38 @@ class StreamEventType(str, Enum):
|
||||
# Stream Event Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ReasoningSummaryDelta(CustomBaseModel):
|
||||
"""Reasoning summary text delta event."""
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = StreamEventType.REASONING_SUMMARY_DELTA
|
||||
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = (
|
||||
StreamEventType.REASONING_SUMMARY_DELTA
|
||||
)
|
||||
delta: str
|
||||
|
||||
|
||||
class ReasoningSummaryDone(CustomBaseModel):
|
||||
"""Reasoning summary completion event."""
|
||||
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DONE] = StreamEventType.REASONING_SUMMARY_DONE
|
||||
|
||||
|
||||
class OutputTextDelta(CustomBaseModel):
|
||||
"""Output text delta event."""
|
||||
|
||||
event: Literal[StreamEventType.OUTPUT_TEXT_DELTA] = StreamEventType.OUTPUT_TEXT_DELTA
|
||||
delta: str
|
||||
|
||||
|
||||
class OutputTextDone(CustomBaseModel):
|
||||
"""Output text completion event."""
|
||||
|
||||
event: Literal[StreamEventType.OUTPUT_TEXT_DONE] = StreamEventType.OUTPUT_TEXT_DONE
|
||||
|
||||
|
||||
class FunctionCallDelta(CustomBaseModel):
|
||||
"""Function call arguments delta event."""
|
||||
|
||||
event: Literal[StreamEventType.FUNCTION_CALL_DELTA] = StreamEventType.FUNCTION_CALL_DELTA
|
||||
delta: str
|
||||
name: str | None = None # Only in first chunk
|
||||
@@ -77,31 +87,34 @@ class FunctionCallDelta(CustomBaseModel):
|
||||
|
||||
class FunctionCallDone(CustomBaseModel):
|
||||
"""Function call completion event."""
|
||||
|
||||
event: Literal[StreamEventType.FUNCTION_CALL_DONE] = StreamEventType.FUNCTION_CALL_DONE
|
||||
|
||||
|
||||
class ResponseDone(CustomBaseModel):
|
||||
"""Response completion event with full response."""
|
||||
|
||||
event: Literal[StreamEventType.RESPONSE_DONE] = StreamEventType.RESPONSE_DONE
|
||||
response: Response
|
||||
|
||||
|
||||
class ErrorEvent(CustomBaseModel):
|
||||
"""Error event."""
|
||||
|
||||
event: Literal[StreamEventType.ERROR] = StreamEventType.ERROR
|
||||
error: dict
|
||||
|
||||
|
||||
# Union type for all stream events
|
||||
StreamEvent = (
|
||||
ReasoningSummaryDelta |
|
||||
ReasoningSummaryDone |
|
||||
OutputTextDelta |
|
||||
OutputTextDone |
|
||||
FunctionCallDelta |
|
||||
FunctionCallDone |
|
||||
ResponseDone |
|
||||
ErrorEvent
|
||||
ReasoningSummaryDelta
|
||||
| ReasoningSummaryDone
|
||||
| OutputTextDelta
|
||||
| OutputTextDone
|
||||
| FunctionCallDelta
|
||||
| FunctionCallDone
|
||||
| ResponseDone
|
||||
| ErrorEvent
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +122,7 @@ StreamEvent = (
|
||||
# Streaming Coordinator
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StreamingCoordinator:
|
||||
"""
|
||||
Coordinates streaming from agents to SSE format.
|
||||
@@ -123,7 +137,7 @@ class StreamingCoordinator:
|
||||
|
||||
async def stream_response_with_steward(
|
||||
self,
|
||||
request: "ResponseRequest" # type: ignore # Forward reference
|
||||
request: "ResponseRequest", # type: ignore # Forward reference
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""
|
||||
Stream response with Steward preprocessing and two-phase Tatlock execution.
|
||||
@@ -181,10 +195,13 @@ class StreamingCoordinator:
|
||||
|
||||
# Check if direct delegation is recommended
|
||||
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||
delegation_only = all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
) and enriched.recommendation.recommended_capabilities
|
||||
delegation_only = (
|
||||
all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
and enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
|
||||
tatlock = TatlockAgent()
|
||||
|
||||
@@ -222,7 +239,7 @@ class StreamingCoordinator:
|
||||
# Stream the synthesized response
|
||||
chunk_size = 50
|
||||
for i in range(0, len(tatlock_response), chunk_size):
|
||||
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
|
||||
yield OutputTextDelta(delta=tatlock_response[i : i + chunk_size])
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
yield OutputTextDone()
|
||||
@@ -231,12 +248,10 @@ class StreamingCoordinator:
|
||||
message_item = MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[OutputTextContent(
|
||||
type="output_text",
|
||||
text=tatlock_response,
|
||||
annotations=[]
|
||||
)],
|
||||
status="completed"
|
||||
content=[
|
||||
OutputTextContent(type="output_text", text=tatlock_response, annotations=[])
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
output_items.append(message_item)
|
||||
|
||||
@@ -252,7 +267,7 @@ class StreamingCoordinator:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=output_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history
|
||||
@@ -319,17 +334,11 @@ class StreamingCoordinator:
|
||||
try:
|
||||
# Execute delegation
|
||||
if agent == "librarian":
|
||||
result = await delegate_to_librarian(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_librarian(task=user_message, context=context)
|
||||
elif agent == "biographer":
|
||||
result = await delegate_to_biographer(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_biographer(task=user_message, context=context)
|
||||
elif agent == "housekeeper":
|
||||
result = await delegate_to_housekeeper(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_housekeeper(task=user_message, context=context)
|
||||
else:
|
||||
result = None
|
||||
|
||||
@@ -366,17 +375,19 @@ class StreamingCoordinator:
|
||||
yield ReasoningSummaryDone()
|
||||
|
||||
if results is not None:
|
||||
results.update({
|
||||
"tools_called": tools_called,
|
||||
"expert_results": expert_results,
|
||||
"tool_outputs": {},
|
||||
"raw_output": "",
|
||||
"think_messages": think_messages,
|
||||
})
|
||||
results.update(
|
||||
{
|
||||
"tools_called": tools_called,
|
||||
"expert_results": expert_results,
|
||||
"tool_outputs": {},
|
||||
"raw_output": "",
|
||||
"think_messages": think_messages,
|
||||
}
|
||||
)
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
request: "ResponseRequest" # type: ignore # Forward reference
|
||||
request: "ResponseRequest", # type: ignore # Forward reference
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""
|
||||
Coordinate streaming from agent to SSE events.
|
||||
@@ -435,18 +446,13 @@ class StreamingCoordinator:
|
||||
elif item.type == "function_call":
|
||||
# Stream function call arguments
|
||||
# First chunk includes name
|
||||
yield FunctionCallDelta(
|
||||
name=item.data["name"],
|
||||
delta=""
|
||||
)
|
||||
yield FunctionCallDelta(name=item.data["name"], delta="")
|
||||
|
||||
# Stream arguments in chunks
|
||||
args = item.data["arguments"]
|
||||
chunk_size = 20
|
||||
for i in range(0, len(args), chunk_size):
|
||||
yield FunctionCallDelta(
|
||||
delta=args[i:i+chunk_size]
|
||||
)
|
||||
yield FunctionCallDelta(delta=args[i : i + chunk_size])
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
yield FunctionCallDone()
|
||||
@@ -458,7 +464,7 @@ class StreamingCoordinator:
|
||||
# Only stream the NEW text (delta) since last update
|
||||
if current_text.startswith(last_message_text):
|
||||
# Extract only the new portion
|
||||
delta_text = current_text[len(last_message_text):]
|
||||
delta_text = current_text[len(last_message_text) :]
|
||||
|
||||
if delta_text:
|
||||
# Stream the delta text in chunks while preserving formatting
|
||||
@@ -466,17 +472,16 @@ class StreamingCoordinator:
|
||||
chunk_size = 50 # characters per chunk
|
||||
|
||||
for i in range(0, len(delta_text), chunk_size):
|
||||
chunk = delta_text[i:i+chunk_size]
|
||||
chunk = delta_text[i : i + chunk_size]
|
||||
|
||||
# Check stop sequences on full accumulated text
|
||||
stop_found, text_before_stop = self._check_stop_sequence(
|
||||
current_text,
|
||||
request.stop
|
||||
current_text, request.stop
|
||||
)
|
||||
|
||||
if stop_found:
|
||||
# Only emit remaining delta before stop
|
||||
remaining = text_before_stop[len(last_message_text):]
|
||||
remaining = text_before_stop[len(last_message_text) :]
|
||||
if remaining:
|
||||
yield OutputTextDelta(delta=remaining)
|
||||
yield OutputTextDone()
|
||||
@@ -508,11 +513,12 @@ class StreamingCoordinator:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=self._convert_output_items(output_items),
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history (import here to avoid circular dependency)
|
||||
from src.responses.service import _conversation_history
|
||||
|
||||
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||
await _conversation_history.add_response(conversation_id, final_response)
|
||||
|
||||
@@ -534,24 +540,30 @@ class StreamingCoordinator:
|
||||
converted = []
|
||||
for item in items:
|
||||
if item.type == "message":
|
||||
converted.append(MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "reasoning":
|
||||
converted.append(ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "function_call":
|
||||
converted.append(FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
@@ -576,18 +588,10 @@ class StreamingCoordinator:
|
||||
error_type = "internal_error"
|
||||
code = 500
|
||||
|
||||
return ErrorEvent(
|
||||
error={
|
||||
"type": error_type,
|
||||
"message": str(error),
|
||||
"code": code
|
||||
}
|
||||
)
|
||||
return ErrorEvent(error={"type": error_type, "message": str(error), "code": code})
|
||||
|
||||
def _check_stop_sequence(
|
||||
self,
|
||||
accumulated_text: str,
|
||||
stop_sequences: list[str] | None
|
||||
self, accumulated_text: str, stop_sequences: list[str] | None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if any stop sequence is encountered.
|
||||
@@ -624,11 +628,7 @@ class StreamingCoordinator:
|
||||
"""
|
||||
return len(text) // 4
|
||||
|
||||
def _check_max_tokens(
|
||||
self,
|
||||
current_tokens: int,
|
||||
max_tokens: int | None
|
||||
) -> bool:
|
||||
def _check_max_tokens(self, current_tokens: int, max_tokens: int | None) -> bool:
|
||||
"""
|
||||
Check if max tokens limit reached.
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Biographer capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.biographer.capability import (
|
||||
BIOGRAPHER_CAPABILITY,
|
||||
get_biographer_capability,
|
||||
@@ -73,9 +74,7 @@ class TestBiographerRegistration:
|
||||
"src.agents.biographer.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_biographer_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.biographer.capability.get_biographer_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Housekeeper capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.housekeeper.capability import (
|
||||
HOUSEKEEPER_CAPABILITY,
|
||||
get_housekeeper_capability,
|
||||
@@ -74,9 +75,7 @@ class TestHousekeeperRegistration:
|
||||
"src.agents.housekeeper.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.housekeeper.capability.get_housekeeper_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.housekeeper.capability.get_housekeeper_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for the Core-API HTTP client.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.agents.housekeeper.client import (
|
||||
Area,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Librarian capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.librarian.capability import (
|
||||
LIBRARIAN_CAPABILITY,
|
||||
get_librarian_capability,
|
||||
@@ -67,9 +68,7 @@ class TestLibrarianRegistration:
|
||||
"src.agents.librarian.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.librarian.capability.get_librarian_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.librarian.capability.get_librarian_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -137,9 +137,7 @@ class TestHybridSearch:
|
||||
assert "docker" in result.keywords
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hybrid_search_empty_results(
|
||||
self, client_with_mock, mock_httpx_client
|
||||
):
|
||||
async def test_hybrid_search_empty_results(self, client_with_mock, mock_httpx_client):
|
||||
"""Test hybrid search with no results."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
@@ -445,9 +443,7 @@ class TestUpdateWikiPage:
|
||||
assert page.tags == ["projects", "devops"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_wiki_page_multiple_fields(
|
||||
self, client_with_mock, mock_httpx_client
|
||||
):
|
||||
async def test_update_wiki_page_multiple_fields(self, client_with_mock, mock_httpx_client):
|
||||
"""Test updating multiple fields at once."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
@@ -622,15 +618,11 @@ class TestExplicitUserContract:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS)
|
||||
async def test_user_from_context_is_sent_on_the_wire(
|
||||
self, method_name, kwargs
|
||||
):
|
||||
async def test_user_from_context_is_sent_on_the_wire(self, method_name, kwargs):
|
||||
"""With no explicit user, the context user is resolved and sent."""
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.client.get_user", return_value="llm_tester"
|
||||
):
|
||||
with patch("src.agents.librarian.client.get_user", return_value="llm_tester"):
|
||||
await getattr(client, method_name)(**kwargs)
|
||||
|
||||
assert self._sent_user(mock_httpx) == "llm_tester"
|
||||
@@ -647,9 +639,7 @@ class TestExplicitUserContract:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS)
|
||||
async def test_empty_context_user_fails_before_any_request(
|
||||
self, method_name, kwargs
|
||||
):
|
||||
async def test_empty_context_user_fails_before_any_request(self, method_name, kwargs):
|
||||
"""An empty resolved user raises before any bytes hit the wire."""
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
@@ -698,9 +688,7 @@ class TestExplicitUserContract:
|
||||
from src.core import config as config_module
|
||||
from src.core.config import Environment
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_module.config, "ENVIRONMENT", Environment.DEVELOPMENT
|
||||
)
|
||||
monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.DEVELOPMENT)
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
await getattr(client, method_name)(user=explicit_user, **kwargs)
|
||||
@@ -708,16 +696,12 @@ class TestExplicitUserContract:
|
||||
assert self._sent_user(mock_httpx) == "llm_tester"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_production_tenant_passes_through_in_prod(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_explicit_production_tenant_passes_through_in_prod(self, monkeypatch):
|
||||
"""In production the production tenant is sent unchanged."""
|
||||
from src.core import config as config_module
|
||||
from src.core.config import Environment
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_module.config, "ENVIRONMENT", Environment.PRODUCTION
|
||||
)
|
||||
monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.PRODUCTION)
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
await client.hybrid_search("q", user="jpmschweitzer")
|
||||
|
||||
@@ -91,9 +91,7 @@ class TestBoundedRetries:
|
||||
mock_httpx.post.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client_with_mock.create_wiki_page(
|
||||
title="T", path="/t", content="c", user="u"
|
||||
)
|
||||
await client_with_mock.create_wiki_page(title="T", path="/t", content="c", user="u")
|
||||
|
||||
assert mock_httpx.post.call_count == 1
|
||||
|
||||
@@ -103,9 +101,7 @@ class TestBoundedRetries:
|
||||
mock_httpx.post.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client_with_mock.smart_create_wiki_page(
|
||||
topic="T", tags=["x"], user="u"
|
||||
)
|
||||
await client_with_mock.smart_create_wiki_page(topic="T", tags=["x"], user="u")
|
||||
|
||||
assert mock_httpx.post.call_count == 1
|
||||
|
||||
@@ -184,9 +180,7 @@ class TestModelRetryEscalation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tool_raises_model_retry_on_transport_error(self):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.hybrid_search.side_effect = httpx.ConnectError(
|
||||
"Connection refused"
|
||||
)
|
||||
mock_client.hybrid_search.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with self._patched_client(mock_client):
|
||||
with pytest.raises(ModelRetry):
|
||||
@@ -219,14 +213,10 @@ class TestModelRetryEscalation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_never_raises_model_retry(self):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.create_wiki_page.side_effect = httpx.ConnectError(
|
||||
"Connection refused"
|
||||
)
|
||||
mock_client.create_wiki_page.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with self._patched_client(mock_client):
|
||||
result = await create_wiki_page(
|
||||
title="T", path="/t", content="c", tags=["x"]
|
||||
)
|
||||
result = await create_wiki_page(title="T", path="/t", content="c", tags=["x"])
|
||||
|
||||
assert "unable" in result
|
||||
assert "Connection refused" not in result
|
||||
|
||||
@@ -83,9 +83,7 @@ class TestHybridRAGContract:
|
||||
assert source in SOURCE_ICONS, f"no icon for source '{source}'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_maps_to_formatted_context(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_context_maps_to_formatted_context(self, client_with_recorded_response):
|
||||
"""Top-level 'context' field maps to formatted_context."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -94,9 +92,7 @@ class TestHybridRAGContract:
|
||||
assert response.formatted_context != ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keywords_and_synonyms_from_dict(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_keywords_and_synonyms_from_dict(self, client_with_recorded_response):
|
||||
"""keywords is a dict: core_keywords + nested synonyms map."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -124,9 +120,7 @@ class TestHybridRAGContract:
|
||||
assert len(response.related_dossiers) == len(set(response.related_dossiers))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_never_sends_zero_limits(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_payload_never_sends_zero_limits(self, client_with_recorded_response):
|
||||
"""The live service 422s on limits < 1; disabled legs use enable_* flags."""
|
||||
await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure",
|
||||
@@ -151,24 +145,18 @@ class TestHybridRAGContract:
|
||||
assert config["enable_volatile"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_always_sent_as_query_param(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_user_always_sent_as_query_param(self, client_with_recorded_response):
|
||||
"""The tenant is always sent explicitly - library-desk is removing
|
||||
its server-side default, so a missing user would 422."""
|
||||
await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
)
|
||||
|
||||
params = client_with_recorded_response._client.post.call_args.kwargs[
|
||||
"params"
|
||||
]
|
||||
params = client_with_recorded_response._client.post.call_args.kwargs["params"]
|
||||
assert params["user"] == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_counts_and_timing_parsed(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_source_counts_and_timing_parsed(self, client_with_recorded_response):
|
||||
"""source_counts and timing map into the response model."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -178,9 +166,7 @@ class TestHybridRAGContract:
|
||||
assert response.timing.get("total_ms", 0) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_status_absent_is_tolerated(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_source_status_absent_is_tolerated(self, client_with_recorded_response):
|
||||
"""Recorded response predates source_status/degraded - defaults apply."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -218,7 +204,9 @@ class TestHybridRAGContract:
|
||||
assert response.source_status["volatile"] == "disabled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_renders_no_unknown_results(self, client_with_recorded_response, monkeypatch):
|
||||
async def test_tool_renders_no_unknown_results(
|
||||
self, client_with_recorded_response, monkeypatch
|
||||
):
|
||||
"""The hybrid_search tool renders real sources and non-zero scores."""
|
||||
|
||||
class _Factory:
|
||||
@@ -231,9 +219,7 @@ class TestHybridRAGContract:
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.agents.librarian.tools.LibraryDeskClient", _Factory()
|
||||
)
|
||||
monkeypatch.setattr("src.agents.librarian.tools.LibraryDeskClient", _Factory())
|
||||
|
||||
output = await hybrid_search("home server infrastructure")
|
||||
|
||||
@@ -278,9 +264,7 @@ class TestCoverageNote:
|
||||
|
||||
def test_wiki_leg_absence_is_not_degradation(self):
|
||||
"""vector/graph missing from top-N counts is healthy ranking, not outage."""
|
||||
response = self._response(
|
||||
source_counts={"web": 2, "documents": 1, "volatile": 1}
|
||||
)
|
||||
response = self._response(source_counts={"web": 2, "documents": 1, "volatile": 1})
|
||||
note = _coverage_note(
|
||||
response, include_web=True, include_documents=True, include_volatile=True
|
||||
)
|
||||
|
||||
@@ -35,9 +35,7 @@ def _nullable_anyof_paths(schema: object, path: str = "") -> list[str]:
|
||||
class TestLibrarianToolSchemas:
|
||||
"""All registered librarian tools emit Ollama-safe parameter schemas."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_func", LIBRARIAN_TOOLS, ids=lambda f: f.__name__
|
||||
)
|
||||
@pytest.mark.parametrize("tool_func", LIBRARIAN_TOOLS, ids=lambda f: f.__name__)
|
||||
def test_no_nullable_anyof_in_schema(self, tool_func):
|
||||
schema = Tool(tool_func).function_schema.json_schema
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ def mock_client():
|
||||
# Web Search Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSearchWeb:
|
||||
"""Tests for search_web tool."""
|
||||
@@ -68,9 +69,7 @@ class TestSearchWeb:
|
||||
)
|
||||
mock_client.search_web.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -94,9 +93,7 @@ class TestSearchWeb:
|
||||
)
|
||||
mock_client.search_web.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -111,9 +108,7 @@ class TestSearchWeb:
|
||||
"Connection failed to http://internal-host:8089"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -144,9 +139,7 @@ class TestSearchWeb:
|
||||
)
|
||||
mock_client.search_web.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -164,6 +157,7 @@ class TestSearchWeb:
|
||||
# Read URL Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadUrl:
|
||||
"""Tests for read_url tool."""
|
||||
@@ -182,9 +176,7 @@ class TestReadUrl:
|
||||
)
|
||||
mock_client.extract_content.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -205,9 +197,7 @@ class TestReadUrl:
|
||||
)
|
||||
mock_client.extract_content.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -227,9 +217,7 @@ class TestReadUrl:
|
||||
)
|
||||
mock_client.extract_content.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
@@ -246,6 +234,7 @@ class TestReadUrl:
|
||||
# Batch URL Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadUrlsBatch:
|
||||
"""Tests for read_urls_batch tool."""
|
||||
@@ -275,16 +264,16 @@ class TestReadUrlsBatch:
|
||||
)
|
||||
mock_client.extract_content_batch.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
result = await read_urls_batch([
|
||||
"https://example.com/1",
|
||||
"https://example.com/2",
|
||||
])
|
||||
result = await read_urls_batch(
|
||||
[
|
||||
"https://example.com/1",
|
||||
"https://example.com/2",
|
||||
]
|
||||
)
|
||||
|
||||
assert "Article 1" in result
|
||||
assert "Article 2" in result
|
||||
@@ -314,16 +303,16 @@ class TestReadUrlsBatch:
|
||||
)
|
||||
mock_client.extract_content_batch.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.tools.LibraryDeskClient"
|
||||
) as mock_client_class:
|
||||
with patch("src.agents.librarian.tools.LibraryDeskClient") as mock_client_class:
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
mock_client_class.return_value.__aexit__.return_value = None
|
||||
|
||||
result = await read_urls_batch([
|
||||
"https://example.com/good",
|
||||
"https://example.com/bad",
|
||||
])
|
||||
result = await read_urls_batch(
|
||||
[
|
||||
"https://example.com/good",
|
||||
"https://example.com/bad",
|
||||
]
|
||||
)
|
||||
|
||||
# Should contain successful result
|
||||
assert "Good Article" in result
|
||||
@@ -336,6 +325,7 @@ class TestReadUrlsBatch:
|
||||
# Response Model Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWebSearchModels:
|
||||
"""Tests for web search response models."""
|
||||
@@ -436,6 +426,7 @@ class TestWebSearchModels:
|
||||
# Wiki Update Tests (tag sentinel behavior)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateWikiPageTagSentinels:
|
||||
"""Empty list leaves tags unchanged; the clear sentinel empties them."""
|
||||
|
||||
@@ -3,7 +3,6 @@ Tests for Steward schemas.
|
||||
|
||||
Tests the structured output models for conversation context and recommendations.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
@@ -24,7 +23,7 @@ class TestConversationContext:
|
||||
context = ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[0, 2, 4],
|
||||
context_summary="User discussed weather in turns 0 and 2"
|
||||
context_summary="User discussed weather in turns 0 and 2",
|
||||
)
|
||||
|
||||
assert context.has_previous_context is True
|
||||
@@ -89,7 +88,7 @@ class TestStewardRecommendation:
|
||||
context = ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[1, 3],
|
||||
context_summary="User asked about calculation in turn 1, now wants explanation"
|
||||
context_summary="User asked about calculation in turn 1, now wants explanation",
|
||||
)
|
||||
|
||||
rec = StewardRecommendation(
|
||||
@@ -122,7 +121,7 @@ class TestStewardRecommendation:
|
||||
context = ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[0],
|
||||
context_summary="Previous calculation mentioned"
|
||||
context_summary="Previous calculation mentioned",
|
||||
)
|
||||
|
||||
rec = StewardRecommendation(
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for Steward service layer.
|
||||
|
||||
Tests request analysis, logging, and benchmarking integration.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +32,9 @@ class TestAnalyzeRequest:
|
||||
"""Test analyzing a simple greeting."""
|
||||
# Mock the Steward agent's analyze method (plain text approach)
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.analyze = AsyncMock(return_value="Simple greeting requires no tools. This is a simple request.")
|
||||
mock_agent.analyze = AsyncMock(
|
||||
return_value="Simple greeting requires no tools. This is a simple request."
|
||||
)
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
result = await analyze_request(
|
||||
@@ -159,7 +162,7 @@ class TestFormatStewardNote:
|
||||
context = ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[0, 1],
|
||||
context_summary="Previous discussion about calculations"
|
||||
context_summary="Previous discussion about calculations",
|
||||
)
|
||||
|
||||
rec = StewardRecommendation(
|
||||
@@ -205,9 +208,7 @@ class TestBuildEnrichedQuery:
|
||||
def test_enrichment_adds_location(self):
|
||||
"""Test location is appended for weather queries."""
|
||||
query = "What's the weather?"
|
||||
memory_context = {
|
||||
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||
}
|
||||
memory_context = {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
|
||||
@@ -218,9 +219,7 @@ class TestBuildEnrichedQuery:
|
||||
def test_no_location_when_specified(self):
|
||||
"""Test location is not appended when already specified."""
|
||||
query = "What's the weather in London?"
|
||||
memory_context = {
|
||||
"profile": {"location": "Amsterdam"}
|
||||
}
|
||||
memory_context = {"profile": {"location": "Amsterdam"}}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
|
||||
@@ -230,9 +229,7 @@ class TestBuildEnrichedQuery:
|
||||
def test_enrichment_adds_timezone(self):
|
||||
"""Test timezone is appended for time queries."""
|
||||
query = "What time is it?"
|
||||
memory_context = {
|
||||
"profile": {"timezone": "Europe/Amsterdam"}
|
||||
}
|
||||
memory_context = {"profile": {"timezone": "Europe/Amsterdam"}}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
|
||||
@@ -241,9 +238,7 @@ class TestBuildEnrichedQuery:
|
||||
def test_no_timezone_when_specified(self):
|
||||
"""Test timezone is not appended when already specified."""
|
||||
query = "What time is it in UTC?"
|
||||
memory_context = {
|
||||
"profile": {"timezone": "Europe/Amsterdam"}
|
||||
}
|
||||
memory_context = {"profile": {"timezone": "Europe/Amsterdam"}}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
|
||||
@@ -254,7 +249,7 @@ class TestBuildEnrichedQuery:
|
||||
query = "What's the weather?"
|
||||
memory_context = {
|
||||
"profile": {"location": "Amsterdam"},
|
||||
"preferences": {"temperature_unit": "celsius"}
|
||||
"preferences": {"temperature_unit": "celsius"},
|
||||
}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
@@ -265,11 +260,8 @@ class TestBuildEnrichedQuery:
|
||||
"""Test multiple context fields are appended."""
|
||||
query = "What time and weather today?"
|
||||
memory_context = {
|
||||
"profile": {
|
||||
"location": "Amsterdam",
|
||||
"timezone": "Europe/Amsterdam"
|
||||
},
|
||||
"preferences": {"temperature_unit": "celsius"}
|
||||
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"},
|
||||
"preferences": {"temperature_unit": "celsius"},
|
||||
}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
@@ -281,9 +273,7 @@ class TestBuildEnrichedQuery:
|
||||
def test_no_enrichment_for_unrelated_query(self):
|
||||
"""Test no enrichment for queries that don't need context."""
|
||||
query = "Tell me a joke"
|
||||
memory_context = {
|
||||
"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}
|
||||
}
|
||||
memory_context = {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
|
||||
|
||||
result = _build_enriched_query(query, memory_context)
|
||||
|
||||
@@ -306,7 +296,10 @@ class TestExtractCapabilities:
|
||||
("The user wants a description of the algorithm.", "script -> housekeeper"),
|
||||
("I should discover what the answer is.", "cover -> housekeeper"),
|
||||
("That sounds fantastic, let me compute it.", "fan -> housekeeper"),
|
||||
("I acknowledge the request to add two numbers.", "knowledge/know -> librarian, biographer"),
|
||||
(
|
||||
"I acknowledge the request to add two numbers.",
|
||||
"knowledge/know -> librarian, biographer",
|
||||
),
|
||||
("The user asks about the economy myth.", "my -> biographer"),
|
||||
("Convert 98.6 Fahrenheit to Celsius.", "temperature is a housekeeper domain"),
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests for delegation infrastructure.
|
||||
Tests the DelegationTask dataclass and delegation wrapper functions
|
||||
that implement the agent-as-tool pattern.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -41,9 +42,7 @@ class TestBuildDelegationContext:
|
||||
assert "assistant: Docker is a container runtime." in context
|
||||
|
||||
def test_only_last_max_turns_kept(self):
|
||||
history = [
|
||||
{"role": "user", "content": f"message {i}"} for i in range(10)
|
||||
]
|
||||
history = [{"role": "user", "content": f"message {i}"} for i in range(10)]
|
||||
|
||||
context = build_delegation_context(history, max_turns=6)
|
||||
|
||||
@@ -60,9 +59,7 @@ class TestBuildDelegationContext:
|
||||
assert "x" * 501 not in context
|
||||
|
||||
def test_structured_content_parts_tolerated(self):
|
||||
history = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello there"}]}
|
||||
]
|
||||
history = [{"role": "user", "content": [{"type": "text", "text": "hello there"}]}]
|
||||
|
||||
context = build_delegation_context(history)
|
||||
|
||||
@@ -343,8 +340,12 @@ class TestHouseholdThinkMessages:
|
||||
for action_type, messages in action_types.items():
|
||||
for phase, msg in messages.items():
|
||||
# Messages should NOT have <think> wrappers - they go to reasoning_content field
|
||||
assert "<think>" not in msg, f"{expert}/{action_type}/{phase} should not have <think> wrapper"
|
||||
assert "</think>" not in msg, f"{expert}/{action_type}/{phase} should not have </think> wrapper"
|
||||
assert (
|
||||
"<think>" not in msg
|
||||
), f"{expert}/{action_type}/{phase} should not have <think> wrapper"
|
||||
assert (
|
||||
"</think>" not in msg
|
||||
), f"{expert}/{action_type}/{phase} should not have </think> wrapper"
|
||||
# Messages should be non-empty strings
|
||||
assert isinstance(msg, str) and len(msg) > 0, f"{expert}/{action_type}/{phase}"
|
||||
|
||||
@@ -356,7 +357,9 @@ class TestDetectActionType:
|
||||
def test_librarian_search_is_retrieve(self):
|
||||
"""Test librarian search tasks are RETRIEVE."""
|
||||
assert _detect_action_type("librarian", "search for Docker info") == ActionType.RETRIEVE
|
||||
assert _detect_action_type("librarian", "find information about CI/CD") == ActionType.RETRIEVE
|
||||
assert (
|
||||
_detect_action_type("librarian", "find information about CI/CD") == ActionType.RETRIEVE
|
||||
)
|
||||
assert _detect_action_type("librarian", "look up Kubernetes docs") == ActionType.RETRIEVE
|
||||
|
||||
def test_librarian_web_search_is_research(self):
|
||||
@@ -378,14 +381,25 @@ class TestDetectActionType:
|
||||
|
||||
def test_biographer_record_is_record(self):
|
||||
"""Test biographer record tasks are RECORD."""
|
||||
assert _detect_action_type("biographer", "remember that I work at Acme") == ActionType.RECORD
|
||||
assert (
|
||||
_detect_action_type("biographer", "remember that I work at Acme") == ActionType.RECORD
|
||||
)
|
||||
assert _detect_action_type("biographer", "note that my car is a Tesla") == ActionType.RECORD
|
||||
assert _detect_action_type("biographer", "save my preference for dark mode") == ActionType.RECORD
|
||||
assert (
|
||||
_detect_action_type("biographer", "save my preference for dark mode")
|
||||
== ActionType.RECORD
|
||||
)
|
||||
|
||||
def test_housekeeper_status_is_retrieve(self):
|
||||
"""Test housekeeper status tasks are RETRIEVE."""
|
||||
assert _detect_action_type("housekeeper", "what devices are in the bedroom?") == ActionType.RETRIEVE
|
||||
assert _detect_action_type("housekeeper", "is the living room light on?") == ActionType.RETRIEVE
|
||||
assert (
|
||||
_detect_action_type("housekeeper", "what devices are in the bedroom?")
|
||||
== ActionType.RETRIEVE
|
||||
)
|
||||
assert (
|
||||
_detect_action_type("housekeeper", "is the living room light on?")
|
||||
== ActionType.RETRIEVE
|
||||
)
|
||||
|
||||
def test_housekeeper_control_is_control(self):
|
||||
"""Test housekeeper control tasks are CONTROL."""
|
||||
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
|
||||
from src.agents.lorem_tester import LoremTesterAgent
|
||||
from src.core.exceptions import (
|
||||
RateLimitError,
|
||||
ContextLengthError,
|
||||
APIError,
|
||||
ContextLengthError,
|
||||
RateLimitError,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,23 +5,25 @@ Tests the multi-expert coordination infrastructure including
|
||||
delegation parsing, think updates, result handling, and
|
||||
multi-expert sequential/parallel execution.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.delegation import DelegationResult, DelegationTask
|
||||
from src.agents.orchestration import (
|
||||
OrchestrationContext,
|
||||
parse_delegation_from_steward_note,
|
||||
execute_delegation,
|
||||
orchestrate_with_think_updates,
|
||||
extract_delegation_context,
|
||||
ExecutionMode,
|
||||
MultiExpertResult,
|
||||
execute_sequential,
|
||||
execute_parallel,
|
||||
orchestrate_multi_expert,
|
||||
OrchestrationContext,
|
||||
_get_display_name,
|
||||
execute_delegation,
|
||||
execute_parallel,
|
||||
execute_sequential,
|
||||
extract_delegation_context,
|
||||
orchestrate_multi_expert,
|
||||
orchestrate_with_think_updates,
|
||||
parse_delegation_from_steward_note,
|
||||
)
|
||||
from src.agents.delegation import DelegationTask, DelegationResult
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -364,6 +366,7 @@ class TestOrchestrationContext:
|
||||
# Multi-Expert Coordination Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMultiExpertResult:
|
||||
"""Tests for MultiExpertResult aggregation."""
|
||||
@@ -414,18 +417,22 @@ class TestMultiExpertResult:
|
||||
"""Test aggregating outputs from multiple experts."""
|
||||
result = MultiExpertResult()
|
||||
|
||||
result.add_result(DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="search docs",
|
||||
success=True,
|
||||
output="Found Docker docs",
|
||||
))
|
||||
result.add_result(DelegationResult(
|
||||
expert_name="memory",
|
||||
task="get preferences",
|
||||
success=True,
|
||||
output="User prefers dark mode",
|
||||
))
|
||||
result.add_result(
|
||||
DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="search docs",
|
||||
success=True,
|
||||
output="Found Docker docs",
|
||||
)
|
||||
)
|
||||
result.add_result(
|
||||
DelegationResult(
|
||||
expert_name="memory",
|
||||
task="get preferences",
|
||||
success=True,
|
||||
output="User prefers dark mode",
|
||||
)
|
||||
)
|
||||
|
||||
combined = result.aggregate_outputs()
|
||||
|
||||
@@ -438,19 +445,23 @@ class TestMultiExpertResult:
|
||||
"""Test that failed results are excluded from aggregate."""
|
||||
result = MultiExpertResult()
|
||||
|
||||
result.add_result(DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="search",
|
||||
success=True,
|
||||
output="Success output",
|
||||
))
|
||||
result.add_result(DelegationResult(
|
||||
expert_name="memory",
|
||||
task="get",
|
||||
success=False,
|
||||
output="",
|
||||
error="Failed",
|
||||
))
|
||||
result.add_result(
|
||||
DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="search",
|
||||
success=True,
|
||||
output="Success output",
|
||||
)
|
||||
)
|
||||
result.add_result(
|
||||
DelegationResult(
|
||||
expert_name="memory",
|
||||
task="get",
|
||||
success=False,
|
||||
output="",
|
||||
error="Failed",
|
||||
)
|
||||
)
|
||||
|
||||
combined = result.aggregate_outputs()
|
||||
|
||||
@@ -471,7 +482,9 @@ class TestExecuteSequential:
|
||||
]
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||
DelegationResult(
|
||||
expert_name="librarian", task="task 1", success=True, output="Result 1"
|
||||
),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||
]
|
||||
|
||||
@@ -496,7 +509,9 @@ class TestExecuteSequential:
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Failed"),
|
||||
DelegationResult(
|
||||
expert_name="memory", task="task 2", success=False, output="", error="Failed"
|
||||
),
|
||||
]
|
||||
|
||||
with patch(
|
||||
@@ -520,7 +535,9 @@ class TestExecuteSequential:
|
||||
]
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=False, output="", error="Error"),
|
||||
DelegationResult(
|
||||
expert_name="librarian", task="task 1", success=False, output="", error="Error"
|
||||
),
|
||||
]
|
||||
|
||||
with patch(
|
||||
@@ -548,7 +565,9 @@ class TestExecuteParallel:
|
||||
]
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||
DelegationResult(
|
||||
expert_name="librarian", task="task 1", success=True, output="Result 1"
|
||||
),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||
]
|
||||
|
||||
@@ -572,7 +591,9 @@ class TestExecuteParallel:
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=False, output="", error="Timeout"),
|
||||
DelegationResult(
|
||||
expert_name="memory", task="task 2", success=False, output="", error="Timeout"
|
||||
),
|
||||
]
|
||||
|
||||
with patch(
|
||||
@@ -629,7 +650,9 @@ class TestOrchestrateMultiExpert:
|
||||
]
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||
DelegationResult(
|
||||
expert_name="librarian", task="task 1", success=True, output="Result 1"
|
||||
),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||
]
|
||||
|
||||
@@ -658,7 +681,9 @@ class TestOrchestrateMultiExpert:
|
||||
]
|
||||
|
||||
mock_results = [
|
||||
DelegationResult(expert_name="librarian", task="task 1", success=True, output="Result 1"),
|
||||
DelegationResult(
|
||||
expert_name="librarian", task="task 1", success=True, output="Result 1"
|
||||
),
|
||||
DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Tests for the agent error protocol.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.protocol import AgentError
|
||||
|
||||
@@ -4,8 +4,8 @@ Tests for model registry.
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.registry import ModelRegistry
|
||||
from src.agents.lorem_tester import LoremTesterAgent
|
||||
from src.agents.registry import ModelRegistry
|
||||
from src.agents.tatlock import TatlockAgent
|
||||
from src.core.exceptions import ModelNotFoundError
|
||||
|
||||
@@ -63,10 +63,10 @@ async def test_tatlock_capabilities():
|
||||
|
||||
# Tatlock Phase 1 - basic streaming, reasoning, and permanent tools
|
||||
assert capabilities["streaming"] is True
|
||||
assert capabilities["reasoning"] is True # Basic reasoning summaries
|
||||
assert capabilities["tools"] is True # Permanent tools: calculator, date/time, search
|
||||
assert capabilities["vision"] is False # Future
|
||||
assert capabilities["audio"] is False # Future
|
||||
assert capabilities["reasoning"] is True # Basic reasoning summaries
|
||||
assert capabilities["tools"] is True # Permanent tools: calculator, date/time, search
|
||||
assert capabilities["vision"] is False # Future
|
||||
assert capabilities["audio"] is False # Future
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -6,9 +6,7 @@ These tests verify:
|
||||
2. Tool calls are logged to reasoning output (users see what tools are doing)
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@@ -28,14 +26,10 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||
"messages": [
|
||||
{"role": "user", "content": "My name is Alice and I love Python programming."}
|
||||
],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data_1,
|
||||
timeout=120.0
|
||||
)
|
||||
response_1 = await async_client.post("/v1/chat/completions", json=request_data_1, timeout=120.0)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
data_1 = response_1.json()
|
||||
@@ -48,16 +42,15 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||
"messages": [
|
||||
{"role": "user", "content": "My name is Alice and I love Python programming."},
|
||||
{"role": "assistant", "content": first_response},
|
||||
{"role": "user", "content": "What did I say my name was? And what programming language did I mention?"}
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What did I say my name was? And what programming language did I mention?",
|
||||
},
|
||||
],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data_2,
|
||||
timeout=120.0
|
||||
)
|
||||
response_2 = await async_client.post("/v1/chat/completions", json=request_data_2, timeout=120.0)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
data_2 = response_2.json()
|
||||
@@ -68,7 +61,9 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||
has_python = "python" in second_response
|
||||
|
||||
if not has_alice or not has_python:
|
||||
pytest.xfail(f"LLM did not remember context (non-deterministic): alice={has_alice}, python={has_python}, response: {second_response[:200]}")
|
||||
pytest.xfail(
|
||||
f"LLM did not remember context (non-deterministic): alice={has_alice}, python={has_python}, response: {second_response[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -86,48 +81,37 @@ async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
||||
# Turn 1: Set up a topic
|
||||
conversation.append({"role": "user", "content": "Let's talk about the number 42."})
|
||||
|
||||
request_1 = {
|
||||
"model": "Tatlock",
|
||||
"messages": conversation.copy(),
|
||||
"stream": False
|
||||
}
|
||||
request_1 = {"model": "Tatlock", "messages": conversation.copy(), "stream": False}
|
||||
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_1,
|
||||
timeout=120.0
|
||||
)
|
||||
response_1 = await async_client.post("/v1/chat/completions", json=request_1, timeout=120.0)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
data_1 = response_1.json()
|
||||
conversation.append({
|
||||
"role": "assistant",
|
||||
"content": data_1["choices"][0]["message"]["content"]
|
||||
})
|
||||
conversation.append(
|
||||
{"role": "assistant", "content": data_1["choices"][0]["message"]["content"]}
|
||||
)
|
||||
|
||||
# Turn 2: Reference "it" (should refer to 42)
|
||||
conversation.append({"role": "user", "content": "What number did I just mention?"})
|
||||
|
||||
request_2 = {
|
||||
"model": "Tatlock",
|
||||
"messages": conversation.copy(),
|
||||
"stream": False
|
||||
}
|
||||
request_2 = {"model": "Tatlock", "messages": conversation.copy(), "stream": False}
|
||||
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_2,
|
||||
timeout=120.0
|
||||
)
|
||||
response_2 = await async_client.post("/v1/chat/completions", json=request_2, timeout=120.0)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
data_2 = response_2.json()
|
||||
final_response = data_2["choices"][0]["message"]["content"]
|
||||
|
||||
# Should reference 42 (check both as digit and word)
|
||||
has_42 = "42" in final_response or "forty-two" in final_response.lower() or "forty two" in final_response.lower()
|
||||
has_42 = (
|
||||
"42" in final_response
|
||||
or "forty-two" in final_response.lower()
|
||||
or "forty two" in final_response.lower()
|
||||
)
|
||||
if not has_42:
|
||||
pytest.xfail(f"LLM did not mention 42 in response (non-deterministic): {final_response[:200]}")
|
||||
pytest.xfail(
|
||||
f"LLM did not mention 42 in response (non-deterministic): {final_response[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -142,16 +126,15 @@ async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Search for current information about Python 3.13 release date"}
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Search for current information about Python 3.13 release date",
|
||||
}
|
||||
],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=120.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=120.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -167,8 +150,9 @@ async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
||||
|
||||
# If search was used, should show the 🔍 emoji
|
||||
if "🔍" in full_response:
|
||||
assert "search" in full_response.lower() or "python" in full_response.lower(), \
|
||||
"Search query should be visible in the response"
|
||||
assert (
|
||||
"search" in full_response.lower() or "python" in full_response.lower()
|
||||
), "Search query should be visible in the response"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -183,45 +167,38 @@ async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
||||
"""
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the square root of 144 plus 25?"}
|
||||
],
|
||||
"stream": False
|
||||
"messages": [{"role": "user", "content": "What is the square root of 144 plus 25?"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=120.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=120.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
full_response = data["choices"][0]["message"]["content"]
|
||||
|
||||
# Should have reasoning in <think> tags (from Steward analysis)
|
||||
assert "<think>" in full_response, \
|
||||
f"Should have reasoning output in <think> tags. Got: {full_response}"
|
||||
assert (
|
||||
"<think>" in full_response
|
||||
), f"Should have reasoning output in <think> tags. Got: {full_response}"
|
||||
|
||||
# Should reference the calculation in some form
|
||||
has_calculation_reference = (
|
||||
"144" in full_response or
|
||||
"sqrt" in full_response.lower() or
|
||||
"square root" in full_response.lower()
|
||||
"144" in full_response
|
||||
or "sqrt" in full_response.lower()
|
||||
or "square root" in full_response.lower()
|
||||
)
|
||||
assert has_calculation_reference, \
|
||||
f"Should reference the calculation. Got: {full_response}"
|
||||
assert has_calculation_reference, f"Should reference the calculation. Got: {full_response}"
|
||||
|
||||
# Should have the correct answer (37)
|
||||
assert "37" in full_response, \
|
||||
f"Should contain the answer 37. Got: {full_response}"
|
||||
assert "37" in full_response, f"Should contain the answer 37. Got: {full_response}"
|
||||
|
||||
# Tool emoji is optional - depends on whether tool was used directly
|
||||
# or computation was delegated to capability
|
||||
if "🧮" in full_response:
|
||||
print(f"\nCalculator tool was used directly")
|
||||
print("\nCalculator tool was used directly")
|
||||
else:
|
||||
print(f"\nCalculation handled via tatlock_core capability")
|
||||
print("\nCalculation handled via tatlock_core capability")
|
||||
|
||||
print(f"\nCalculator response: {full_response}")
|
||||
|
||||
@@ -234,17 +211,11 @@ async def test_tatlock_tool_call_logging_datetime(async_client: AsyncClient):
|
||||
"""
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What was the date exactly 2 weeks ago?"}
|
||||
],
|
||||
"stream": False
|
||||
"messages": [{"role": "user", "content": "What was the date exactly 2 weeks ago?"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=120.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=120.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -257,19 +228,36 @@ async def test_tatlock_tool_call_logging_datetime(async_client: AsyncClient):
|
||||
used_date_tool = "🕐" in full_response
|
||||
|
||||
# Should mention the calculation or the timeframe
|
||||
assert "2 weeks ago" in full_response.lower() or "weeks" in full_response.lower(), \
|
||||
f"Should reference the requested timeframe. Got: {full_response}"
|
||||
assert (
|
||||
"2 weeks ago" in full_response.lower() or "weeks" in full_response.lower()
|
||||
), f"Should reference the requested timeframe. Got: {full_response}"
|
||||
|
||||
# Should provide a specific date (either YYYY-MM-DD format or natural language like "November 23")
|
||||
import re
|
||||
has_iso_date = bool(re.search(r'\d{4}-\d{2}-\d{2}', full_response))
|
||||
has_month_mention = any(month in full_response.lower() for month in
|
||||
['january', 'february', 'march', 'april', 'may', 'june',
|
||||
'july', 'august', 'september', 'october', 'november', 'december'])
|
||||
has_date_number = bool(re.search(r'\b\d{1,2}(st|nd|rd|th)?\b', full_response.lower()))
|
||||
|
||||
assert has_iso_date or has_month_mention or has_date_number, \
|
||||
f"Should contain a specific date. Got: {full_response}"
|
||||
has_iso_date = bool(re.search(r"\d{4}-\d{2}-\d{2}", full_response))
|
||||
has_month_mention = any(
|
||||
month in full_response.lower()
|
||||
for month in [
|
||||
"january",
|
||||
"february",
|
||||
"march",
|
||||
"april",
|
||||
"may",
|
||||
"june",
|
||||
"july",
|
||||
"august",
|
||||
"september",
|
||||
"october",
|
||||
"november",
|
||||
"december",
|
||||
]
|
||||
)
|
||||
has_date_number = bool(re.search(r"\b\d{1,2}(st|nd|rd|th)?\b", full_response.lower()))
|
||||
|
||||
assert (
|
||||
has_iso_date or has_month_mention or has_date_number
|
||||
), f"Should contain a specific date. Got: {full_response}"
|
||||
|
||||
print(f"\nDate/time response (tool used: {used_date_tool}): {full_response}")
|
||||
|
||||
@@ -284,17 +272,11 @@ async def test_tatlock_no_tool_calls_no_logging(async_client: AsyncClient):
|
||||
"""
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Just say hello to me."}
|
||||
],
|
||||
"stream": False
|
||||
"messages": [{"role": "user", "content": "Just say hello to me."}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=120.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=120.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -327,17 +309,9 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
# Turn 1: Do a calculation
|
||||
conversation.append({"role": "user", "content": "Calculate 15 times 7 for me."})
|
||||
|
||||
request_1 = {
|
||||
"model": "Tatlock",
|
||||
"messages": conversation.copy(),
|
||||
"stream": False
|
||||
}
|
||||
request_1 = {"model": "Tatlock", "messages": conversation.copy(), "stream": False}
|
||||
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_1,
|
||||
timeout=120.0
|
||||
)
|
||||
response_1 = await async_client.post("/v1/chat/completions", json=request_1, timeout=120.0)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
data_1 = response_1.json()
|
||||
@@ -353,17 +327,9 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
# Turn 2: Ask about previous calculation
|
||||
conversation.append({"role": "user", "content": "What calculation did I just ask you to do?"})
|
||||
|
||||
request_2 = {
|
||||
"model": "Tatlock",
|
||||
"messages": conversation.copy(),
|
||||
"stream": False
|
||||
}
|
||||
request_2 = {"model": "Tatlock", "messages": conversation.copy(), "stream": False}
|
||||
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_2,
|
||||
timeout=120.0
|
||||
)
|
||||
response_2 = await async_client.post("/v1/chat/completions", json=request_2, timeout=120.0)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
data_2 = response_2.json()
|
||||
@@ -371,13 +337,15 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
|
||||
# Should remember the calculation (either as digits or words)
|
||||
has_calculation = (
|
||||
("15" in second_response and "7" in second_response) or # As digits
|
||||
("fifteen" in second_response and "seven" in second_response) or # As words
|
||||
"105" in second_response or # As answer
|
||||
"multipl" in second_response # Mentions multiplication
|
||||
("15" in second_response and "7" in second_response) # As digits
|
||||
or ("fifteen" in second_response and "seven" in second_response) # As words
|
||||
or "105" in second_response # As answer
|
||||
or "multipl" in second_response # Mentions multiplication
|
||||
)
|
||||
if not has_calculation:
|
||||
pytest.xfail(f"LLM did not remember calculation (non-deterministic): {second_response[:200]}")
|
||||
pytest.xfail(
|
||||
f"LLM did not remember calculation (non-deterministic): {second_response[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -404,19 +372,13 @@ async def test_tatlock_ollama_fallback(async_client: AsyncClient):
|
||||
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Say hello to me."}
|
||||
],
|
||||
"stream": False
|
||||
"messages": [{"role": "user", "content": "Say hello to me."}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
# 300s: this test forbids the Claude rescue, and the full local
|
||||
# Steward -> orchestrate -> synthesize flow on gemma4 exceeds 120s
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=300.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=300.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
@@ -5,21 +5,20 @@ Note: Web search has been moved to The Librarian agent.
|
||||
See tests/agents/librarian/test_tools.py for search tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from src.agents.tools import (
|
||||
calculate,
|
||||
get_current_datetime,
|
||||
calculate_time_offset,
|
||||
get_current_datetime,
|
||||
time_difference,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calculator Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestCalculator:
|
||||
"""Tests for the calculator tool."""
|
||||
|
||||
@@ -81,6 +80,7 @@ class TestCalculator:
|
||||
# Date/Time Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestDateTime:
|
||||
"""Tests for date/time toolkit."""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Unit tests for backend selection (Ollama primary, Claude fallback).
|
||||
These tests set the cached health-check globals directly so they are
|
||||
deterministic regardless of which services are reachable.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.anthropic import model_selector
|
||||
|
||||
+11
-10
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Tests for chat completions router.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
@@ -17,24 +18,24 @@ def test_chat_completion_non_streaming(
|
||||
) -> None:
|
||||
"""Test non-streaming chat completion."""
|
||||
response = client.post("/v1/chat/completions", json=mock_chat_request)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Verify response structure
|
||||
assert "id" in data
|
||||
assert data["object"] == constants.CHAT_COMPLETION_OBJECT
|
||||
assert "created" in data
|
||||
assert data["model"] == mock_chat_request["model"]
|
||||
assert len(data["choices"]) == 1
|
||||
|
||||
|
||||
# Verify choice structure
|
||||
choice = data["choices"][0]
|
||||
assert choice["index"] == 0
|
||||
assert choice["message"]["role"] == constants.ROLE_ASSISTANT
|
||||
assert choice["message"]["content"] # Should have content
|
||||
assert choice["finish_reason"] == constants.FINISH_REASON_STOP
|
||||
|
||||
|
||||
# Verify usage
|
||||
assert "usage" in data
|
||||
assert data["usage"]["prompt_tokens"] > 0
|
||||
@@ -47,9 +48,9 @@ def test_chat_completion_validation_error(client: TestClient) -> None:
|
||||
"""Test chat completion with invalid request."""
|
||||
# Missing required field 'messages'
|
||||
invalid_request = {"model": "Tatlock"}
|
||||
|
||||
|
||||
response = client.post("/v1/chat/completions", json=invalid_request)
|
||||
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
@@ -108,7 +109,7 @@ async def test_chat_completion_streaming(
|
||||
# Execute with overall timeout
|
||||
try:
|
||||
chunks = await asyncio.wait_for(read_stream_with_timeout(), timeout=20.0)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pytest.fail("Streaming test timed out after 20 seconds")
|
||||
|
||||
# Verify we got chunks
|
||||
@@ -133,7 +134,7 @@ def test_chat_completion_temperature_validation(
|
||||
invalid_request = {**mock_chat_request, "temperature": 3.0}
|
||||
response = client.post("/v1/chat/completions", json=invalid_request)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# Valid temperature
|
||||
valid_request = {**mock_chat_request, "temperature": 0.5}
|
||||
response = client.post("/v1/chat/completions", json=valid_request)
|
||||
@@ -153,7 +154,7 @@ def test_chat_completion_message_roles(
|
||||
{"role": "user", "content": "Hello!"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
response = client.post("/v1/chat/completions", json=request_with_system)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -168,6 +169,6 @@ def test_chat_completion_invalid_role(
|
||||
**mock_chat_request,
|
||||
"messages": [{"role": "invalid_role", "content": "test"}],
|
||||
}
|
||||
|
||||
|
||||
response = client.post("/v1/chat/completions", json=invalid_request)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -7,7 +7,9 @@ Tests that the wrapper correctly:
|
||||
- Streams reasoning via reasoning_content field (DeepSeek R1 format)
|
||||
- Streams both reasoning and content
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@@ -20,10 +22,8 @@ async def test_streaming_wrapper_enables_reasoning(async_client: AsyncClient):
|
||||
"""Test that streaming wrapper automatically enables reasoning via reasoning_content."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Test message"}
|
||||
],
|
||||
"stream": True
|
||||
"messages": [{"role": "user", "content": "Test message"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunks_received = []
|
||||
@@ -74,10 +74,8 @@ async def test_streaming_wrapper_reasoning_before_content(async_client: AsyncCli
|
||||
"""Test that reasoning_content comes before regular content."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Explain something"}
|
||||
],
|
||||
"stream": True
|
||||
"messages": [{"role": "user", "content": "Explain something"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunk_types = [] # Track order: 'reasoning' or 'content'
|
||||
@@ -127,11 +125,9 @@ async def test_streaming_wrapper_proper_chunk_structure(async_client: AsyncClien
|
||||
"""Test that streaming chunks have proper structure."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 0.8,
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
first_chunk = None
|
||||
@@ -201,9 +197,9 @@ async def test_streaming_wrapper_with_system_message(async_client: AsyncClient):
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunks_received = []
|
||||
@@ -243,10 +239,8 @@ async def test_streaming_wrapper_pipeline_prefix(async_client: AsyncClient):
|
||||
"""Test streaming with pipeline prefix in model name."""
|
||||
request_data = {
|
||||
"model": "some_pipeline.lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Test"}
|
||||
],
|
||||
"stream": True
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunks_received = []
|
||||
@@ -285,17 +279,11 @@ async def test_streaming_wrapper_non_streaming_fallback(async_client: AsyncClien
|
||||
"""Test that non-streaming request works through wrapper."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"stream": False # Non-streaming
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": False, # Non-streaming
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=20.0
|
||||
)
|
||||
response = await async_client.post("/v1/chat/completions", json=request_data, timeout=20.0)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
+6
-12
@@ -2,11 +2,12 @@
|
||||
Shared test fixtures for all tests.
|
||||
Following FastAPI testing best practices.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
@@ -28,10 +29,7 @@ def _tenant_guard():
|
||||
from src.core.multi_tenancy import get_memory_collection_name
|
||||
|
||||
effective = get_default_user()
|
||||
if (
|
||||
effective == PRODUCTION_TENANT
|
||||
or config.effective_default_user == PRODUCTION_TENANT
|
||||
):
|
||||
if effective == PRODUCTION_TENANT or config.effective_default_user == PRODUCTION_TENANT:
|
||||
pytest.exit(
|
||||
f"TENANT GUARD: refusing to run the test suite - the effective "
|
||||
f"tenant resolves to the production tenant '{PRODUCTION_TENANT}' "
|
||||
@@ -59,6 +57,7 @@ def _initialize_app(_tenant_guard):
|
||||
production tenant before any initialization happens.
|
||||
"""
|
||||
from src.core.startup import initialize_application
|
||||
|
||||
asyncio.run(initialize_application())
|
||||
|
||||
|
||||
@@ -79,10 +78,7 @@ async def async_client() -> AsyncClient:
|
||||
|
||||
Use for testing async endpoints and streaming.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as client:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@@ -91,9 +87,7 @@ def mock_chat_request() -> dict:
|
||||
"""Standard chat completion request fixture."""
|
||||
return {
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, world!"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello, world!"}],
|
||||
"temperature": 0.7,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ Semantics:
|
||||
|
||||
Run with: make test-contracts
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
@@ -66,9 +67,9 @@ class TestOllamaContract:
|
||||
assert response.status_code == 200
|
||||
names = [m["name"] for m in response.json()["models"]]
|
||||
model = config.OLLAMA_DEFAULT_MODEL
|
||||
assert model in names or f"{model}:latest" in names, (
|
||||
f"{model} not pulled; available: {names}"
|
||||
)
|
||||
assert (
|
||||
model in names or f"{model}:latest" in names
|
||||
), f"{model} not pulled; available: {names}"
|
||||
|
||||
async def test_generate_returns_plain_text(self):
|
||||
# Mirrors StewardAgent._call_ollama()
|
||||
@@ -94,9 +95,7 @@ class TestOllamaContract:
|
||||
"ollama",
|
||||
{
|
||||
"model": config.OLLAMA_DEFAULT_MODEL,
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 6 * 7? Use the calculator."}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "What is 6 * 7? Use the calculator."}],
|
||||
"tools": [CALCULATOR_TOOL],
|
||||
"tool_choice": "required",
|
||||
"stream": False,
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for household registry.
|
||||
|
||||
Tests capability registration, toolset scoping, and coordination features.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.tools import Tool
|
||||
|
||||
@@ -10,7 +11,6 @@ from src.core.household_registry import (
|
||||
HouseholdCapability,
|
||||
HouseholdMember,
|
||||
HouseholdRegistry,
|
||||
household_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ def sample_capability():
|
||||
@pytest.fixture
|
||||
def sample_tools():
|
||||
"""Sample tool definitions."""
|
||||
|
||||
def test_function_1(x: int) -> int:
|
||||
"""Test function 1."""
|
||||
return x * 2
|
||||
@@ -96,6 +97,7 @@ class TestHouseholdMember:
|
||||
def test_member_with_agent(self, sample_capability, sample_tools):
|
||||
"""Test member can include an agent."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
mock_agent = Mock()
|
||||
|
||||
member = HouseholdMember(
|
||||
@@ -322,7 +324,9 @@ class TestGetDelegationTools:
|
||||
assert callable(tools[0])
|
||||
assert tools[0].__name__ == "delegate_to_librarian"
|
||||
|
||||
def test_delegation_tools_returns_raw_tools_for_member_without_agent(self, registry, sample_capability, sample_tools):
|
||||
def test_delegation_tools_returns_raw_tools_for_member_without_agent(
|
||||
self, registry, sample_capability, sample_tools
|
||||
):
|
||||
"""Test delegation tools returns raw tools when member has no agent."""
|
||||
registry.register("test_tools", sample_capability, sample_tools)
|
||||
|
||||
@@ -373,8 +377,8 @@ class TestGetDelegationTools:
|
||||
assert tools[0].__name__ == "delegate_to_librarian"
|
||||
|
||||
# Rest should be raw tools
|
||||
assert hasattr(tools[1], 'name')
|
||||
assert hasattr(tools[2], 'name')
|
||||
assert hasattr(tools[1], "name")
|
||||
assert hasattr(tools[2], "name")
|
||||
|
||||
def test_delegation_tools_nonexistent_member(self, registry):
|
||||
"""Test delegation tools handles non-existent member gracefully."""
|
||||
|
||||
@@ -3,12 +3,11 @@ Tests for structured logging configuration.
|
||||
|
||||
Tests logging setup, context management, and FastAPI integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
|
||||
from src.core.logging_config import (
|
||||
add_log_level,
|
||||
@@ -50,10 +49,10 @@ class TestGetLogger:
|
||||
"""Test get_logger returns structlog BoundLogger."""
|
||||
logger = get_logger("test")
|
||||
# Logger should have standard logging methods
|
||||
assert hasattr(logger, 'info')
|
||||
assert hasattr(logger, 'debug')
|
||||
assert hasattr(logger, 'warning')
|
||||
assert hasattr(logger, 'error')
|
||||
assert hasattr(logger, "info")
|
||||
assert hasattr(logger, "debug")
|
||||
assert hasattr(logger, "warning")
|
||||
assert hasattr(logger, "error")
|
||||
|
||||
def test_get_logger_with_module_name(self):
|
||||
"""Test logger with module name."""
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
Tests for the memory service (direct access layer).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
from src.core.memory_service import (
|
||||
MemoryRecord,
|
||||
MemoryService,
|
||||
MemoryType,
|
||||
MemoryRecord,
|
||||
memory_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Tests for core router (health check and root endpoints).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -11,7 +12,7 @@ from src.core.config import config
|
||||
def test_health_check(client: TestClient) -> None:
|
||||
"""Test health check endpoint returns healthy status."""
|
||||
response = client.get("/health")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
@@ -22,7 +23,7 @@ def test_health_check(client: TestClient) -> None:
|
||||
def test_root_endpoint(client: TestClient) -> None:
|
||||
"""Test root endpoint returns API information."""
|
||||
response = client.get("/")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == config.APP_NAME
|
||||
@@ -42,7 +43,7 @@ def test_openapi_schema(client: TestClient) -> None:
|
||||
"""Test that OpenAPI schema is accessible."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
schema = response.json()
|
||||
assert schema["info"]["title"] == config.APP_NAME
|
||||
assert schema["info"]["version"] == config.APP_VERSION
|
||||
|
||||
@@ -157,9 +157,7 @@ class TestRequestContextGuard:
|
||||
"jpmschweitzer!",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"environment", [Environment.DEVELOPMENT, Environment.TESTING]
|
||||
)
|
||||
@pytest.mark.parametrize("environment", [Environment.DEVELOPMENT, Environment.TESTING])
|
||||
def test_production_tenant_sanitization_variants_are_forced(
|
||||
self, monkeypatch, environment, variant
|
||||
):
|
||||
@@ -175,9 +173,8 @@ class TestRequestContextGuard:
|
||||
with RequestContext(user=variant):
|
||||
effective = get_user()
|
||||
assert effective == TEST_TENANT
|
||||
assert (
|
||||
get_memory_collection_name(effective)
|
||||
!= get_memory_collection_name(PRODUCTION_TENANT)
|
||||
assert get_memory_collection_name(effective) != get_memory_collection_name(
|
||||
PRODUCTION_TENANT
|
||||
)
|
||||
|
||||
def test_dev_non_colliding_user_is_not_forced(self, monkeypatch):
|
||||
@@ -228,10 +225,7 @@ class TestSuiteRunsUnderTestTenant:
|
||||
from src.core.context import get_default_user
|
||||
from src.core.multi_tenancy import get_memory_collection_name
|
||||
|
||||
assert (
|
||||
get_memory_collection_name(get_default_user())
|
||||
== f"memories_{TEST_TENANT}"
|
||||
)
|
||||
assert get_memory_collection_name(get_default_user()) == f"memories_{TEST_TENANT}"
|
||||
|
||||
def test_redis_session_namespace_is_test_tenant(self):
|
||||
from src.core.context import get_default_user
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for tool call tracking.
|
||||
|
||||
Tests capability extraction and recommendation matching.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.tool_tracking import ToolCallTracker
|
||||
@@ -29,9 +30,7 @@ class TestToolCallTracker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_call_recognizes_delegation_as_recommended(self):
|
||||
"""Test that delegate_to_X is recognized when X is recommended."""
|
||||
tracker = ToolCallTracker(
|
||||
recommended_capabilities=["librarian", "biographer"]
|
||||
)
|
||||
tracker = ToolCallTracker(recommended_capabilities=["librarian", "biographer"])
|
||||
|
||||
await tracker.track_call("delegate_to_librarian", 1.0)
|
||||
|
||||
@@ -42,9 +41,7 @@ class TestToolCallTracker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_call_detects_not_recommended(self):
|
||||
"""Test that unrecommended tools are flagged."""
|
||||
tracker = ToolCallTracker(
|
||||
recommended_capabilities=["librarian"]
|
||||
)
|
||||
tracker = ToolCallTracker(recommended_capabilities=["librarian"])
|
||||
|
||||
await tracker.track_call("delegate_to_housekeeper", 1.0)
|
||||
|
||||
@@ -55,9 +52,7 @@ class TestToolCallTracker:
|
||||
|
||||
def test_get_summary_with_delegation_tools(self):
|
||||
"""Test summary correctly maps delegation tools to capabilities."""
|
||||
tracker = ToolCallTracker(
|
||||
recommended_capabilities=["librarian", "biographer"]
|
||||
)
|
||||
tracker = ToolCallTracker(recommended_capabilities=["librarian", "biographer"])
|
||||
tracker.actual_calls = {
|
||||
"delegate_to_librarian": [1.0, 2.0],
|
||||
"delegate_to_housekeeper": [0.5], # Not recommended
|
||||
@@ -72,9 +67,7 @@ class TestToolCallTracker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_with_delegation_tools(self):
|
||||
"""Test finalize correctly identifies unused recommendations."""
|
||||
tracker = ToolCallTracker(
|
||||
recommended_capabilities=["librarian", "biographer"]
|
||||
)
|
||||
tracker = ToolCallTracker(recommended_capabilities=["librarian", "biographer"])
|
||||
tracker.actual_calls = {
|
||||
"delegate_to_librarian": [1.0],
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ These tests hit the actual running server and test the full stack:
|
||||
- Tool execution
|
||||
- Response formatting
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import httpx
|
||||
from typing import AsyncGenerator
|
||||
|
||||
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
|
||||
BASE_URL = "http://localhost:8777"
|
||||
@@ -34,10 +36,8 @@ class TestChatCompletionsE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 144 divided by 12?"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "What is 144 divided by 12?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -68,7 +68,9 @@ class TestChatCompletionsE2E:
|
||||
assert "usage" in data
|
||||
assert data["usage"]["total_tokens"] > 0
|
||||
|
||||
print(f"✓ Calculator test passed. Found '12' in response. Tool indicator: {has_calculator_indicator}")
|
||||
print(
|
||||
f"✓ Calculator test passed. Found '12' in response. Tool indicator: {has_calculator_indicator}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_search(self, client: httpx.AsyncClient):
|
||||
@@ -80,7 +82,7 @@ class TestChatCompletionsE2E:
|
||||
"messages": [
|
||||
{"role": "user", "content": "Search for the current population of Tokyo"}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -114,10 +116,8 @@ class TestChatCompletionsE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 15 times 4?"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "What is 15 times 4?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response1.status_code == 200
|
||||
@@ -135,9 +135,9 @@ class TestChatCompletionsE2E:
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 15 times 4?"},
|
||||
{"role": "assistant", "content": message1},
|
||||
{"role": "user", "content": "Now add 20 to that result."}
|
||||
{"role": "user", "content": "Now add 20 to that result."},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response2.status_code == 200
|
||||
@@ -152,7 +152,9 @@ class TestChatCompletionsE2E:
|
||||
has_calculation = "60" in message2 and "20" in message2
|
||||
assert has_answer or has_calculation, f"Expected '80' or calculation in: {message2}"
|
||||
|
||||
print(f"✓ Multi-turn test passed. Answer found: {has_answer}, Calculation shown: {has_calculation}")
|
||||
print(
|
||||
f"✓ Multi-turn test passed. Answer found: {has_answer}, Calculation shown: {has_calculation}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculation_and_search(self, client: httpx.AsyncClient):
|
||||
@@ -161,13 +163,8 @@ class TestChatCompletionsE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Calculate the square root of 256"
|
||||
}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "Calculate the square root of 256"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -194,10 +191,8 @@ class TestChatCompletionsE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, how are you?"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "Hello, how are you?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -223,10 +218,8 @@ class TestChatCompletionsE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is today's date?"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "What is today's date?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -242,11 +235,16 @@ class TestChatCompletionsE2E:
|
||||
|
||||
# Should contain some date/time information (flexible - varies in format)
|
||||
import re
|
||||
|
||||
has_date = (
|
||||
re.search(r'\d{4}', message) or # Year
|
||||
re.search(r'\d{1,2}', message) or # Day/month number
|
||||
re.search(r'(January|February|March|April|May|June|July|August|September|October|November|December)', message, re.IGNORECASE) or
|
||||
"today" in message.lower()
|
||||
re.search(r"\d{4}", message) # Year
|
||||
or re.search(r"\d{1,2}", message) # Day/month number
|
||||
or re.search(
|
||||
r"(January|February|March|April|May|June|July|August|September|October|November|December)",
|
||||
message,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
or "today" in message.lower()
|
||||
)
|
||||
|
||||
assert has_date, f"Expected date/time information in: {message}"
|
||||
@@ -263,11 +261,9 @@ class TestResponsesAPIE2E:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Calculate 25 times 16"}
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||
}
|
||||
"input": [{"role": "user", "content": "Calculate 25 times 16"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -300,7 +296,7 @@ class TestResponsesAPIE2E:
|
||||
assert "usage" in data
|
||||
assert data["usage"]["total_tokens"] > 0
|
||||
|
||||
print(f"✓ Responses API test passed. Found '400' with Steward reasoning.")
|
||||
print("✓ Responses API test passed. Found '400' with Steward reasoning.")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_multi_turn(self, client: httpx.AsyncClient):
|
||||
@@ -312,10 +308,10 @@ class TestResponsesAPIE2E:
|
||||
"input": [
|
||||
{"role": "user", "content": "What is 7 times 8?"},
|
||||
{"role": "assistant", "content": "Certainly, sir. 7 times 8 equals 56."},
|
||||
{"role": "user", "content": "Double that number."}
|
||||
{"role": "user", "content": "Double that number."},
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||
}
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -346,11 +342,9 @@ class TestStreamingE2E:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 9 times 7?"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
"messages": [{"role": "user", "content": "What is 9 times 7?"}],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -362,6 +356,7 @@ class TestStreamingE2E:
|
||||
break
|
||||
|
||||
import json
|
||||
|
||||
chunk = json.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
|
||||
@@ -373,8 +368,7 @@ class TestStreamingE2E:
|
||||
|
||||
# Should have received Steward's reasoning (in <think> tags)
|
||||
full_content = "".join(
|
||||
chunk["choices"][0]["delta"].get("content", "") or ""
|
||||
for chunk in chunks
|
||||
chunk["choices"][0]["delta"].get("content", "") or "" for chunk in chunks
|
||||
)
|
||||
assert "<think>" in full_content
|
||||
assert "</think>" in full_content
|
||||
@@ -393,10 +387,8 @@ class TestErrorHandling:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "nonexistent-model",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -411,7 +403,7 @@ class TestErrorHandling:
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
# Missing "messages" field
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -425,11 +417,9 @@ class TestErrorHandling:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"temperature": 5.0 # Max is 2.0
|
||||
}
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 5.0, # Max is 2.0
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -447,11 +437,9 @@ class TestChatResponsesWrapper:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Calculate 13 times 9"}
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||
}
|
||||
"input": [{"role": "user", "content": "Calculate 13 times 9"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -500,10 +488,8 @@ class TestChatResponsesWrapper:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 5 plus 3?"}
|
||||
],
|
||||
}
|
||||
"messages": [{"role": "user", "content": "What is 5 plus 3?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -539,11 +525,9 @@ class TestChatResponsesWrapper:
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Count to 3"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
"messages": [{"role": "user", "content": "Count to 3"}],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -555,6 +539,7 @@ class TestChatResponsesWrapper:
|
||||
break
|
||||
|
||||
import json
|
||||
|
||||
chunk = json.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
|
||||
@@ -574,10 +559,7 @@ class TestChatResponsesWrapper:
|
||||
assert chunks[0]["choices"][0]["delta"]["role"] == "assistant"
|
||||
|
||||
# Should have content chunks
|
||||
has_content = any(
|
||||
"content" in chunk["choices"][0]["delta"]
|
||||
for chunk in chunks
|
||||
)
|
||||
has_content = any("content" in chunk["choices"][0]["delta"] for chunk in chunks)
|
||||
assert has_content
|
||||
|
||||
print(f"✓ Streaming format matches OpenAI spec ({len(chunks)} chunks)")
|
||||
@@ -593,11 +575,9 @@ class TestStewardIntegration:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Calculate 123 times 456"}
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||
}
|
||||
"input": [{"role": "user", "content": "Calculate 123 times 456"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -620,10 +600,10 @@ class TestStewardIntegration:
|
||||
"input": [
|
||||
{"role": "user", "content": "My favorite number is 42"},
|
||||
{"role": "assistant", "content": "Noted, sir. 42 is an excellent choice."},
|
||||
{"role": "user", "content": "What was that number again?"}
|
||||
{"role": "user", "content": "What was that number again?"},
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"}
|
||||
}
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -18,13 +18,14 @@ Requirements:
|
||||
Note: LLM outputs are non-deterministic. Tests use flexible assertions
|
||||
that check for behavioral patterns rather than exact text matches.
|
||||
"""
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.core.config import PRODUCTION_TENANT, TEST_TENANT
|
||||
|
||||
@@ -43,6 +44,7 @@ assert TEST_USER != PRODUCTION_TENANT
|
||||
@dataclass
|
||||
class LLMAssertionResult:
|
||||
"""Result of an LLM output assertion check."""
|
||||
|
||||
passed: bool
|
||||
evidence: str
|
||||
confidence: str # "high", "medium", "low"
|
||||
@@ -183,6 +185,7 @@ def assert_llm_behavior(
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
"""HTTP client for API requests."""
|
||||
@@ -223,6 +226,7 @@ async def clean_test_memories(qdrant: QdrantVerifier):
|
||||
# Memory System Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestMemoryStorage:
|
||||
@@ -245,9 +249,7 @@ class TestMemoryStorage:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Remember that my test color is purple"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "Remember that my test color is purple"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -281,7 +283,8 @@ class TestMemoryStorage:
|
||||
# Verify data in Qdrant - look for any color-related or test-related memory
|
||||
points = await qdrant.scroll_points(TEST_COLLECTION)
|
||||
relevant_memories = [
|
||||
p for p in points
|
||||
p
|
||||
for p in points
|
||||
if "color" in p.get("payload", {}).get("key", "").lower()
|
||||
or "purple" in str(p.get("payload", {}).get("value", "")).lower()
|
||||
or "test" in p.get("payload", {}).get("key", "").lower()
|
||||
@@ -341,9 +344,7 @@ class TestMemoryStorage:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What is my favorite food?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What is my favorite food?"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -431,6 +432,7 @@ class TestMemoryRecall:
|
||||
# Steward Delegation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestStewardDelegation:
|
||||
@@ -445,9 +447,7 @@ class TestStewardDelegation:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What do you know about me?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What do you know about me?"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
@@ -479,9 +479,7 @@ class TestStewardDelegation:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Calculate 127 times 83"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "Calculate 127 times 83"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
@@ -511,15 +509,16 @@ class TestStewardDelegation:
|
||||
|
||||
# Remove commas for number comparison
|
||||
message_normalized = message_text.replace(",", "")
|
||||
assert "10541" in message_normalized, (
|
||||
f"Expected calculation result 10541. Got: {message_text[:200]}"
|
||||
)
|
||||
assert (
|
||||
"10541" in message_normalized
|
||||
), f"Expected calculation result 10541. Got: {message_text[:200]}"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Direct Delegation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestDirectDelegation:
|
||||
@@ -569,6 +568,7 @@ class TestDirectDelegation:
|
||||
# Data Verification Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestDataVerification:
|
||||
@@ -610,8 +610,7 @@ class TestDataVerification:
|
||||
payload = point.get("payload", {})
|
||||
for field in required_fields:
|
||||
assert field in payload, (
|
||||
f"Point {point['id']} missing required field '{field}'. "
|
||||
f"Payload: {payload}"
|
||||
f"Point {point['id']} missing required field '{field}'. " f"Payload: {payload}"
|
||||
)
|
||||
|
||||
async def test_qdrant_point_types_are_valid(
|
||||
@@ -643,6 +642,7 @@ class TestDataVerification:
|
||||
# Integration Health Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestIntegrationHealth:
|
||||
@@ -678,6 +678,7 @@ class TestIntegrationHealth:
|
||||
# Orchestration Scenario Tests (from ORCHESTRATION_SCENARIOS.md)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestScenario1WeatherWithMemory:
|
||||
@@ -697,9 +698,7 @@ class TestScenario1WeatherWithMemory:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What's the weather like?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What's the weather like?"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
)
|
||||
@@ -717,13 +716,17 @@ class TestScenario1WeatherWithMemory:
|
||||
print(f"Weather query - Steward: {reasoning_text[:200]}...")
|
||||
|
||||
# Should mention location/memory and search capabilities
|
||||
has_memory_mention = "biographer" in reasoning_text.lower() or "memory" in reasoning_text.lower()
|
||||
has_search_mention = "tatlock_core" in reasoning_text.lower() or "search" in reasoning_text.lower()
|
||||
has_memory_mention = (
|
||||
"biographer" in reasoning_text.lower() or "memory" in reasoning_text.lower()
|
||||
)
|
||||
has_search_mention = (
|
||||
"tatlock_core" in reasoning_text.lower() or "search" in reasoning_text.lower()
|
||||
)
|
||||
|
||||
# Weather query should trigger at least web search
|
||||
assert has_search_mention, (
|
||||
f"Weather query should recommend search capability. Got: {reasoning_text[:200]}"
|
||||
)
|
||||
assert (
|
||||
has_search_mention
|
||||
), f"Weather query should recommend search capability. Got: {reasoning_text[:200]}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -744,9 +747,7 @@ class TestScenario4SimpleExpertDelegation:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What is 847 times 293?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What is 847 times 293?"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -761,9 +762,9 @@ class TestScenario4SimpleExpertDelegation:
|
||||
|
||||
# Expected result: 248171 (may be formatted as 248,171)
|
||||
message_normalized = message_text.replace(",", "")
|
||||
assert "248171" in message_normalized, (
|
||||
f"Calculator should compute 847 * 293 = 248171. Got: {message_text[:200]}"
|
||||
)
|
||||
assert (
|
||||
"248171" in message_normalized
|
||||
), f"Calculator should compute 847 * 293 = 248171. Got: {message_text[:200]}"
|
||||
|
||||
async def test_datetime_query_uses_datetime_tool(
|
||||
self,
|
||||
@@ -774,9 +775,7 @@ class TestScenario4SimpleExpertDelegation:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What day of the week is it?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What day of the week is it?"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -791,8 +790,20 @@ class TestScenario4SimpleExpertDelegation:
|
||||
|
||||
# Should mention a day of the week (full or abbreviated)
|
||||
days = [
|
||||
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||
"mon", "tue", "wed", "thu", "fri", "sat", "sun"
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
]
|
||||
has_day = any(day in message_text.lower() for day in days)
|
||||
|
||||
@@ -838,9 +849,9 @@ class TestScenario6WikiCreation:
|
||||
print(f"Wiki search - Steward: {reasoning_text[:200]}...")
|
||||
|
||||
# Should mention librarian
|
||||
assert "librarian" in reasoning_text.lower(), (
|
||||
f"Wiki search should recommend librarian. Got: {reasoning_text[:200]}"
|
||||
)
|
||||
assert (
|
||||
"librarian" in reasoning_text.lower()
|
||||
), f"Wiki search should recommend librarian. Got: {reasoning_text[:200]}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -862,7 +873,10 @@ class TestScenario8MultiExpertCoordination:
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What do you know about me? And also search for Python tutorials."}
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What do you know about me? And also search for Python tutorials.",
|
||||
}
|
||||
],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
},
|
||||
@@ -885,15 +899,16 @@ class TestScenario8MultiExpertCoordination:
|
||||
has_librarian = "librarian" in reasoning_text.lower()
|
||||
has_search = "tatlock_core" in reasoning_text.lower() or "search" in reasoning_text.lower()
|
||||
|
||||
assert has_biographer or has_librarian or has_search, (
|
||||
f"Complex query should identify multiple capabilities. Got: {reasoning_text[:200]}"
|
||||
)
|
||||
assert (
|
||||
has_biographer or has_librarian or has_search
|
||||
), f"Complex query should identify multiple capabilities. Got: {reasoning_text[:200]}"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# New Scenarios from Today's Session
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
class TestDirectDelegationBypass:
|
||||
@@ -917,9 +932,7 @@ class TestDirectDelegationBypass:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Remember that my test value is alpha123"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "Remember that my test value is alpha123"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -979,7 +992,10 @@ class TestUserContextIsolation:
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": f"Remember that my isolation marker is {unique_value}"}
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Remember that my isolation marker is {unique_value}",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -1000,13 +1016,15 @@ class TestUserContextIsolation:
|
||||
else:
|
||||
in_prod_collection = False
|
||||
|
||||
print(f"Isolation test - In test collection: {in_test_collection}, In prod: {in_prod_collection}")
|
||||
print(
|
||||
f"Isolation test - In test collection: {in_test_collection}, In prod: {in_prod_collection}"
|
||||
)
|
||||
|
||||
# Should be in test collection OR response acknowledged
|
||||
# Should NOT be in production collection
|
||||
assert not in_prod_collection, (
|
||||
f"Test data leaked to production collection! Value: {unique_value}"
|
||||
)
|
||||
assert (
|
||||
not in_prod_collection
|
||||
), f"Test data leaked to production collection! Value: {unique_value}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -1025,16 +1043,16 @@ class TestErrorHandling:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "nonexistent-model-xyz",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
|
||||
# Should return error status (404 or 400)
|
||||
assert response.status_code in [400, 404, 422], (
|
||||
f"Expected error status for invalid model. Got: {response.status_code}"
|
||||
)
|
||||
assert response.status_code in [
|
||||
400,
|
||||
404,
|
||||
422,
|
||||
], f"Expected error status for invalid model. Got: {response.status_code}"
|
||||
data = response.json()
|
||||
# Error could be in "error" or "detail" key
|
||||
assert "error" in data or "detail" in data
|
||||
@@ -1084,7 +1102,10 @@ class TestEvaluationReport:
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "Remember that my test pet is a hamster named Fluffy"}
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Remember that my test pet is a hamster named Fluffy",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -1096,7 +1117,7 @@ class TestEvaluationReport:
|
||||
break
|
||||
|
||||
report_lines.append("\n[STORE TEST]")
|
||||
report_lines.append(f"Input: 'Remember that my test pet is a hamster named Fluffy'")
|
||||
report_lines.append("Input: 'Remember that my test pet is a hamster named Fluffy'")
|
||||
report_lines.append(f"Response: {store_text[:200]}...")
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
@@ -1106,9 +1127,7 @@ class TestEvaluationReport:
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "Tatlock",
|
||||
"input": [
|
||||
{"role": "user", "content": "What pet do I have?"}
|
||||
],
|
||||
"input": [{"role": "user", "content": "What pet do I have?"}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1119,7 +1138,7 @@ class TestEvaluationReport:
|
||||
break
|
||||
|
||||
report_lines.append("\n[RECALL TEST]")
|
||||
report_lines.append(f"Input: 'What pet do I have?'")
|
||||
report_lines.append("Input: 'What pet do I have?'")
|
||||
report_lines.append(f"Response: {recall_text[:200]}...")
|
||||
|
||||
# Check Qdrant state
|
||||
@@ -1128,7 +1147,8 @@ class TestEvaluationReport:
|
||||
report_lines.append(f"Total points in {TEST_COLLECTION}: {len(points)}")
|
||||
|
||||
pet_memories = [
|
||||
p for p in points
|
||||
p
|
||||
for p in points
|
||||
if "pet" in str(p.get("payload", {})).lower()
|
||||
or "fluffy" in str(p.get("payload", {})).lower()
|
||||
or "hamster" in str(p.get("payload", {})).lower()
|
||||
|
||||
@@ -3,12 +3,14 @@ Integration tests for Steward + Tatlock streaming.
|
||||
|
||||
Tests the complete streaming flow with Steward preprocessing.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.responses.schemas import ResponseRequest
|
||||
from src.responses.streaming import StreamingCoordinator, StreamEventType
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.startup import initialize_application
|
||||
from src.responses.schemas import ResponseRequest
|
||||
from src.responses.streaming import StreamEventType, StreamingCoordinator
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
@@ -32,7 +34,9 @@ class TestStewardStreaming:
|
||||
# Mock the Steward analysis
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
# Mock the streaming method (async generator)
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||
with patch(
|
||||
"src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream"
|
||||
) as mock_tatlock_stream:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
# Mock Steward recommendation
|
||||
@@ -89,7 +93,9 @@ class TestStewardStreaming:
|
||||
)
|
||||
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||
with patch(
|
||||
"src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream"
|
||||
) as mock_tatlock_stream:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
mock_steward.return_value = StewardRecommendation(
|
||||
@@ -99,7 +105,7 @@ class TestStewardStreaming:
|
||||
conversation_context=ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[0],
|
||||
context_summary="Previous calculation in turn 0"
|
||||
context_summary="Previous calculation in turn 0",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -134,7 +140,9 @@ class TestStewardStreaming:
|
||||
)
|
||||
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||
with patch(
|
||||
"src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream"
|
||||
) as mock_tatlock_stream:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
mock_steward.return_value = StewardRecommendation(
|
||||
@@ -173,7 +181,9 @@ class TestStewardStreaming:
|
||||
)
|
||||
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream") as mock_tatlock_stream:
|
||||
with patch(
|
||||
"src.agents.tatlock.TatlockAgent.run_with_scoped_tools_stream"
|
||||
) as mock_tatlock_stream:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
mock_steward.return_value = StewardRecommendation(
|
||||
|
||||
@@ -7,12 +7,14 @@ Tests the complete Phase 2 request pipeline:
|
||||
3. Tatlock runs with scoped tools
|
||||
4. Response includes both Steward reasoning and Tatlock output
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.startup import initialize_application
|
||||
from src.responses.schemas import ResponseRequest
|
||||
from src.responses.service import create_response_with_steward
|
||||
from src.core.startup import initialize_application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
@@ -56,8 +58,9 @@ class TestStewardTatlockIntegration:
|
||||
assert mock_steward.called
|
||||
# Note: preprocess_request injects temporal context
|
||||
steward_call_arg = mock_steward.call_args[0][0]
|
||||
assert steward_call_arg.startswith("What's 2 + 2?"), \
|
||||
f"Expected request to start with original message, got: {steward_call_arg}"
|
||||
assert steward_call_arg.startswith(
|
||||
"What's 2 + 2?"
|
||||
), f"Expected request to start with original message, got: {steward_call_arg}"
|
||||
|
||||
# Verify Tatlock was called with scoped tools
|
||||
assert mock_tatlock.called
|
||||
@@ -100,7 +103,7 @@ class TestStewardTatlockIntegration:
|
||||
conversation_context=ConversationContext(
|
||||
has_previous_context=True,
|
||||
relevant_turns=[0],
|
||||
context_summary="Previous calculation in turn 0"
|
||||
context_summary="Previous calculation in turn 0",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -161,7 +164,10 @@ class TestStewardTatlockIntegration:
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||
with patch("src.core.tool_tracking.ToolCallTracker.finalize") as mock_finalize:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
from src.agents.steward.schemas import (
|
||||
ConversationContext,
|
||||
StewardRecommendation,
|
||||
)
|
||||
|
||||
mock_steward.return_value = StewardRecommendation(
|
||||
recommended_capabilities=["tatlock_core"],
|
||||
@@ -198,7 +204,9 @@ class TestStewardTatlockIntegration:
|
||||
missing_capabilities="Image generation capability would be needed",
|
||||
)
|
||||
|
||||
mock_tatlock.return_value = "I'm afraid I don't have image generation capabilities, sir."
|
||||
mock_tatlock.return_value = (
|
||||
"I'm afraid I don't have image generation capabilities, sir."
|
||||
)
|
||||
|
||||
response = await create_response_with_steward(request)
|
||||
|
||||
@@ -220,7 +228,10 @@ class TestStewardTatlockIntegration:
|
||||
with patch("src.core.preprocessing.analyze_request") as mock_steward:
|
||||
with patch("src.agents.tatlock.TatlockAgent.run_with_scoped_tools") as mock_tatlock:
|
||||
with patch("src.responses.service.ToolCallTracker") as mock_tracker_class:
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
from src.agents.steward.schemas import (
|
||||
ConversationContext,
|
||||
StewardRecommendation,
|
||||
)
|
||||
|
||||
mock_steward.return_value = StewardRecommendation(
|
||||
recommended_capabilities=["tatlock_core"],
|
||||
|
||||
@@ -5,10 +5,12 @@ These tests verify the complete streaming flow from API endpoint through
|
||||
StreamingCoordinator to TatlockAgent, ensuring no text duplication and
|
||||
proper delta calculation.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -24,7 +26,7 @@ async def test_tatlock_streaming_no_duplication(async_client: AsyncClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Say hello"}],
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
collected_deltas = []
|
||||
@@ -74,12 +76,12 @@ async def test_tatlock_streaming_no_duplication(async_client: AsyncClient):
|
||||
if len(words) > 0:
|
||||
# Check for consecutive duplicate words (sign of duplication bug)
|
||||
consecutive_dupes = sum(
|
||||
1 for i in range(len(words) - 1)
|
||||
if words[i] == words[i + 1] and len(words[i]) > 3
|
||||
1 for i in range(len(words) - 1) if words[i] == words[i + 1] and len(words[i]) > 3
|
||||
)
|
||||
# Allow a few duplicates (natural language), but not excessive
|
||||
assert consecutive_dupes < len(words) * 0.1, \
|
||||
f"Too many consecutive duplicate words: {consecutive_dupes}/{len(words)}"
|
||||
assert (
|
||||
consecutive_dupes < len(words) * 0.1
|
||||
), f"Too many consecutive duplicate words: {consecutive_dupes}/{len(words)}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -94,7 +96,7 @@ async def test_tatlock_chat_streaming_no_duplication(async_client: AsyncClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
collected_content = []
|
||||
@@ -138,11 +140,11 @@ async def test_tatlock_chat_streaming_no_duplication(async_client: AsyncClient):
|
||||
words = full_response.lower().split()
|
||||
if len(words) > 0:
|
||||
consecutive_dupes = sum(
|
||||
1 for i in range(len(words) - 1)
|
||||
if words[i] == words[i + 1] and len(words[i]) > 3
|
||||
1 for i in range(len(words) - 1) if words[i] == words[i + 1] and len(words[i]) > 3
|
||||
)
|
||||
assert consecutive_dupes < len(words) * 0.1, \
|
||||
f"Too many consecutive duplicate words in chat response: {consecutive_dupes}/{len(words)}"
|
||||
assert (
|
||||
consecutive_dupes < len(words) * 0.1
|
||||
), f"Too many consecutive duplicate words in chat response: {consecutive_dupes}/{len(words)}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -153,7 +155,7 @@ def test_tatlock_non_streaming_responses_api(client: TestClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Say hello"}],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data, timeout=30.0)
|
||||
@@ -183,7 +185,7 @@ def test_tatlock_non_streaming_chat_api(client: TestClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/chat/completions", json=request_data, timeout=30.0)
|
||||
@@ -216,7 +218,7 @@ async def test_tatlock_streaming_delta_accumulation(async_client: AsyncClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Count to three"}],
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
collected_deltas = []
|
||||
@@ -248,8 +250,9 @@ async def test_tatlock_streaming_delta_accumulation(async_client: AsyncClient):
|
||||
|
||||
# Verify each delta is new content
|
||||
current_full = "".join(collected_deltas)
|
||||
assert current_full.startswith(previous_full_text), \
|
||||
"Deltas should accumulate progressively"
|
||||
assert current_full.startswith(
|
||||
previous_full_text
|
||||
), "Deltas should accumulate progressively"
|
||||
previous_full_text = current_full
|
||||
|
||||
except json.JSONDecodeError:
|
||||
@@ -273,7 +276,7 @@ async def test_tatlock_with_reasoning(async_client: AsyncClient):
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
has_reasoning = False
|
||||
@@ -328,7 +331,7 @@ async def test_tatlock_markdown_formatting_preserved(async_client: AsyncClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Can you give me an HTML5 boilerplate template?"}],
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
collected_deltas = []
|
||||
@@ -365,15 +368,15 @@ async def test_tatlock_markdown_formatting_preserved(async_client: AsyncClient):
|
||||
full_response = "".join(collected_deltas)
|
||||
|
||||
# Always print the response for debugging
|
||||
print("\n" + "="*80)
|
||||
print("\n" + "=" * 80)
|
||||
print("FULL RESPONSE (repr):")
|
||||
print("="*80)
|
||||
print("=" * 80)
|
||||
print(repr(full_response))
|
||||
print("\n" + "="*80)
|
||||
print("\n" + "=" * 80)
|
||||
print("FULL RESPONSE (formatted):")
|
||||
print("="*80)
|
||||
print("=" * 80)
|
||||
print(full_response)
|
||||
print("="*80 + "\n")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# Verify we got a response (xfail if LLM didn't produce output)
|
||||
if len(full_response) < 100:
|
||||
@@ -384,7 +387,7 @@ async def test_tatlock_markdown_formatting_preserved(async_client: AsyncClient):
|
||||
pytest.xfail("No markdown code blocks in response (LLM response varied)")
|
||||
|
||||
# Verify newlines are preserved (not all collapsed to spaces)
|
||||
newline_count = full_response.count('\n')
|
||||
newline_count = full_response.count("\n")
|
||||
if newline_count < 5:
|
||||
pytest.xfail(f"Only {newline_count} newlines, formatting may have been lost")
|
||||
|
||||
@@ -412,7 +415,7 @@ def test_tatlock_markdown_non_streaming(client: TestClient):
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"input": [{"role": "user", "content": "Give me a simple Python hello world code"}],
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data, timeout=30.0)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Tests for models listing router.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -39,10 +40,10 @@ def test_list_models(client: TestClient) -> None:
|
||||
def test_models_endpoint_returns_json(client: TestClient) -> None:
|
||||
"""Test that models endpoint returns valid JSON."""
|
||||
response = client.get("/v1/models")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
|
||||
|
||||
# Should be able to parse as JSON
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
@@ -6,7 +6,9 @@ Tests:
|
||||
- Stop sequence detection and enforcement
|
||||
- Max tokens enforcement
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
@@ -15,11 +17,11 @@ from pydantic import ValidationError
|
||||
from src.responses.schemas import ResponseRequest
|
||||
from src.responses.streaming import StreamingCoordinator
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Parameter Validation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_temperature_validation():
|
||||
"""Test temperature parameter validation."""
|
||||
@@ -27,9 +29,7 @@ def test_temperature_validation():
|
||||
valid_temps = [0.0, 0.5, 1.0, 1.5, 2.0]
|
||||
for temp in valid_temps:
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
temperature=temp
|
||||
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], temperature=temp
|
||||
)
|
||||
assert request.temperature == temp
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_temperature_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
temperature=-0.1 # Too low
|
||||
temperature=-0.1, # Too low
|
||||
)
|
||||
assert "temperature" in str(exc_info.value).lower()
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_temperature_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
temperature=2.1 # Too high
|
||||
temperature=2.1, # Too high
|
||||
)
|
||||
assert "temperature" in str(exc_info.value).lower()
|
||||
|
||||
@@ -55,12 +55,12 @@ def test_temperature_validation():
|
||||
def test_reasoning_effort_validation():
|
||||
"""Test reasoning.effort parameter validation."""
|
||||
# Valid effort levels
|
||||
valid_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
|
||||
valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
for effort in valid_efforts:
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
reasoning={"effort": effort, "summary": "auto"}
|
||||
reasoning={"effort": effort, "summary": "auto"},
|
||||
)
|
||||
assert request.reasoning["effort"] == effort
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_reasoning_effort_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
reasoning={"effort": "invalid", "summary": "auto"}
|
||||
reasoning={"effort": "invalid", "summary": "auto"},
|
||||
)
|
||||
assert "reasoning.effort" in str(exc_info.value)
|
||||
|
||||
@@ -78,12 +78,12 @@ def test_reasoning_effort_validation():
|
||||
def test_reasoning_summary_validation():
|
||||
"""Test reasoning.summary parameter validation."""
|
||||
# Valid summary values
|
||||
valid_summaries = ['auto', 'off']
|
||||
valid_summaries = ["auto", "off"]
|
||||
for summary in valid_summaries:
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
reasoning={"effort": "medium", "summary": summary}
|
||||
reasoning={"effort": "medium", "summary": summary},
|
||||
)
|
||||
assert request.reasoning["summary"] == summary
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_reasoning_summary_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
reasoning={"effort": "medium", "summary": "invalid"}
|
||||
reasoning={"effort": "medium", "summary": "invalid"},
|
||||
)
|
||||
assert "reasoning.summary" in str(exc_info.value)
|
||||
|
||||
@@ -102,26 +102,20 @@ def test_max_output_tokens_validation():
|
||||
"""Test max_output_tokens parameter validation."""
|
||||
# Valid values
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
max_output_tokens=100
|
||||
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=100
|
||||
)
|
||||
assert request.max_output_tokens == 100
|
||||
|
||||
# None is valid (unlimited)
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
max_output_tokens=None
|
||||
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=None
|
||||
)
|
||||
assert request.max_output_tokens is None
|
||||
|
||||
# Invalid: zero or negative
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
max_output_tokens=0
|
||||
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=0
|
||||
)
|
||||
assert "max_output_tokens" in str(exc_info.value)
|
||||
|
||||
@@ -129,7 +123,7 @@ def test_max_output_tokens_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
max_output_tokens=-10
|
||||
max_output_tokens=-10,
|
||||
)
|
||||
assert "max_output_tokens" in str(exc_info.value)
|
||||
|
||||
@@ -141,9 +135,7 @@ def test_stop_sequences_validation():
|
||||
for num_seqs in range(1, 5):
|
||||
stop_seqs = [f"stop{i}" for i in range(num_seqs)]
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
stop=stop_seqs
|
||||
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], stop=stop_seqs
|
||||
)
|
||||
assert request.stop == stop_seqs
|
||||
|
||||
@@ -152,7 +144,7 @@ def test_stop_sequences_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
stop=["stop1", "stop2", "stop3", "stop4", "stop5"] # 5 sequences
|
||||
stop=["stop1", "stop2", "stop3", "stop4", "stop5"], # 5 sequences
|
||||
)
|
||||
assert "4 stop sequences" in str(exc_info.value)
|
||||
|
||||
@@ -161,7 +153,7 @@ def test_stop_sequences_validation():
|
||||
ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
stop=["stop1", ""] # Empty string
|
||||
stop=["stop1", ""], # Empty string
|
||||
)
|
||||
assert "non-empty" in str(exc_info.value).lower()
|
||||
|
||||
@@ -170,6 +162,7 @@ def test_stop_sequences_validation():
|
||||
# Stop Sequence Enforcement Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stop_sequence_detection_helper():
|
||||
"""Test stop sequence detection helper method."""
|
||||
@@ -181,26 +174,17 @@ def test_stop_sequence_detection_helper():
|
||||
assert text == "Hello world"
|
||||
|
||||
# Stop sequence not present
|
||||
found, text = coordinator._check_stop_sequence(
|
||||
"Hello world",
|
||||
["STOP", "END"]
|
||||
)
|
||||
found, text = coordinator._check_stop_sequence("Hello world", ["STOP", "END"])
|
||||
assert found is False
|
||||
assert text == "Hello world"
|
||||
|
||||
# Stop sequence found
|
||||
found, text = coordinator._check_stop_sequence(
|
||||
"Hello STOP this should not appear",
|
||||
["STOP"]
|
||||
)
|
||||
found, text = coordinator._check_stop_sequence("Hello STOP this should not appear", ["STOP"])
|
||||
assert found is True
|
||||
assert text == "Hello "
|
||||
|
||||
# Multiple stop sequences, first one wins
|
||||
found, text = coordinator._check_stop_sequence(
|
||||
"Hello STOP this END that",
|
||||
["STOP", "END"]
|
||||
)
|
||||
found, text = coordinator._check_stop_sequence("Hello STOP this END that", ["STOP", "END"])
|
||||
assert found is True
|
||||
assert text == "Hello "
|
||||
|
||||
@@ -216,7 +200,7 @@ async def test_stop_sequence_in_streaming(async_client: AsyncClient):
|
||||
"model": "lorem-tester",
|
||||
"input": [{"role": "user", "content": "Generate long text"}],
|
||||
"stop": ["dolor"], # Common word in lorem ipsum
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunks_received = []
|
||||
@@ -251,6 +235,7 @@ async def test_stop_sequence_in_streaming(async_client: AsyncClient):
|
||||
# Max Tokens Enforcement Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_max_tokens_check_helper():
|
||||
"""Test max tokens check helper method."""
|
||||
@@ -296,7 +281,7 @@ async def test_max_tokens_in_streaming(async_client: AsyncClient):
|
||||
"model": "lorem-tester",
|
||||
"input": [{"role": "user", "content": "Generate text"}],
|
||||
"max_output_tokens": 5, # Very low limit
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
chunks_received = []
|
||||
@@ -340,6 +325,7 @@ async def test_max_tokens_in_streaming(async_client: AsyncClient):
|
||||
# Combined Features Test
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_combined_validation(client: TestClient):
|
||||
"""Test combined parameter validation in actual request."""
|
||||
@@ -351,7 +337,7 @@ def test_combined_validation(client: TestClient):
|
||||
"max_output_tokens": 100,
|
||||
"stop": ["STOP", "END"],
|
||||
"reasoning": {"effort": "high", "summary": "auto"},
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -368,7 +354,7 @@ def test_invalid_combined_parameters(client: TestClient):
|
||||
"model": "lorem-tester",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 3.0, # Too high
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -382,6 +368,7 @@ def test_invalid_combined_parameters(client: TestClient):
|
||||
# Streaming Delta Calculation Tests (No Duplication)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_delta_calculation_no_duplication():
|
||||
@@ -392,8 +379,10 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
This test prevents the duplication bug where the same text was
|
||||
streamed multiple times because we weren't computing deltas correctly.
|
||||
"""
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from src.agents.base import AgentInterface, OutputItem
|
||||
from typing import AsyncGenerator, Any
|
||||
|
||||
# Create a mock agent that simulates PydanticAI's behavior
|
||||
# (yielding accumulated text, not deltas)
|
||||
@@ -406,7 +395,7 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int | None = None,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
"""
|
||||
Simulate PydanticAI streaming behavior:
|
||||
@@ -430,12 +419,8 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
type="message",
|
||||
id=msg_id,
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
}],
|
||||
status="in_progress"
|
||||
content=[{"type": "output_text", "text": text, "annotations": []}],
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
# Final message
|
||||
@@ -443,12 +428,10 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
type="message",
|
||||
id=msg_id,
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": "Hello world how are you",
|
||||
"annotations": []
|
||||
}],
|
||||
status="completed"
|
||||
content=[
|
||||
{"type": "output_text", "text": "Hello world how are you", "annotations": []}
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
|
||||
async def supports_tools(self) -> bool:
|
||||
@@ -462,7 +445,9 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
|
||||
# Register the mock agent
|
||||
import time
|
||||
|
||||
from src.agents.registry import ModelRegistry
|
||||
|
||||
ModelRegistry.MODELS["mock-streaming"] = {
|
||||
"agent_class": MockStreamingAgent,
|
||||
"description": "Mock streaming agent for testing",
|
||||
@@ -473,9 +458,7 @@ async def test_streaming_delta_calculation_no_duplication():
|
||||
try:
|
||||
# Create a test request
|
||||
request = ResponseRequest(
|
||||
model="mock-streaming",
|
||||
input=[{"role": "user", "content": "Test"}],
|
||||
stream=True
|
||||
model="mock-streaming", input=[{"role": "user", "content": "Test"}], stream=True
|
||||
)
|
||||
|
||||
# Stream the response
|
||||
@@ -512,8 +495,10 @@ async def test_streaming_with_multiple_message_items():
|
||||
Test that coordinator handles multiple message OutputItems correctly,
|
||||
only streaming the delta between each one.
|
||||
"""
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from src.agents.base import AgentInterface, OutputItem
|
||||
from typing import AsyncGenerator, Any
|
||||
|
||||
class MockMultiMessageAgent(AgentInterface):
|
||||
async def generate_response(
|
||||
@@ -524,7 +509,7 @@ async def test_streaming_with_multiple_message_items():
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int | None = None,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
"""Yield multiple in_progress messages with accumulated text."""
|
||||
# First chunk
|
||||
@@ -533,7 +518,7 @@ async def test_streaming_with_multiple_message_items():
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
content=[{"type": "output_text", "text": "The answer is", "annotations": []}],
|
||||
status="in_progress"
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
# Second chunk (more text accumulated)
|
||||
@@ -542,7 +527,7 @@ async def test_streaming_with_multiple_message_items():
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
content=[{"type": "output_text", "text": "The answer is 42", "annotations": []}],
|
||||
status="in_progress"
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
# Final chunk
|
||||
@@ -551,7 +536,7 @@ async def test_streaming_with_multiple_message_items():
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
content=[{"type": "output_text", "text": "The answer is 42", "annotations": []}],
|
||||
status="completed"
|
||||
status="completed",
|
||||
)
|
||||
|
||||
async def supports_tools(self) -> bool:
|
||||
@@ -565,7 +550,9 @@ async def test_streaming_with_multiple_message_items():
|
||||
|
||||
# Register mock agent
|
||||
import time
|
||||
|
||||
from src.agents.registry import ModelRegistry
|
||||
|
||||
ModelRegistry.MODELS["mock-multi"] = {
|
||||
"agent_class": MockMultiMessageAgent,
|
||||
"description": "Mock multi-message agent for testing",
|
||||
@@ -577,7 +564,7 @@ async def test_streaming_with_multiple_message_items():
|
||||
request = ResponseRequest(
|
||||
model="mock-multi",
|
||||
input=[{"role": "user", "content": "What is the answer?"}],
|
||||
stream=True
|
||||
stream=True,
|
||||
)
|
||||
|
||||
coordinator = StreamingCoordinator()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
Tests for error handling in Responses API.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
@@ -12,10 +14,8 @@ def test_model_not_found_error(client: TestClient) -> None:
|
||||
"""Test response when model doesn't exist."""
|
||||
request_data = {
|
||||
"model": "nonexistent-model-12345",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -29,11 +29,7 @@ def test_model_not_found_error(client: TestClient) -> None:
|
||||
@pytest.mark.unit
|
||||
def test_validation_error_missing_model(client: TestClient) -> None:
|
||||
"""Test validation error when model field is missing."""
|
||||
request_data = {
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
}
|
||||
request_data = {"input": [{"role": "user", "content": "Hello"}]}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
|
||||
@@ -45,10 +41,8 @@ def test_validation_error_invalid_temperature(client: TestClient) -> None:
|
||||
"""Test validation error for out-of-range temperature."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"temperature": 3.0 # Max is 2.0
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 3.0, # Max is 2.0
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -61,10 +55,8 @@ def test_rate_limit_error(client: TestClient) -> None:
|
||||
"""Test rate limit error trigger."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "trigger_rate_limit"}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "trigger_rate_limit"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -78,10 +70,8 @@ def test_context_overflow_error(client: TestClient) -> None:
|
||||
"""Test context length overflow error trigger."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "trigger_context_overflow"}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "trigger_context_overflow"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -96,10 +86,8 @@ async def test_streaming_model_not_found(async_client: AsyncClient) -> None:
|
||||
"""Test streaming response with nonexistent model."""
|
||||
request_data = {
|
||||
"model": "nonexistent-streaming-model",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"stream": True
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with async_client.stream(
|
||||
@@ -133,10 +121,8 @@ async def test_streaming_rate_limit_error(async_client: AsyncClient) -> None:
|
||||
"""Test streaming with rate limit error."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "trigger_rate_limit"}
|
||||
],
|
||||
"stream": True
|
||||
"input": [{"role": "user", "content": "trigger_rate_limit"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with async_client.stream(
|
||||
@@ -177,10 +163,8 @@ async def test_streaming_context_overflow_error(async_client: AsyncClient) -> No
|
||||
"""Test streaming with context overflow error."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "trigger_context_overflow"}
|
||||
],
|
||||
"stream": True
|
||||
"input": [{"role": "user", "content": "trigger_context_overflow"}],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with async_client.stream(
|
||||
@@ -215,17 +199,9 @@ def test_function_call_output_item(client: TestClient) -> None:
|
||||
"""Test response with function call items."""
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "Use the search tool"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search for information",
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "Use the search tool"}],
|
||||
"tools": [{"name": "search", "description": "Search for information", "parameters": {}}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -248,10 +224,8 @@ def test_pipeline_prefix_stripping(client: TestClient) -> None:
|
||||
"""Test that pipeline prefixes are stripped from model names."""
|
||||
request_data = {
|
||||
"model": "some_pipeline.lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -267,10 +241,8 @@ def test_multiple_pipeline_prefixes(client: TestClient) -> None:
|
||||
"""Test multiple dots in model name (only first is prefix)."""
|
||||
request_data = {
|
||||
"model": "pipeline.sub.lorem-tester",
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
"stream": False
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
Tests for conversation history management.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.responses.history import ConversationHistory
|
||||
from src.responses.context import ContextWindow
|
||||
from src.responses.schemas import ResponseRequest
|
||||
from src.responses import service
|
||||
from src.responses.context import ContextWindow
|
||||
from src.responses.history import ConversationHistory
|
||||
from src.responses.schemas import ResponseRequest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -19,7 +20,7 @@ async def test_conversation_id_from_metadata():
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
metadata={"conversation_id": "conv_123"}
|
||||
metadata={"conversation_id": "conv_123"},
|
||||
)
|
||||
|
||||
conv_id = await history.get_conversation_id(request)
|
||||
@@ -34,7 +35,7 @@ async def test_conversation_id_generation():
|
||||
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}]
|
||||
input=[{"role": "user", "content": "Hello"}],
|
||||
# No metadata provided
|
||||
)
|
||||
|
||||
@@ -43,10 +44,7 @@ async def test_conversation_id_generation():
|
||||
assert len(conv_id) == 16 # 16 character hex
|
||||
|
||||
# Same first message should generate same ID
|
||||
request2 = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
request2 = ResponseRequest(model="lorem-tester", input=[{"role": "user", "content": "Hello"}])
|
||||
conv_id2 = await history.get_conversation_id(request2)
|
||||
assert conv_id == conv_id2
|
||||
|
||||
@@ -59,7 +57,7 @@ def test_conversation_history_tracking(client: TestClient):
|
||||
"model": "lorem-tester",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {"conversation_id": "test_conv_001"},
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = client.post("/v1/responses", json=request_data)
|
||||
@@ -71,10 +69,10 @@ def test_conversation_history_tracking(client: TestClient):
|
||||
"input": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"}
|
||||
{"role": "user", "content": "How are you?"},
|
||||
],
|
||||
"metadata": {"conversation_id": "test_conv_001"},
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response2 = client.post("/v1/responses", json=request_data2)
|
||||
@@ -94,7 +92,7 @@ async def test_conversation_history_retrieval():
|
||||
request = ResponseRequest(
|
||||
model="lorem-tester",
|
||||
input=[{"role": "user", "content": "Test"}],
|
||||
metadata={"conversation_id": "test_retrieve"}
|
||||
metadata={"conversation_id": "test_retrieve"},
|
||||
)
|
||||
|
||||
conv_id = await history.get_conversation_id(request)
|
||||
@@ -104,24 +102,15 @@ async def test_conversation_history_retrieval():
|
||||
assert len(retrieved) == 0
|
||||
|
||||
# Add mock response
|
||||
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
|
||||
from src.responses.schemas import MessageOutputItem, OutputTextContent, Response, ResponseUsage
|
||||
|
||||
mock_response = Response(
|
||||
id="resp_123",
|
||||
created_at=1234567890,
|
||||
model="lorem-tester",
|
||||
status="completed",
|
||||
output=[
|
||||
MessageOutputItem(
|
||||
id="msg_1",
|
||||
content=[OutputTextContent(text="Test response")]
|
||||
)
|
||||
],
|
||||
usage=ResponseUsage(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
reasoning_tokens=0,
|
||||
total_tokens=15
|
||||
)
|
||||
output=[MessageOutputItem(id="msg_1", content=[OutputTextContent(text="Test response")])],
|
||||
usage=ResponseUsage(input_tokens=10, output_tokens=5, reasoning_tokens=0, total_tokens=15),
|
||||
)
|
||||
|
||||
await history.add_response(conv_id, mock_response)
|
||||
@@ -138,7 +127,7 @@ async def test_conversation_history_trimming():
|
||||
"""Test that history is trimmed to max_turns."""
|
||||
history = ConversationHistory(max_turns=3)
|
||||
|
||||
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
|
||||
from src.responses.schemas import MessageOutputItem, OutputTextContent, Response, ResponseUsage
|
||||
|
||||
conv_id = "test_trim"
|
||||
|
||||
@@ -150,17 +139,11 @@ async def test_conversation_history_trimming():
|
||||
model="lorem-tester",
|
||||
status="completed",
|
||||
output=[
|
||||
MessageOutputItem(
|
||||
id=f"msg_{i}",
|
||||
content=[OutputTextContent(text=f"Response {i}")]
|
||||
)
|
||||
MessageOutputItem(id=f"msg_{i}", content=[OutputTextContent(text=f"Response {i}")])
|
||||
],
|
||||
usage=ResponseUsage(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
reasoning_tokens=0,
|
||||
total_tokens=15
|
||||
)
|
||||
input_tokens=10, output_tokens=5, reasoning_tokens=0, total_tokens=15
|
||||
),
|
||||
)
|
||||
await history.add_response(conv_id, response)
|
||||
|
||||
@@ -178,7 +161,7 @@ async def test_clear_conversation():
|
||||
"""Test clearing conversation history."""
|
||||
history = ConversationHistory()
|
||||
|
||||
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
|
||||
from src.responses.schemas import MessageOutputItem, OutputTextContent, Response, ResponseUsage
|
||||
|
||||
conv_id = "test_clear"
|
||||
|
||||
@@ -188,18 +171,8 @@ async def test_clear_conversation():
|
||||
created_at=1234567890,
|
||||
model="lorem-tester",
|
||||
status="completed",
|
||||
output=[
|
||||
MessageOutputItem(
|
||||
id="msg_1",
|
||||
content=[OutputTextContent(text="Test")]
|
||||
)
|
||||
],
|
||||
usage=ResponseUsage(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
reasoning_tokens=0,
|
||||
total_tokens=15
|
||||
)
|
||||
output=[MessageOutputItem(id="msg_1", content=[OutputTextContent(text="Test")])],
|
||||
usage=ResponseUsage(input_tokens=10, output_tokens=5, reasoning_tokens=0, total_tokens=15),
|
||||
)
|
||||
await history.add_response(conv_id, response)
|
||||
|
||||
@@ -227,7 +200,7 @@ async def test_conversation_count():
|
||||
initial_count = await history.get_conversation_count()
|
||||
|
||||
# Add responses to 3 different conversations
|
||||
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
|
||||
from src.responses.schemas import MessageOutputItem, OutputTextContent, Response, ResponseUsage
|
||||
|
||||
for i in range(3):
|
||||
response = Response(
|
||||
@@ -235,18 +208,10 @@ async def test_conversation_count():
|
||||
created_at=1234567890,
|
||||
model="lorem-tester",
|
||||
status="completed",
|
||||
output=[
|
||||
MessageOutputItem(
|
||||
id=f"msg_{i}",
|
||||
content=[OutputTextContent(text="Test")]
|
||||
)
|
||||
],
|
||||
output=[MessageOutputItem(id=f"msg_{i}", content=[OutputTextContent(text="Test")])],
|
||||
usage=ResponseUsage(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
reasoning_tokens=0,
|
||||
total_tokens=15
|
||||
)
|
||||
input_tokens=10, output_tokens=5, reasoning_tokens=0, total_tokens=15
|
||||
),
|
||||
)
|
||||
await history.add_response(f"conv_{i}", response)
|
||||
|
||||
@@ -281,8 +246,8 @@ async def test_context_window_trimming():
|
||||
# Create items that exceed limit
|
||||
items = [
|
||||
"This is a long message " * 20, # ~480 chars = ~120 tokens
|
||||
"Another message " * 10, # ~160 chars = ~40 tokens
|
||||
"Short message" # ~13 chars = ~3 tokens
|
||||
"Another message " * 10, # ~160 chars = ~40 tokens
|
||||
"Short message", # ~13 chars = ~3 tokens
|
||||
]
|
||||
|
||||
# Trim with 10 token reserve
|
||||
@@ -355,14 +320,12 @@ async def test_service_conversation_helpers():
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_tracks_history(async_client):
|
||||
"""Test that streaming responses also track conversation history."""
|
||||
from httpx import AsyncClient
|
||||
import json
|
||||
|
||||
request_data = {
|
||||
"model": "lorem-tester",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {"conversation_id": "stream_test_001"},
|
||||
"stream": True
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with async_client.stream(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user