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:
+15
-11
@@ -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
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'."
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user