Skip to content
← How we build

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.

No spam. Unsubscribe anytime.