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
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Honest expert failures** - `run_librarian`/`run_librarian_stream` now raise a structured `AgentError` instead of returning error text as if it were research output, so delegation correctly reports `success=False` and the streaming error branch is reachable. Failures surface to the user as curated butler-toned sentences; exception detail (including internal URLs) stays in the logs only. Librarian tool errors no longer leak `str(e)` into synthesis
- **HybridRAG response mapping** - The librarian client now parses the field names library-desk actually returns (`source_type`/`sources`, `rrf_score`, `context`, per-item `related_dossiers`, synonyms nested in the `keywords` dict); previously every result rendered as "unknown (score: 0.00)". Source icons now key off the per-item `sources` list. Requests no longer send zero limits (the service rejects them with 422); legs are disabled via `enable_*` flags. Pinned by a contract test against a recorded live response (`tests/agents/librarian/fixtures/`)
## [2.3.0] - 2026-07-13
+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}'."
# ============================================================================
+62 -45
View File
@@ -6,32 +6,32 @@ Tracks conversation history for analytics and future vector memory.
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
"""
import time
import asyncio
import re
import secrets
from typing import AsyncGenerator
import time
from collections.abc import AsyncGenerator
from src.agents.delegation import get_think_message
from src.agents.registry import ModelRegistry
from src.agents.steward.schemas import StewardRecommendation
from src.core.context import current_conversation, current_user, get_default_user
from src.core.logging_config import get_logger
from src.core.preprocessing import preprocess_request
from src.core.tool_tracking import ToolCallTracker
from src.core.tracing import SpanType, end_trace, start_span, start_trace
from src.responses.context import ContextWindow
from src.responses.history import ConversationHistory
from src.responses.schemas import (
FunctionCallOutputItem,
MessageOutputItem,
OutputTextContent,
ReasoningOutputItem,
Response,
ResponseRequest,
ResponseUsage,
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
OutputTextContent,
)
from src.responses.streaming import StreamingCoordinator
from src.responses.history import ConversationHistory
from src.responses.context import ContextWindow
from src.core.preprocessing import preprocess_request
from src.core.tool_tracking import ToolCallTracker
from src.core.logging_config import get_logger
from src.core.tracing import start_trace, end_trace, start_span, SpanType
from src.core.context import current_user, current_conversation, get_default_user
from src.agents.steward.schemas import StewardRecommendation
import re
import asyncio
logger = get_logger(__name__)
@@ -63,7 +63,7 @@ async def _execute_single_delegation(
agent_name: str,
task: str,
tracker: "ToolCallTracker",
) -> tuple[str, str]:
) -> tuple[str, str, bool]:
"""
Execute a single delegation to an agent.
@@ -73,7 +73,8 @@ async def _execute_single_delegation(
tracker: Tool call tracker
Returns:
tuple: (agent_name, result_summary)
tuple: (agent_name, result_summary, success). On failure the
result summary is a curated user-safe sentence.
"""
import time
start_time = time.time()
@@ -83,24 +84,24 @@ async def _execute_single_delegation(
result = await delegate_to_biographer(task=task)
duration = time.time() - start_time
await tracker.track_call("delegate_to_biographer", duration)
return (agent_name, result.output)
return (agent_name, result.output, result.success)
elif agent_name == "librarian":
from src.agents.delegation import delegate_to_librarian
result = await delegate_to_librarian(task=task)
duration = time.time() - start_time
await tracker.track_call("delegate_to_librarian", duration)
return (agent_name, result.output)
return (agent_name, result.output, result.success)
elif agent_name == "housekeeper":
from src.agents.delegation import delegate_to_housekeeper
result = await delegate_to_housekeeper(task=task)
duration = time.time() - start_time
await tracker.track_call("delegate_to_housekeeper", duration)
return (agent_name, result.output)
return (agent_name, result.output, result.success)
else:
return (agent_name, f"Unknown agent: {agent_name}")
return (agent_name, f"Unknown agent: {agent_name}", False)
async def _handle_text_delegation(
@@ -175,13 +176,24 @@ async def _handle_text_delegation(
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Combine results
# Combine results (failures carry curated user-safe sentences)
summaries = []
for agent_name, result in results:
if isinstance(result, Exception):
summaries.append(f"**{agent_name}**: Error - {result}")
for (agent, task), item in zip(matches, results, strict=False):
agent_name = agent.lower()
if isinstance(item, BaseException):
logger.error(
"delegation_failed",
agent=agent_name,
error=str(item),
conversation_id=conversation_id,
)
summaries.append(
f"**{agent_name}**: "
f"{get_think_message(agent_name, task, 'error')}"
)
else:
summaries.append(f"**{agent_name}**: {result}")
_, output, _ = item
summaries.append(f"**{agent_name}**: {output}")
return "\n\n".join(summaries)
@@ -197,20 +209,19 @@ async def _handle_text_delegation(
conversation_id=conversation_id,
)
try:
_, result = await _execute_single_delegation(
_, output, _ = await _execute_single_delegation(
agent_name, task, tracker
)
summaries.append(result)
summaries.append(output)
except Exception as e:
logger.error(
"delegation_failed",
agent=agent_name,
error=str(e),
conversation_id=conversation_id,
exc_info=True,
)
summaries.append(
f"I apologize, sir. Delegation to {agent_name} failed: {e}"
)
summaries.append(get_think_message(agent_name, task, "error"))
return "\n\n".join(summaries)
@@ -219,8 +230,9 @@ async def _handle_text_delegation(
"text_delegation_failed",
error=str(e),
conversation_id=conversation_id,
exc_info=True,
)
return f"I apologize, sir. I encountered an error processing delegations: {e}"
return "I apologize, sir. I was unable to complete the requested delegations."
async def _direct_delegation(
@@ -254,13 +266,14 @@ async def _direct_delegation(
results = []
for agent in recommendation.recommended_capabilities:
try:
agent_name, result = await _execute_single_delegation(
agent_name, result, success = await _execute_single_delegation(
agent, user_message, tracker
)
results.append(result)
logger.info(
"direct_delegation_complete",
agent=agent_name,
success=success,
result_preview=result[:100] if result else "empty",
conversation_id=conversation_id,
)
@@ -270,8 +283,9 @@ async def _direct_delegation(
agent=agent,
error=str(e),
conversation_id=conversation_id,
exc_info=True,
)
results.append(f"I apologize, sir. Delegation to {agent} failed: {e}")
results.append(get_think_message(agent, user_message, "error"))
return "\n\n".join(results) if results else "I apologize, sir. No delegation results available."
@@ -309,15 +323,17 @@ async def _direct_delegation_with_results(
for agent in recommendation.recommended_capabilities:
try:
agent_name, result = await _execute_single_delegation(
agent_name, result, success = await _execute_single_delegation(
agent, user_message, tracker
)
expert_results[agent_name] = result
tools_called.append(f"delegate_to_{agent_name}")
if success:
tools_called.append(f"delegate_to_{agent_name}")
logger.info(
"direct_delegation_result",
agent=agent_name,
success=success,
result_preview=result[:100] if result else "empty",
conversation_id=conversation_id,
)
@@ -327,8 +343,9 @@ async def _direct_delegation_with_results(
agent=agent,
error=str(e),
conversation_id=conversation_id,
exc_info=True,
)
expert_results[agent] = f"Error: {e}"
expert_results[agent] = get_think_message(agent, user_message, "error")
return {
"tools_called": tools_called,
@@ -439,7 +456,7 @@ async def create_response(request: ResponseRequest) -> Response:
user_input = _extract_user_input(request.input)
# Start trace
trace = start_trace(
start_trace(
conversation_id=conversation_id,
user=effective_user,
request={
@@ -451,7 +468,7 @@ async def create_response(request: ResponseRequest) -> Response:
)
# Start service span
service_span = start_span(
start_span(
"create_response",
SpanType.ROUTER,
metadata={"model": request.model, "user": effective_user},
@@ -509,7 +526,7 @@ async def create_response(request: ResponseRequest) -> Response:
return response
except Exception as e:
except Exception:
end_trace(status="error")
raise
@@ -550,7 +567,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
user_input = _extract_user_input(request.input)
# Start trace
trace = start_trace(
start_trace(
conversation_id=conversation_id,
user=effective_user,
request={
@@ -562,7 +579,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
)
# Start service span
service_span = start_span(
start_span(
"create_response_with_steward",
SpanType.ROUTER,
metadata={"model": request.model, "user": effective_user},
@@ -706,7 +723,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
return response
except Exception as e:
except Exception:
end_trace(status="error")
raise
+46 -26
View File
@@ -8,14 +8,22 @@ Handles Server-Sent Events (SSE) streaming with proper event types:
- response.done
"""
from enum import Enum
from typing import Literal, AsyncGenerator
import json
import time
from collections.abc import AsyncGenerator
from enum import Enum
from typing import TYPE_CHECKING, Literal
from src.agents.registry import ModelRegistry
from src.core.logging_config import get_logger
from src.core.models import CustomBaseModel
from src.responses.schemas import Response
from src.agents.registry import ModelRegistry
if TYPE_CHECKING:
from src.agents.steward.schemas import StewardRecommendation
from src.core.tool_tracking import ToolCallTracker
from src.responses.schemas import ResponseRequest
logger = get_logger(__name__)
# ============================================================================
@@ -131,18 +139,17 @@ class StreamingCoordinator:
Yields:
StreamEvent: Stream of SSE events
"""
from src.responses.service import (
_calculate_usage,
generate_id,
_conversation_history,
_direct_delegation_with_results,
)
import asyncio
from src.agents.tatlock import TatlockAgent
from src.core.preprocessing import preprocess_request
from src.core.tool_tracking import ToolCallTracker
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
from src.agents.tatlock import TatlockAgent
from src.agents.delegation import get_think_message, STREAMING_DELEGATION_WRAPPERS
import asyncio
from src.responses.schemas import MessageOutputItem, OutputTextContent
from src.responses.service import (
_calculate_usage,
_conversation_history,
generate_id,
)
output_items = []
@@ -280,13 +287,14 @@ class StreamingCoordinator:
Returns:
dict: Orchestration results with think_messages list
"""
import time as time_module
from src.agents.delegation import (
get_think_message,
delegate_to_librarian,
delegate_to_biographer,
delegate_to_housekeeper,
delegate_to_librarian,
get_think_message,
)
import time as time_module
expert_results = {}
tools_called = []
@@ -319,15 +327,26 @@ class StreamingCoordinator:
success_msg = get_think_message(agent, user_message, "success")
think_messages.append(success_msg + "\n")
else:
error_msg = result.error if result else "Unknown error"
expert_results[agent] = f"Error: {error_msg}"
# Emit error think message
# Failed delegations carry a curated user-safe sentence
# in output; exception detail is already in the logs.
error_think = get_think_message(agent, user_message, "error")
if result and result.output:
expert_results[agent] = result.output
else:
expert_results[agent] = error_think
# Emit error think message
think_messages.append(error_think + "\n")
except Exception as e:
expert_results[agent] = f"Error: {e}"
logger.error(
"direct_delegation_stream_error",
agent=agent,
error=str(e),
conversation_id=conversation_id,
exc_info=True,
)
error_think = get_think_message(agent, user_message, "error")
expert_results[agent] = error_think
think_messages.append(error_think + "\n")
return {
@@ -361,9 +380,10 @@ class StreamingCoordinator:
event: response.done
data: {"response": {...}}
"""
from src.responses.service import _calculate_usage, generate_id
import asyncio
from src.responses.service import _calculate_usage, generate_id
output_items = []
last_message_text = "" # Track last streamed message text to compute deltas
@@ -488,10 +508,10 @@ class StreamingCoordinator:
def _convert_output_items(self, items: list) -> list:
"""Convert agent OutputItem objects to schema OutputItem objects."""
from src.responses.schemas import (
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
MessageOutputItem,
OutputTextContent,
ReasoningOutputItem,
)
converted = []
@@ -521,9 +541,9 @@ class StreamingCoordinator:
def _create_error_event(self, error: Exception) -> ErrorEvent:
"""Create error event from exception."""
from src.core.exceptions import (
RateLimitError,
ContextLengthError,
AppException,
ContextLengthError,
RateLimitError,
)
if isinstance(error, RateLimitError):
@@ -0,0 +1,60 @@
"""
Tests for structured failure behavior of The Librarian entry points.
run_librarian and run_librarian_stream must raise AgentError on failure
instead of returning/yielding error text as if it were research output,
and the raised error must not leak exception detail (internal URLs etc.).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.agents.librarian.agent import run_librarian, run_librarian_stream
from src.agents.protocol import AgentError
@pytest.mark.unit
class TestRunLibrarianFailures:
"""run_librarian raises structured errors instead of returning text."""
@pytest.mark.asyncio
async def test_run_librarian_raises_agent_error(self):
"""Failures raise AgentError rather than returning error prose."""
mock_agent = MagicMock()
mock_agent.run = AsyncMock(
side_effect=RuntimeError("Connection refused to http://internal:8089")
)
with patch(
"src.agents.librarian.agent.get_librarian_agent",
return_value=mock_agent,
):
with pytest.raises(AgentError) as exc_info:
await run_librarian(task="Find Docker docs")
assert exc_info.value.agent_name == "librarian"
# Exception detail stays in logs only
assert "internal" not in str(exc_info.value)
assert "Connection refused" not in str(exc_info.value)
@pytest.mark.asyncio
async def test_run_librarian_stream_raises_agent_error(self):
"""Streaming failures raise instead of yielding error text."""
mock_agent = MagicMock()
mock_agent.run_stream = MagicMock(
side_effect=RuntimeError("Connection refused to http://internal:8089")
)
with patch(
"src.agents.librarian.agent.get_librarian_agent",
return_value=mock_agent,
):
collected: list[str] = []
with pytest.raises(AgentError) as exc_info:
async for delta in run_librarian_stream(task="Find Docker docs"):
collected.append(delta)
assert exc_info.value.agent_name == "librarian"
assert collected == [], "no error text may be yielded as output"
assert "internal" not in str(exc_info.value)
+19 -16
View File
@@ -5,21 +5,20 @@ Tests the tool functions that wrap the Library-Desk API,
including the new web search and content extraction tools.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
from src.agents.librarian.client import (
BatchExtractionResponse,
ContentExtractionResult,
WebSearchResponse,
WebSearchResult,
)
from src.agents.librarian.tools import (
search_web,
read_url,
read_urls_batch,
hybrid_search,
search_wiki,
)
from src.agents.librarian.client import (
WebSearchResult,
WebSearchResponse,
ContentExtractionResult,
BatchExtractionResponse,
search_web,
)
@@ -104,8 +103,10 @@ class TestSearchWeb:
@pytest.mark.asyncio
async def test_search_web_error_handling(self, mock_client):
"""Test web search error handling."""
mock_client.search_web.side_effect = Exception("Connection failed")
"""Test web search errors return a user-safe message without internals."""
mock_client.search_web.side_effect = Exception(
"Connection failed to http://internal-host:8089"
)
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
@@ -115,8 +116,10 @@ class TestSearchWeb:
result = await search_web("test query")
assert "Error" in result
assert "Connection failed" in result
assert "unable to search the web" in result
# Exception detail (internal URLs etc.) must not leak
assert "Connection failed" not in result
assert "internal-host" not in result
@pytest.mark.asyncio
async def test_search_web_with_news_type(self, mock_client):
@@ -227,7 +230,7 @@ class TestReadUrl:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await read_url("https://example.com/long", max_length=2000)
await read_url("https://example.com/long", max_length=2000)
mock_client.extract_content.assert_called_with(
url="https://example.com/long",
+17 -9
View File
@@ -4,18 +4,19 @@ 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
from unittest.mock import AsyncMock, patch, MagicMock
from src.agents.delegation import (
ActionType,
DelegationTask,
DelegationResult,
HOUSEHOLD_THINK_MESSAGES,
STREAMING_DELEGATION_WRAPPERS,
ActionType,
DelegationResult,
DelegationTask,
_detect_action_type,
delegate_to_librarian,
get_think_message,
_detect_action_type,
)
@@ -170,11 +171,11 @@ class TestDelegateToLibrarian:
@pytest.mark.asyncio
async def test_delegate_to_librarian_handles_error(self):
"""Test delegation handles Librarian errors gracefully."""
"""Test delegation maps Librarian errors to a user-safe result."""
with patch(
"src.agents.librarian.agent.run_librarian",
new_callable=AsyncMock,
side_effect=Exception("Connection refused"),
side_effect=Exception("Connection refused to http://internal:8089"),
):
result = await delegate_to_librarian(
task="Search for information",
@@ -182,8 +183,15 @@ class TestDelegateToLibrarian:
assert isinstance(result, DelegationResult)
assert result.success is False
assert result.output == ""
assert result.error == "Connection refused"
# Output carries a curated butler-toned sentence
assert result.output == get_think_message(
"librarian", "Search for information", "error"
)
# Exception detail stays in logs only - never in the result
assert "Connection refused" not in result.output
assert result.error is not None
assert "Connection refused" not in result.error
assert "internal" not in result.error
@pytest.mark.asyncio
async def test_delegate_to_librarian_preserves_task(self):