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