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:
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### 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/`)
|
- **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
|
## [2.3.0] - 2026-07-13
|
||||||
|
|||||||
+15
-11
@@ -10,7 +10,8 @@ based on Steward recommendations. Handles:
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
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.librarian import run_librarian, run_librarian_stream
|
||||||
from src.agents.protocol import (
|
from src.agents.protocol import (
|
||||||
@@ -22,7 +23,6 @@ from src.agents.protocol import (
|
|||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
DelegationIntent,
|
DelegationIntent,
|
||||||
DelegationReason,
|
DelegationReason,
|
||||||
ToolCallRecord,
|
|
||||||
)
|
)
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
@@ -87,7 +87,7 @@ class CoordinationEngine:
|
|||||||
self,
|
self,
|
||||||
intent: DelegationIntent,
|
intent: DelegationIntent,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""
|
"""
|
||||||
Execute a single delegation to an expert agent.
|
Execute a single delegation to an expert agent.
|
||||||
@@ -166,7 +166,7 @@ class CoordinationEngine:
|
|||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError as e:
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
logger.error(
|
logger.error(
|
||||||
"delegation_timeout",
|
"delegation_timeout",
|
||||||
@@ -176,7 +176,7 @@ class CoordinationEngine:
|
|||||||
raise AgentTimeoutError(
|
raise AgentTimeoutError(
|
||||||
f"Agent '{agent_name}' timed out after {duration_ms}ms",
|
f"Agent '{agent_name}' timed out after {duration_ms}ms",
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
)
|
) from e
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
@@ -198,7 +198,7 @@ class CoordinationEngine:
|
|||||||
self,
|
self,
|
||||||
intent: DelegationIntent,
|
intent: DelegationIntent,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Execute a delegation with streaming output.
|
Execute a delegation with streaming output.
|
||||||
@@ -242,19 +242,23 @@ class CoordinationEngine:
|
|||||||
logger.info("delegation_stream_completed", agent=agent_name)
|
logger.info("delegation_stream_completed", agent=agent_name)
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
logger.error(
|
||||||
"delegation_stream_error",
|
"delegation_stream_error",
|
||||||
agent=agent_name,
|
agent=agent_name,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
exc_info=True,
|
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(
|
async def coordinate(
|
||||||
self,
|
self,
|
||||||
intents: list[DelegationIntent],
|
intents: list[DelegationIntent],
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> CoordinationResult:
|
) -> CoordinationResult:
|
||||||
"""
|
"""
|
||||||
Coordinate execution of multiple delegations.
|
Coordinate execution of multiple delegations.
|
||||||
@@ -329,7 +333,7 @@ class CoordinationEngine:
|
|||||||
|
|
||||||
|
|
||||||
# Global coordination engine instance
|
# Global coordination engine instance
|
||||||
_coordination_engine: Optional[CoordinationEngine] = None
|
_coordination_engine: CoordinationEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_coordination_engine() -> CoordinationEngine:
|
def get_coordination_engine() -> CoordinationEngine:
|
||||||
@@ -344,7 +348,7 @@ async def delegate_to_librarian(
|
|||||||
task: str,
|
task: str,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
reason: DelegationReason = DelegationReason.DOMAIN_EXPERTISE,
|
reason: DelegationReason = DelegationReason.DOMAIN_EXPERTISE,
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""
|
"""
|
||||||
Convenience function to delegate a task to The Librarian.
|
Convenience function to delegate a task to The Librarian.
|
||||||
@@ -377,7 +381,7 @@ async def delegate_to_librarian(
|
|||||||
async def delegate_to_librarian_stream(
|
async def delegate_to_librarian_stream(
|
||||||
task: str,
|
task: str,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Convenience function to delegate to Librarian with streaming.
|
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:
|
This implements the agent-as-tool pattern recommended by PydanticAI:
|
||||||
agents call other agents via tool wrappers, keeping each agent focused.
|
agents call other agents via tool wrappers, keeping each agent focused.
|
||||||
"""
|
"""
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import AsyncGenerator, Callable, Optional, Any
|
|
||||||
|
|
||||||
from src.core.logging_config import get_logger
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ class DelegationTask:
|
|||||||
action: str = ""
|
action: str = ""
|
||||||
priority: int = 0
|
priority: int = 0
|
||||||
depends_on: list[str] = field(default_factory=list)
|
depends_on: list[str] = field(default_factory=list)
|
||||||
result: Optional[str] = None
|
result: str | None = None
|
||||||
task_id: str = ""
|
task_id: str = ""
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -186,14 +186,16 @@ class DelegationResult:
|
|||||||
expert_name: Which expert handled the task
|
expert_name: Which expert handled the task
|
||||||
task: Original task description
|
task: Original task description
|
||||||
success: Whether the delegation succeeded
|
success: Whether the delegation succeeded
|
||||||
output: Expert's response/findings
|
output: Expert's response/findings. On failure this holds a
|
||||||
error: Error message if failed
|
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
|
expert_name: str
|
||||||
task: str
|
task: str
|
||||||
success: bool
|
success: bool
|
||||||
output: str
|
output: str
|
||||||
error: Optional[str] = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def delegate_to_librarian(
|
async def delegate_to_librarian(
|
||||||
@@ -285,12 +287,15 @@ async def delegate_to_librarian(
|
|||||||
span.metadata["success"] = False
|
span.metadata["success"] = False
|
||||||
span.details["error"] = str(e)
|
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(
|
return DelegationResult(
|
||||||
expert_name="librarian",
|
expert_name="librarian",
|
||||||
task=task,
|
task=task,
|
||||||
success=False,
|
success=False,
|
||||||
output="",
|
output=get_think_message("librarian", task, "error"),
|
||||||
error=str(e),
|
error="The Librarian was unable to complete the task.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,12 +388,13 @@ async def delegate_to_biographer(
|
|||||||
span.metadata["success"] = False
|
span.metadata["success"] = False
|
||||||
span.details["error"] = str(e)
|
span.details["error"] = str(e)
|
||||||
|
|
||||||
|
# Exception detail stays in the logs only.
|
||||||
return DelegationResult(
|
return DelegationResult(
|
||||||
expert_name="biographer",
|
expert_name="biographer",
|
||||||
task=task,
|
task=task,
|
||||||
success=False,
|
success=False,
|
||||||
output="",
|
output=get_think_message("biographer", task, "error"),
|
||||||
error=str(e),
|
error="The Biographer was unable to complete the task.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -480,12 +486,13 @@ async def delegate_to_housekeeper(
|
|||||||
span.metadata["success"] = False
|
span.metadata["success"] = False
|
||||||
span.details["error"] = str(e)
|
span.details["error"] = str(e)
|
||||||
|
|
||||||
|
# Exception detail stays in the logs only.
|
||||||
return DelegationResult(
|
return DelegationResult(
|
||||||
expert_name="housekeeper",
|
expert_name="housekeeper",
|
||||||
task=task,
|
task=task,
|
||||||
success=False,
|
success=False,
|
||||||
output="",
|
output=get_think_message("housekeeper", task, "error"),
|
||||||
error=str(e),
|
error="The Housekeeper was unable to complete the task.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ the library-desk API, offering:
|
|||||||
- Wiki and document management
|
- Wiki and document management
|
||||||
- Semantic search and knowledge graph exploration
|
- Semantic search and knowledge graph exploration
|
||||||
"""
|
"""
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ from src.agents.librarian.tools import (
|
|||||||
smart_create_wiki_page,
|
smart_create_wiki_page,
|
||||||
update_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
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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
|
# 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]:
|
def _create_librarian_agent() -> Agent[None, str]:
|
||||||
@@ -204,7 +204,7 @@ def get_librarian_agent() -> Agent[None, str]:
|
|||||||
async def run_librarian(
|
async def run_librarian(
|
||||||
task: str,
|
task: str,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Execute a research task with The Librarian.
|
Execute a research task with The Librarian.
|
||||||
@@ -220,6 +220,10 @@ async def run_librarian(
|
|||||||
Returns:
|
Returns:
|
||||||
Research results and findings
|
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:
|
Example:
|
||||||
result = await run_librarian(
|
result = await run_librarian(
|
||||||
task="Find information about Docker networking",
|
task="Find information about Docker networking",
|
||||||
@@ -255,19 +259,23 @@ async def run_librarian(
|
|||||||
return result.output
|
return result.output
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
logger.error(
|
||||||
"librarian_task_error",
|
"librarian_task_error",
|
||||||
task=task[:50],
|
task=task[:50],
|
||||||
error=str(e),
|
error=str(e),
|
||||||
exc_info=True,
|
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(
|
async def run_librarian_stream(
|
||||||
task: str,
|
task: str,
|
||||||
context: str = "",
|
context: str = "",
|
||||||
message_history: Optional[list[Any]] = None,
|
message_history: list[Any] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Execute a research task with streaming output.
|
Execute a research task with streaming output.
|
||||||
@@ -282,6 +290,10 @@ async def run_librarian_stream(
|
|||||||
Yields:
|
Yields:
|
||||||
str: Text deltas from the response
|
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:
|
Example:
|
||||||
async for delta in run_librarian_stream("Find Docker docs"):
|
async for delta in run_librarian_stream("Find Docker docs"):
|
||||||
print(delta, end="", flush=True)
|
print(delta, end="", flush=True)
|
||||||
@@ -309,10 +321,14 @@ async def run_librarian_stream(
|
|||||||
logger.info("librarian_stream_completed", task=task[:50])
|
logger.info("librarian_stream_completed", task=task[:50])
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
logger.error(
|
||||||
"librarian_stream_error",
|
"librarian_stream_error",
|
||||||
task=task[:50],
|
task=task[:50],
|
||||||
error=str(e),
|
error=str(e),
|
||||||
exc_info=True,
|
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:
|
except Exception as e:
|
||||||
logger.error("librarian_hybrid_search_error", error=str(e), query=query)
|
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:
|
except Exception as e:
|
||||||
logger.error("librarian_wiki_search_error", error=str(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(
|
async def get_wiki_page(
|
||||||
@@ -202,7 +202,7 @@ async def get_wiki_page(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_get_page_error", error=str(e), page_id=page_id)
|
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:
|
async def list_dossiers() -> str:
|
||||||
@@ -236,7 +236,7 @@ async def list_dossiers() -> str:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_list_dossiers_error", error=str(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(
|
async def get_dossier_pages(
|
||||||
@@ -277,7 +277,7 @@ async def get_dossier_pages(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_get_dossier_error", error=str(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:
|
except Exception as e:
|
||||||
logger.error("librarian_semantic_search_error", error=str(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:
|
except Exception as e:
|
||||||
logger.error("librarian_explore_graph_error", error=str(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(
|
async def find_related_entities(
|
||||||
@@ -450,7 +450,7 @@ async def find_related_entities(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_find_related_error", error=str(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:
|
except Exception as e:
|
||||||
logger.error("librarian_web_search_error", error=str(e), query=query)
|
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(
|
async def read_url(
|
||||||
@@ -609,7 +609,7 @@ async def read_url(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_read_url_error", error=str(e), url=url)
|
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(
|
async def read_urls_batch(
|
||||||
@@ -683,7 +683,7 @@ async def read_urls_batch(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_read_urls_batch_error", error=str(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:
|
except Exception as e:
|
||||||
logger.error("librarian_update_page_error", error=str(e), page_id=page_id)
|
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(
|
async def create_wiki_page(
|
||||||
@@ -841,7 +841,7 @@ async def create_wiki_page(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_create_page_error", error=str(e), title=title)
|
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(
|
async def smart_create_wiki_page(
|
||||||
@@ -930,7 +930,7 @@ async def smart_create_wiki_page(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("librarian_smart_create_error", error=str(e), topic=topic)
|
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
@@ -6,32 +6,32 @@ Tracks conversation history for analytics and future vector memory.
|
|||||||
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
|
Integrates with Steward preprocessing for Phase 2 two-tier architecture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import asyncio
|
||||||
|
import re
|
||||||
import secrets
|
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.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 (
|
from src.responses.schemas import (
|
||||||
|
FunctionCallOutputItem,
|
||||||
|
MessageOutputItem,
|
||||||
|
OutputTextContent,
|
||||||
|
ReasoningOutputItem,
|
||||||
Response,
|
Response,
|
||||||
ResponseRequest,
|
ResponseRequest,
|
||||||
ResponseUsage,
|
ResponseUsage,
|
||||||
MessageOutputItem,
|
|
||||||
ReasoningOutputItem,
|
|
||||||
FunctionCallOutputItem,
|
|
||||||
OutputTextContent,
|
|
||||||
)
|
)
|
||||||
from src.responses.streaming import StreamingCoordinator
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ async def _execute_single_delegation(
|
|||||||
agent_name: str,
|
agent_name: str,
|
||||||
task: str,
|
task: str,
|
||||||
tracker: "ToolCallTracker",
|
tracker: "ToolCallTracker",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str, bool]:
|
||||||
"""
|
"""
|
||||||
Execute a single delegation to an agent.
|
Execute a single delegation to an agent.
|
||||||
|
|
||||||
@@ -73,7 +73,8 @@ async def _execute_single_delegation(
|
|||||||
tracker: Tool call tracker
|
tracker: Tool call tracker
|
||||||
|
|
||||||
Returns:
|
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
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -83,24 +84,24 @@ async def _execute_single_delegation(
|
|||||||
result = await delegate_to_biographer(task=task)
|
result = await delegate_to_biographer(task=task)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
await tracker.track_call("delegate_to_biographer", duration)
|
await tracker.track_call("delegate_to_biographer", duration)
|
||||||
return (agent_name, result.output)
|
return (agent_name, result.output, result.success)
|
||||||
|
|
||||||
elif agent_name == "librarian":
|
elif agent_name == "librarian":
|
||||||
from src.agents.delegation import delegate_to_librarian
|
from src.agents.delegation import delegate_to_librarian
|
||||||
result = await delegate_to_librarian(task=task)
|
result = await delegate_to_librarian(task=task)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
await tracker.track_call("delegate_to_librarian", duration)
|
await tracker.track_call("delegate_to_librarian", duration)
|
||||||
return (agent_name, result.output)
|
return (agent_name, result.output, result.success)
|
||||||
|
|
||||||
elif agent_name == "housekeeper":
|
elif agent_name == "housekeeper":
|
||||||
from src.agents.delegation import delegate_to_housekeeper
|
from src.agents.delegation import delegate_to_housekeeper
|
||||||
result = await delegate_to_housekeeper(task=task)
|
result = await delegate_to_housekeeper(task=task)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
await tracker.track_call("delegate_to_housekeeper", duration)
|
await tracker.track_call("delegate_to_housekeeper", duration)
|
||||||
return (agent_name, result.output)
|
return (agent_name, result.output, result.success)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return (agent_name, f"Unknown agent: {agent_name}")
|
return (agent_name, f"Unknown agent: {agent_name}", False)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_text_delegation(
|
async def _handle_text_delegation(
|
||||||
@@ -175,13 +176,24 @@ async def _handle_text_delegation(
|
|||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Combine results
|
# Combine results (failures carry curated user-safe sentences)
|
||||||
summaries = []
|
summaries = []
|
||||||
for agent_name, result in results:
|
for (agent, task), item in zip(matches, results, strict=False):
|
||||||
if isinstance(result, Exception):
|
agent_name = agent.lower()
|
||||||
summaries.append(f"**{agent_name}**: Error - {result}")
|
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:
|
else:
|
||||||
summaries.append(f"**{agent_name}**: {result}")
|
_, output, _ = item
|
||||||
|
summaries.append(f"**{agent_name}**: {output}")
|
||||||
|
|
||||||
return "\n\n".join(summaries)
|
return "\n\n".join(summaries)
|
||||||
|
|
||||||
@@ -197,20 +209,19 @@ async def _handle_text_delegation(
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_, result = await _execute_single_delegation(
|
_, output, _ = await _execute_single_delegation(
|
||||||
agent_name, task, tracker
|
agent_name, task, tracker
|
||||||
)
|
)
|
||||||
summaries.append(result)
|
summaries.append(output)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"delegation_failed",
|
"delegation_failed",
|
||||||
agent=agent_name,
|
agent=agent_name,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
summaries.append(
|
summaries.append(get_think_message(agent_name, task, "error"))
|
||||||
f"I apologize, sir. Delegation to {agent_name} failed: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n\n".join(summaries)
|
return "\n\n".join(summaries)
|
||||||
|
|
||||||
@@ -219,8 +230,9 @@ async def _handle_text_delegation(
|
|||||||
"text_delegation_failed",
|
"text_delegation_failed",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
conversation_id=conversation_id,
|
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(
|
async def _direct_delegation(
|
||||||
@@ -254,13 +266,14 @@ async def _direct_delegation(
|
|||||||
results = []
|
results = []
|
||||||
for agent in recommendation.recommended_capabilities:
|
for agent in recommendation.recommended_capabilities:
|
||||||
try:
|
try:
|
||||||
agent_name, result = await _execute_single_delegation(
|
agent_name, result, success = await _execute_single_delegation(
|
||||||
agent, user_message, tracker
|
agent, user_message, tracker
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
logger.info(
|
logger.info(
|
||||||
"direct_delegation_complete",
|
"direct_delegation_complete",
|
||||||
agent=agent_name,
|
agent=agent_name,
|
||||||
|
success=success,
|
||||||
result_preview=result[:100] if result else "empty",
|
result_preview=result[:100] if result else "empty",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
@@ -270,8 +283,9 @@ async def _direct_delegation(
|
|||||||
agent=agent,
|
agent=agent,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
conversation_id=conversation_id,
|
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."
|
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:
|
for agent in recommendation.recommended_capabilities:
|
||||||
try:
|
try:
|
||||||
agent_name, result = await _execute_single_delegation(
|
agent_name, result, success = await _execute_single_delegation(
|
||||||
agent, user_message, tracker
|
agent, user_message, tracker
|
||||||
)
|
)
|
||||||
expert_results[agent_name] = result
|
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(
|
logger.info(
|
||||||
"direct_delegation_result",
|
"direct_delegation_result",
|
||||||
agent=agent_name,
|
agent=agent_name,
|
||||||
|
success=success,
|
||||||
result_preview=result[:100] if result else "empty",
|
result_preview=result[:100] if result else "empty",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
@@ -327,8 +343,9 @@ async def _direct_delegation_with_results(
|
|||||||
agent=agent,
|
agent=agent,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
expert_results[agent] = f"Error: {e}"
|
expert_results[agent] = get_think_message(agent, user_message, "error")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tools_called": tools_called,
|
"tools_called": tools_called,
|
||||||
@@ -439,7 +456,7 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
user_input = _extract_user_input(request.input)
|
user_input = _extract_user_input(request.input)
|
||||||
|
|
||||||
# Start trace
|
# Start trace
|
||||||
trace = start_trace(
|
start_trace(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
user=effective_user,
|
user=effective_user,
|
||||||
request={
|
request={
|
||||||
@@ -451,7 +468,7 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Start service span
|
# Start service span
|
||||||
service_span = start_span(
|
start_span(
|
||||||
"create_response",
|
"create_response",
|
||||||
SpanType.ROUTER,
|
SpanType.ROUTER,
|
||||||
metadata={"model": request.model, "user": effective_user},
|
metadata={"model": request.model, "user": effective_user},
|
||||||
@@ -509,7 +526,7 @@ async def create_response(request: ResponseRequest) -> Response:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
end_trace(status="error")
|
end_trace(status="error")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -550,7 +567,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
user_input = _extract_user_input(request.input)
|
user_input = _extract_user_input(request.input)
|
||||||
|
|
||||||
# Start trace
|
# Start trace
|
||||||
trace = start_trace(
|
start_trace(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
user=effective_user,
|
user=effective_user,
|
||||||
request={
|
request={
|
||||||
@@ -562,7 +579,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Start service span
|
# Start service span
|
||||||
service_span = start_span(
|
start_span(
|
||||||
"create_response_with_steward",
|
"create_response_with_steward",
|
||||||
SpanType.ROUTER,
|
SpanType.ROUTER,
|
||||||
metadata={"model": request.model, "user": effective_user},
|
metadata={"model": request.model, "user": effective_user},
|
||||||
@@ -706,7 +723,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
end_trace(status="error")
|
end_trace(status="error")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
+46
-26
@@ -8,14 +8,22 @@ Handles Server-Sent Events (SSE) streaming with proper event types:
|
|||||||
- response.done
|
- response.done
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Literal, AsyncGenerator
|
|
||||||
import json
|
|
||||||
import time
|
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.core.models import CustomBaseModel
|
||||||
from src.responses.schemas import Response
|
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:
|
Yields:
|
||||||
StreamEvent: Stream of SSE events
|
StreamEvent: Stream of SSE events
|
||||||
"""
|
"""
|
||||||
from src.responses.service import (
|
import asyncio
|
||||||
_calculate_usage,
|
|
||||||
generate_id,
|
from src.agents.tatlock import TatlockAgent
|
||||||
_conversation_history,
|
|
||||||
_direct_delegation_with_results,
|
|
||||||
)
|
|
||||||
from src.core.preprocessing import preprocess_request
|
from src.core.preprocessing import preprocess_request
|
||||||
from src.core.tool_tracking import ToolCallTracker
|
from src.core.tool_tracking import ToolCallTracker
|
||||||
from src.responses.schemas import MessageOutputItem, ReasoningOutputItem, OutputTextContent
|
from src.responses.schemas import MessageOutputItem, OutputTextContent
|
||||||
from src.agents.tatlock import TatlockAgent
|
from src.responses.service import (
|
||||||
from src.agents.delegation import get_think_message, STREAMING_DELEGATION_WRAPPERS
|
_calculate_usage,
|
||||||
import asyncio
|
_conversation_history,
|
||||||
|
generate_id,
|
||||||
|
)
|
||||||
|
|
||||||
output_items = []
|
output_items = []
|
||||||
|
|
||||||
@@ -280,13 +287,14 @@ class StreamingCoordinator:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Orchestration results with think_messages list
|
dict: Orchestration results with think_messages list
|
||||||
"""
|
"""
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
from src.agents.delegation import (
|
from src.agents.delegation import (
|
||||||
get_think_message,
|
|
||||||
delegate_to_librarian,
|
|
||||||
delegate_to_biographer,
|
delegate_to_biographer,
|
||||||
delegate_to_housekeeper,
|
delegate_to_housekeeper,
|
||||||
|
delegate_to_librarian,
|
||||||
|
get_think_message,
|
||||||
)
|
)
|
||||||
import time as time_module
|
|
||||||
|
|
||||||
expert_results = {}
|
expert_results = {}
|
||||||
tools_called = []
|
tools_called = []
|
||||||
@@ -319,15 +327,26 @@ class StreamingCoordinator:
|
|||||||
success_msg = get_think_message(agent, user_message, "success")
|
success_msg = get_think_message(agent, user_message, "success")
|
||||||
think_messages.append(success_msg + "\n")
|
think_messages.append(success_msg + "\n")
|
||||||
else:
|
else:
|
||||||
error_msg = result.error if result else "Unknown error"
|
# Failed delegations carry a curated user-safe sentence
|
||||||
expert_results[agent] = f"Error: {error_msg}"
|
# in output; exception detail is already in the logs.
|
||||||
# Emit error think message
|
|
||||||
error_think = get_think_message(agent, user_message, "error")
|
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")
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
error_think = get_think_message(agent, user_message, "error")
|
||||||
|
expert_results[agent] = error_think
|
||||||
think_messages.append(error_think + "\n")
|
think_messages.append(error_think + "\n")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -361,9 +380,10 @@ class StreamingCoordinator:
|
|||||||
event: response.done
|
event: response.done
|
||||||
data: {"response": {...}}
|
data: {"response": {...}}
|
||||||
"""
|
"""
|
||||||
from src.responses.service import _calculate_usage, generate_id
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from src.responses.service import _calculate_usage, generate_id
|
||||||
|
|
||||||
output_items = []
|
output_items = []
|
||||||
last_message_text = "" # Track last streamed message text to compute deltas
|
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:
|
def _convert_output_items(self, items: list) -> list:
|
||||||
"""Convert agent OutputItem objects to schema OutputItem objects."""
|
"""Convert agent OutputItem objects to schema OutputItem objects."""
|
||||||
from src.responses.schemas import (
|
from src.responses.schemas import (
|
||||||
MessageOutputItem,
|
|
||||||
ReasoningOutputItem,
|
|
||||||
FunctionCallOutputItem,
|
FunctionCallOutputItem,
|
||||||
|
MessageOutputItem,
|
||||||
OutputTextContent,
|
OutputTextContent,
|
||||||
|
ReasoningOutputItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
converted = []
|
converted = []
|
||||||
@@ -521,9 +541,9 @@ class StreamingCoordinator:
|
|||||||
def _create_error_event(self, error: Exception) -> ErrorEvent:
|
def _create_error_event(self, error: Exception) -> ErrorEvent:
|
||||||
"""Create error event from exception."""
|
"""Create error event from exception."""
|
||||||
from src.core.exceptions import (
|
from src.core.exceptions import (
|
||||||
RateLimitError,
|
|
||||||
ContextLengthError,
|
|
||||||
AppException,
|
AppException,
|
||||||
|
ContextLengthError,
|
||||||
|
RateLimitError,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(error, 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)
|
||||||
@@ -5,21 +5,20 @@ Tests the tool functions that wrap the Library-Desk API,
|
|||||||
including the new web search and content extraction tools.
|
including the new web search and content extraction tools.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
from unittest.mock import AsyncMock, patch
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agents.librarian.client import (
|
||||||
|
BatchExtractionResponse,
|
||||||
|
ContentExtractionResult,
|
||||||
|
WebSearchResponse,
|
||||||
|
WebSearchResult,
|
||||||
|
)
|
||||||
from src.agents.librarian.tools import (
|
from src.agents.librarian.tools import (
|
||||||
search_web,
|
|
||||||
read_url,
|
read_url,
|
||||||
read_urls_batch,
|
read_urls_batch,
|
||||||
hybrid_search,
|
search_web,
|
||||||
search_wiki,
|
|
||||||
)
|
|
||||||
from src.agents.librarian.client import (
|
|
||||||
WebSearchResult,
|
|
||||||
WebSearchResponse,
|
|
||||||
ContentExtractionResult,
|
|
||||||
BatchExtractionResponse,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,8 +103,10 @@ class TestSearchWeb:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_web_error_handling(self, mock_client):
|
async def test_search_web_error_handling(self, mock_client):
|
||||||
"""Test web search error handling."""
|
"""Test web search errors return a user-safe message without internals."""
|
||||||
mock_client.search_web.side_effect = Exception("Connection failed")
|
mock_client.search_web.side_effect = Exception(
|
||||||
|
"Connection failed to http://internal-host:8089"
|
||||||
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.agents.librarian.tools.LibraryDeskClient"
|
"src.agents.librarian.tools.LibraryDeskClient"
|
||||||
@@ -115,8 +116,10 @@ class TestSearchWeb:
|
|||||||
|
|
||||||
result = await search_web("test query")
|
result = await search_web("test query")
|
||||||
|
|
||||||
assert "Error" in result
|
assert "unable to search the web" in result
|
||||||
assert "Connection failed" 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
|
@pytest.mark.asyncio
|
||||||
async def test_search_web_with_news_type(self, mock_client):
|
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.__aenter__.return_value = mock_client
|
||||||
mock_client_class.return_value.__aexit__.return_value = None
|
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(
|
mock_client.extract_content.assert_called_with(
|
||||||
url="https://example.com/long",
|
url="https://example.com/long",
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ Tests for delegation infrastructure.
|
|||||||
Tests the DelegationTask dataclass and delegation wrapper functions
|
Tests the DelegationTask dataclass and delegation wrapper functions
|
||||||
that implement the agent-as-tool pattern.
|
that implement the agent-as-tool pattern.
|
||||||
"""
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
|
|
||||||
from src.agents.delegation import (
|
from src.agents.delegation import (
|
||||||
ActionType,
|
|
||||||
DelegationTask,
|
|
||||||
DelegationResult,
|
|
||||||
HOUSEHOLD_THINK_MESSAGES,
|
HOUSEHOLD_THINK_MESSAGES,
|
||||||
STREAMING_DELEGATION_WRAPPERS,
|
STREAMING_DELEGATION_WRAPPERS,
|
||||||
|
ActionType,
|
||||||
|
DelegationResult,
|
||||||
|
DelegationTask,
|
||||||
|
_detect_action_type,
|
||||||
delegate_to_librarian,
|
delegate_to_librarian,
|
||||||
get_think_message,
|
get_think_message,
|
||||||
_detect_action_type,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,11 +171,11 @@ class TestDelegateToLibrarian:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delegate_to_librarian_handles_error(self):
|
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(
|
with patch(
|
||||||
"src.agents.librarian.agent.run_librarian",
|
"src.agents.librarian.agent.run_librarian",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=Exception("Connection refused"),
|
side_effect=Exception("Connection refused to http://internal:8089"),
|
||||||
):
|
):
|
||||||
result = await delegate_to_librarian(
|
result = await delegate_to_librarian(
|
||||||
task="Search for information",
|
task="Search for information",
|
||||||
@@ -182,8 +183,15 @@ class TestDelegateToLibrarian:
|
|||||||
|
|
||||||
assert isinstance(result, DelegationResult)
|
assert isinstance(result, DelegationResult)
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.output == ""
|
# Output carries a curated butler-toned sentence
|
||||||
assert result.error == "Connection refused"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_delegate_to_librarian_preserves_task(self):
|
async def test_delegate_to_librarian_preserves_task(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user