fix(agents): make librarian failures structured and user-safe

- run_librarian / run_librarian_stream raise AgentError instead of
  returning/yielding error text as normal output; detail stays in logs
- delegate_to_* wrappers now put a curated butler-toned sentence in
  DelegationResult.output on failure and never expose str(e), so
  streaming's error branch is reachable and honest
- _execute_single_delegation propagates success; direct delegation only
  records delegate_to_* as called when the expert actually succeeded
- librarian tools return user-safe messages instead of
  'Error searching: {e}' strings that leaked internal URLs into
  synthesis; coordination stream errors are curated as well
- ruff cleanups (TYPE_CHECKING forward refs, B904, unused locals) in
  the touched files to keep them lint-clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:12:30 +02:00
co-authored by Claude Fable 5
parent 59f5b54ac9
commit f853db8ccc
10 changed files with 277 additions and 140 deletions
+15 -11
View File
@@ -10,7 +10,8 @@ based on Steward recommendations. Handles:
"""
import asyncio
import time
from typing import Any, AsyncGenerator, Optional
from collections.abc import AsyncGenerator
from typing import Any
from src.agents.librarian import run_librarian, run_librarian_stream
from src.agents.protocol import (
@@ -22,7 +23,6 @@ from src.agents.protocol import (
CoordinationResult,
DelegationIntent,
DelegationReason,
ToolCallRecord,
)
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
@@ -87,7 +87,7 @@ class CoordinationEngine:
self,
intent: DelegationIntent,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> AgentResponse:
"""
Execute a single delegation to an expert agent.
@@ -166,7 +166,7 @@ class CoordinationEngine:
duration_ms=duration_ms,
)
except asyncio.TimeoutError:
except TimeoutError as e:
duration_ms = int((time.time() - start_time) * 1000)
logger.error(
"delegation_timeout",
@@ -176,7 +176,7 @@ class CoordinationEngine:
raise AgentTimeoutError(
f"Agent '{agent_name}' timed out after {duration_ms}ms",
agent_name=agent_name,
)
) from e
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
@@ -198,7 +198,7 @@ class CoordinationEngine:
self,
intent: DelegationIntent,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> AsyncGenerator[str, None]:
"""
Execute a delegation with streaming output.
@@ -242,19 +242,23 @@ class CoordinationEngine:
logger.info("delegation_stream_completed", agent=agent_name)
except Exception as e:
# Exception detail stays in the logs; yield a curated
# user-safe sentence instead of leaking internals.
from src.agents.delegation import get_think_message
logger.error(
"delegation_stream_error",
agent=agent_name,
error=str(e),
exc_info=True,
)
yield f"\n\n[Error from {agent_name}: {str(e)}]"
yield "\n\n" + get_think_message(agent_name, intent.task, "error")
async def coordinate(
self,
intents: list[DelegationIntent],
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> CoordinationResult:
"""
Coordinate execution of multiple delegations.
@@ -329,7 +333,7 @@ class CoordinationEngine:
# Global coordination engine instance
_coordination_engine: Optional[CoordinationEngine] = None
_coordination_engine: CoordinationEngine | None = None
def get_coordination_engine() -> CoordinationEngine:
@@ -344,7 +348,7 @@ async def delegate_to_librarian(
task: str,
context: str = "",
reason: DelegationReason = DelegationReason.DOMAIN_EXPERTISE,
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> AgentResponse:
"""
Convenience function to delegate a task to The Librarian.
@@ -377,7 +381,7 @@ async def delegate_to_librarian(
async def delegate_to_librarian_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> AsyncGenerator[str, None]:
"""
Convenience function to delegate to Librarian with streaming.
+19 -12
View File
@@ -8,12 +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.
"""
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from enum import Enum
from typing import AsyncGenerator, Callable, Optional, Any
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__)
@@ -167,7 +167,7 @@ class DelegationTask:
action: str = ""
priority: int = 0
depends_on: list[str] = field(default_factory=list)
result: Optional[str] = None
result: str | None = None
task_id: str = ""
def __post_init__(self):
@@ -186,14 +186,16 @@ class DelegationResult:
expert_name: Which expert handled the task
task: Original task description
success: Whether the delegation succeeded
output: Expert's response/findings
error: Error message if failed
output: Expert's response/findings. On failure this holds a
curated, user-safe butler sentence (never exception detail)
error: Short user-safe error label if failed. Exception detail
stays in the logs only
"""
expert_name: str
task: str
success: bool
output: str
error: Optional[str] = None
error: str | None = None
async def delegate_to_librarian(
@@ -285,12 +287,15 @@ async def delegate_to_librarian(
span.metadata["success"] = False
span.details["error"] = str(e)
# Exception detail stays in the logs; the user-facing output
# is a curated butler sentence so internals never leak into
# synthesis.
return DelegationResult(
expert_name="librarian",
task=task,
success=False,
output="",
error=str(e),
output=get_think_message("librarian", task, "error"),
error="The Librarian was unable to complete the task.",
)
@@ -383,12 +388,13 @@ async def delegate_to_biographer(
span.metadata["success"] = False
span.details["error"] = str(e)
# Exception detail stays in the logs only.
return DelegationResult(
expert_name="biographer",
task=task,
success=False,
output="",
error=str(e),
output=get_think_message("biographer", task, "error"),
error="The Biographer was unable to complete the task.",
)
@@ -480,12 +486,13 @@ async def delegate_to_housekeeper(
span.metadata["success"] = False
span.details["error"] = str(e)
# Exception detail stays in the logs only.
return DelegationResult(
expert_name="housekeeper",
task=task,
success=False,
output="",
error=str(e),
output=get_think_message("housekeeper", task, "error"),
error="The Housekeeper was unable to complete the task.",
)
+23 -7
View File
@@ -7,7 +7,7 @@ the library-desk API, offering:
- Wiki and document management
- Semantic search and knowledge graph exploration
"""
from typing import Any, Optional
from typing import Any
from pydantic_ai import Agent
@@ -27,7 +27,7 @@ from src.agents.librarian.tools import (
smart_create_wiki_page,
update_wiki_page,
)
from src.core.config import config
from src.agents.protocol import AgentError
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -138,7 +138,7 @@ If a tool fails or you cannot access a data source:
"""
# Lazy initialization to avoid connection issues during imports
_librarian_agent: Optional[Agent[None, str]] = None
_librarian_agent: Agent[None, str] | None = None
def _create_librarian_agent() -> Agent[None, str]:
@@ -204,7 +204,7 @@ def get_librarian_agent() -> Agent[None, str]:
async def run_librarian(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
) -> str:
"""
Execute a research task with The Librarian.
@@ -220,6 +220,10 @@ async def run_librarian(
Returns:
Research results and findings
Raises:
AgentError: If the research task fails. Exception detail is
logged here; callers map the failure to a user-safe message.
Example:
result = await run_librarian(
task="Find information about Docker networking",
@@ -255,19 +259,23 @@ async def run_librarian(
return result.output
except Exception as e:
# Full detail stays in the logs; callers receive a structured
# failure instead of error text masquerading as research output.
logger.error(
"librarian_task_error",
task=task[:50],
error=str(e),
exc_info=True,
)
return f"The Librarian encountered an error: {str(e)}"
raise AgentError(
"Research task failed", agent_name="librarian"
) from e
async def run_librarian_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
message_history: list[Any] | None = None,
):
"""
Execute a research task with streaming output.
@@ -282,6 +290,10 @@ async def run_librarian_stream(
Yields:
str: Text deltas from the response
Raises:
AgentError: If the research task fails. Exception detail is
logged here; callers map the failure to a user-safe message.
Example:
async for delta in run_librarian_stream("Find Docker docs"):
print(delta, end="", flush=True)
@@ -309,10 +321,14 @@ async def run_librarian_stream(
logger.info("librarian_stream_completed", task=task[:50])
except Exception as e:
# Full detail stays in the logs; raise instead of yielding error
# text into the stream as if it were research output.
logger.error(
"librarian_stream_error",
task=task[:50],
error=str(e),
exc_info=True,
)
yield f"\n\nThe Librarian encountered an error: {str(e)}"
raise AgentError(
"Research task failed", agent_name="librarian"
) from e
+14 -14
View File
@@ -111,7 +111,7 @@ async def hybrid_search(
except Exception as e:
logger.error("librarian_hybrid_search_error", error=str(e), query=query)
return f"Error searching: {str(e)}"
return "I was unable to search the knowledge archives; the search service did not respond properly."
# ============================================================================
@@ -159,7 +159,7 @@ async def search_wiki(
except Exception as e:
logger.error("librarian_wiki_search_error", error=str(e))
return f"Error searching wiki: {str(e)}"
return "I was unable to search the wiki at this time."
async def get_wiki_page(
@@ -202,7 +202,7 @@ async def get_wiki_page(
except Exception as e:
logger.error("librarian_get_page_error", error=str(e), page_id=page_id)
return f"Error getting page {page_id}: {str(e)}"
return f"I was unable to retrieve wiki page {page_id}."
async def list_dossiers() -> str:
@@ -236,7 +236,7 @@ async def list_dossiers() -> str:
except Exception as e:
logger.error("librarian_list_dossiers_error", error=str(e))
return f"Error listing dossiers: {str(e)}"
return "I was unable to retrieve the list of dossiers."
async def get_dossier_pages(
@@ -277,7 +277,7 @@ async def get_dossier_pages(
except Exception as e:
logger.error("librarian_get_dossier_error", error=str(e))
return f"Error getting dossier: {str(e)}"
return f"I was unable to retrieve the dossier '{dossier_name}'."
# ============================================================================
@@ -326,7 +326,7 @@ async def semantic_search(
except Exception as e:
logger.error("librarian_semantic_search_error", error=str(e))
return f"Error in semantic search: {str(e)}"
return "I was unable to complete the semantic search."
# ============================================================================
@@ -379,7 +379,7 @@ async def explore_knowledge_graph(
except Exception as e:
logger.error("librarian_explore_graph_error", error=str(e))
return f"Error exploring knowledge graph: {str(e)}"
return "I was unable to explore the knowledge graph."
async def find_related_entities(
@@ -450,7 +450,7 @@ async def find_related_entities(
except Exception as e:
logger.error("librarian_find_related_error", error=str(e))
return f"Error finding related entities: {str(e)}"
return f"I was unable to look up entities related to '{entity_name}'."
# ============================================================================
@@ -533,7 +533,7 @@ async def search_web(
except Exception as e:
logger.error("librarian_web_search_error", error=str(e), query=query)
return f"Error searching web: {str(e)}"
return "I was unable to search the web at this time."
async def read_url(
@@ -609,7 +609,7 @@ async def read_url(
except Exception as e:
logger.error("librarian_read_url_error", error=str(e), url=url)
return f"Error reading URL: {str(e)}"
return f"I was unable to read the page at {url}."
async def read_urls_batch(
@@ -683,7 +683,7 @@ async def read_urls_batch(
except Exception as e:
logger.error("librarian_read_urls_batch_error", error=str(e))
return f"Error reading URLs: {str(e)}"
return "I was unable to read the requested pages."
# ============================================================================
@@ -766,7 +766,7 @@ async def update_wiki_page(
except Exception as e:
logger.error("librarian_update_page_error", error=str(e), page_id=page_id)
return f"Error updating page {page_id}: {str(e)}"
return f"I was unable to update wiki page {page_id}."
async def create_wiki_page(
@@ -841,7 +841,7 @@ async def create_wiki_page(
except Exception as e:
logger.error("librarian_create_page_error", error=str(e), title=title)
return f"Error creating page: {str(e)}"
return f"I was unable to create the page '{title}'."
async def smart_create_wiki_page(
@@ -930,7 +930,7 @@ async def smart_create_wiki_page(
except Exception as e:
logger.error("librarian_smart_create_error", error=str(e), topic=topic)
return f"Error creating page about '{topic}': {str(e)}"
return f"I was unable to create a page about '{topic}'."
# ============================================================================