backend/src/agents/orchestrator.py
"""Orchestrator — Router-first request coordination (`architecture-overview.md` §8/§14). Sits between the API layer and Router/Context Builder/Skills. Owns exactly one control-flow decision: after the Router scores the raw query, build the `RequestContext` either trivially (high confidence) or via the Context Builder (low confidence / no route), then invoke the Skill(s) the `RoutingDecision` names and assemble a single `AgentResponse`. Never imports LangChain, never calls a Tool or the LLM Service directly (that's each Skill's job), and never calls the Context Builder on the high-confidence branch (`project-context.md` §2.2). """ from __future__ import annotations import time from typing import Any from agents.base.agent_context import AgentContext from agents.context_builder import ContextBuilder, PassthroughContextBuilder from models.context import RequestContext from models.orchestrator import AgentResponse from models.routing import RoutingDecision from models.skills import SkillRequest, SkillResult from observability import log_event, record_metric from router.router import route from skills.base_skill import BaseSkill # Phase 1's low-confidence/no-route catch-all — GenericChatSkill, not FallbackAgentSkill # (Phase 2+). Fixed regardless of what a low-confidence route's own `required_skills` # says (`routing-design.md` §6, pickup-checkpoint decision 7). FALLBACK_SKILL_NAME = "generic_chat" class OrchestratorError(RuntimeError): """Raised when a `RoutingDecision` names a Skill the Orchestrator doesn't have.""" class Orchestrator: """Coordinates Router -> (Context Builder | inline context) -> Skill(s) -> response. `skills` is a name -> `BaseSkill` map, normally `SkillFactory.build_all()`'s output keyed the same way `config/skills/registry.yaml` and `required_skills` are. """ def __init__( self, skills: dict[str, BaseSkill], context_builder: ContextBuilder | None = None, ) -> None: self._skills = skills self._context_builder = context_builder or PassthroughContextBuilder() async def handle(self, query: str, agent_context: AgentContext) -> AgentResponse: trace_id = agent_context.trace_id session_id = agent_context.session_id started_at = time.perf_counter() log_event( "dispatch_start", component="Orchestrator", trace_id=trace_id, fields={"session_id": session_id, "query_preview": query[:80]}, ) routing = route(query, agent_context) is_fallback = routing.route_name is None or routing.low_confidence if is_fallback: context = await self._context_builder.build(query, agent_context) skill_names = [FALLBACK_SKILL_NAME] else: context = RequestContext( user_query=query, session_id=agent_context.session_id, trace_id=agent_context.trace_id, conversation_history=[], retrieved_context=[], system_prompt=agent_context.system_prompt, ) skill_names = routing.skills if not skill_names: raise OrchestratorError( f"High-confidence route '{routing.route_name}' declares no " f"required_skills — check config/routing/skills_routing.yaml." ) skill_request = SkillRequest(context=context, routing=routing) results = [ await self._resolve_skill(name).execute(skill_request) for name in skill_names ] response = self._assemble_response( agent_context=agent_context, routing=routing, skill_names=skill_names, results=results, ) tools_invoked = sorted( { tool for name in skill_names for tool in getattr(self._resolve_skill(name), "required_tools", []) } ) latency_ms = (time.perf_counter() - started_at) * 1000 log_event( "dispatch_complete", component="Orchestrator", trace_id=trace_id, fields={ "session_id": session_id, "route_name": routing.route_name, "confidence": routing.confidence, "low_confidence": routing.low_confidence, "skills_invoked": skill_names, "tools_invoked": tools_invoked, "fallback": is_fallback, "latency_ms": round(latency_ms, 2), }, ) record_metric( "orchestrator.latency_ms", latency_ms, tags={"fallback": "true" if is_fallback else "false"}, ) return response def _resolve_skill(self, name: str) -> BaseSkill: try: return self._skills[name] except KeyError as exc: known = ", ".join(sorted(self._skills)) or "(none)" raise OrchestratorError( f"RoutingDecision names skill '{name}', which the Orchestrator was not " f"given. Known: {known}" ) from exc @staticmethod def _assemble_response( *, agent_context: AgentContext, routing: RoutingDecision, skill_names: list[str], results: list[SkillResult], ) -> AgentResponse: content = "\n\n".join(result.content for result in results) citations = [citation for result in results for citation in result.citations] confidence = min((result.confidence for result in results), default=routing.confidence) per_skill_metadata: dict[str, Any] = { name: result.metadata for name, result in zip(skill_names, results) } return AgentResponse( content=content, session_id=agent_context.session_id, trace_id=agent_context.trace_id, route_name=routing.route_name, skills_used=skill_names, low_confidence=routing.low_confidence, confidence=confidence, citations=citations, metadata={"skills": per_skill_metadata}, )
Follow the work.
Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.