feat(agents): enforce one librarian timeout budget

- add LIBRARIAN_TIMEOUT config (default 180s) and enforce it with
  asyncio.wait_for inside delegate_to_librarian, covering the live
  paths (steward direct delegation and SSE streaming) that had no cap
- timeouts fail honestly: success=False with a curated butler sentence,
  detail in logs
- set an explicit timeout on TatlockOllamaProvider's AsyncOpenAI client
  from OLLAMA_TIMEOUT instead of the SDK default (~600s per LLM call)
- remove the contradictory unused 60s default from
  AgentRequest.timeout_seconds; coordination falls back to the
  configured budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:19:16 +02:00
co-authored by Claude Fable 5
parent 99e1fe33ca
commit 18f2e0efbd
9 changed files with 145 additions and 18 deletions
+3 -2
View File
@@ -24,6 +24,7 @@ from src.agents.protocol import (
DelegationIntent,
DelegationReason,
)
from src.core.config import config
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
@@ -138,8 +139,8 @@ class CoordinationEngine:
delegation_reason=intent.reason,
)
# Execute with timeout
timeout = request.timeout_seconds or 60
# Execute with timeout (explicit request value or configured budget)
timeout = request.timeout_seconds or config.LIBRARIAN_TIMEOUT
result = await asyncio.wait_for(
executor(
+34 -2
View File
@@ -8,10 +8,12 @@ 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 collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from enum import Enum
from src.core.config import config
from src.core.logging_config import get_logger
from src.core.tracing import SpanType, trace_span
@@ -252,8 +254,14 @@ async def delegate_to_librarian(
},
) as span:
try:
# Use run() not run_stream() - avoids Ollama bug
output = await run_librarian(task=task, context=context)
# Use run() not run_stream() - avoids Ollama bug.
# One timeout budget for the whole delegation - covers both
# live paths (steward direct delegation and streaming), which
# previously had no cap at all (SDK default ~600s per LLM call).
output = await asyncio.wait_for(
run_librarian(task=task, context=context),
timeout=config.LIBRARIAN_TIMEOUT,
)
logger.info(
"delegation_to_librarian_completed",
@@ -275,6 +283,30 @@ async def delegate_to_librarian(
output=output,
)
except TimeoutError:
logger.error(
"delegation_to_librarian_timeout",
task=task[:50],
timeout_seconds=config.LIBRARIAN_TIMEOUT,
)
if span:
span.metadata["success"] = False
span.details["error"] = (
f"timed out after {config.LIBRARIAN_TIMEOUT}s"
)
return DelegationResult(
expert_name="librarian",
task=task,
success=False,
output=(
"I'm afraid the research took longer than expected "
"and had to be abandoned, sir."
),
error="The Librarian did not respond within the time budget.",
)
except Exception as e:
logger.error(
"delegation_to_librarian_error",
+9 -6
View File
@@ -6,7 +6,7 @@ Defines standardized request/response formats for communication between:
- Tatlock (coordination) → Expert agents (Librarian, Developer, etc.)
"""
from enum import Enum
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -53,13 +53,16 @@ class AgentRequest(BaseModel):
default="default",
description="User identifier for multi-tenant operations"
)
max_tokens: Optional[int] = Field(
max_tokens: int | None = Field(
default=None,
description="Optional token limit for response"
)
timeout_seconds: Optional[int] = Field(
default=60,
description="Maximum time for task completion"
timeout_seconds: int | None = Field(
default=None,
description=(
"Maximum time for task completion; None uses the configured "
"expert budget (LIBRARIAN_TIMEOUT)"
)
)
@@ -103,7 +106,7 @@ class AgentResponse(BaseModel):
default_factory=list,
description="Sources or references used"
)
error_message: Optional[str] = Field(
error_message: str | None = Field(
default=None,
description="Error details if success=False"
)
+9 -5
View File
@@ -42,7 +42,7 @@ class Environment(str, Enum):
class Config(BaseSettings):
"""
Global application configuration.
Loads from environment variables and .env file.
Domain-specific configs should be in their respective modules.
"""
@@ -52,18 +52,18 @@ class Config(BaseSettings):
case_sensitive=True,
extra="ignore",
)
# Application
APP_NAME: str = "OpenAI-Compatible API"
APP_VERSION: str = Field(default_factory=_get_version_from_pyproject)
ENVIRONMENT: Environment = Environment.DEVELOPMENT
DEBUG: bool = Field(default=False, description="Debug mode")
# API Configuration
API_HOST: str = Field(default="0.0.0.0", description="API host")
API_PORT: int = Field(default=8000, description="API port")
API_PREFIX: str = Field(default="/v1", description="API route prefix")
# Anthropic Configuration (Claude - cloud fallback)
ANTHROPIC_API_KEY: str | None = Field(
default=None,
@@ -125,6 +125,10 @@ class Config(BaseSettings):
)
# Library-Desk Configuration (The Librarian backend)
LIBRARIAN_TIMEOUT: int = Field(
default=180,
description="Total time budget for a librarian delegation in seconds"
)
LIBRARY_DESK_HOST: HttpUrl = Field(
default="http://localhost:8089",
description="Library-Desk API URL"
@@ -259,7 +263,7 @@ class Config(BaseSettings):
def get_config() -> Config:
"""
Get cached configuration instance.
Uses lru_cache to ensure config is loaded once and reused.
"""
return Config()
+9 -2
View File
@@ -40,14 +40,21 @@ class TatlockOllamaProvider(OllamaProvider):
# Override the client with our sanitized version
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
logger.debug("tatlock_ollama_provider_created", base_url=base_url)
logger.debug(
"tatlock_ollama_provider_created",
base_url=base_url,
timeout=config.OLLAMA_TIMEOUT,
)
class _SanitizedAsyncOpenAI(AsyncOpenAI):
"""AsyncOpenAI client that sanitizes messages before sending."""
def __init__(self, **kwargs: Any):
# Ollama doesn't need an API key
# Ollama doesn't need an API key. Cap each LLM call at the
# configured Ollama timeout instead of the SDK default (~600s),
# so one stuck request cannot eat the whole delegation budget.
kwargs.setdefault("timeout", float(config.OLLAMA_TIMEOUT))
super().__init__(api_key="ollama", **kwargs)
@property