Skip to content
← How we build

backend/src/agents/context_builder.py

"""Context Builder — no-op Phase 1 implementation (`context-builder-design.md`, Revision 3).

Exists to establish the interface boundary Phase 2 (memory + RAG retrieval) will fill
in, so that growth lands in this one module instead of the Orchestrator. Milestone 7
delivers the class and its unit tests only — the Orchestrator's conditional call site
(invoked only on the low-confidence/no-route branch, after the Router, never on the
high-confidence path) is Milestone 8 scope.
"""

from __future__ import annotations

import logging
import time
from abc import ABC, abstractmethod
from typing import Any

from agents.base.agent_context import AgentContext
from models.context import RequestContext
from observability import log_event, record_metric


class ContextBuilder(ABC):
    """Produces a `RequestContext` from the raw query + `AgentContext`.

    Never called by the Router (which stays a pure function of the raw query) and
    never called by Skills directly — only the Orchestrator calls this, and only on
    the low-confidence/no-route branch (`context-builder-design.md` §2, §8).
    """

    @abstractmethod
    async def build(self, query: str, agent_context: AgentContext) -> RequestContext:
        """Build the `RequestContext` for this query."""

    def _emit_start(
        self,
        *,
        trace_id: str | None = None,
        session_id: str | None = None,
    ) -> None:
        log_event(
            "build_start",
            component=type(self).__name__,
            trace_id=trace_id,
            fields={"session_id": session_id},
        )

    def _emit_complete(
        self,
        *,
        latency_ms: float,
        retrieved_context_count: int,
        trace_id: str | None = None,
        session_id: str | None = None,
    ) -> None:
        fields: dict[str, Any] = {
            "session_id": session_id,
            "retrieved_context_count": retrieved_context_count,
            "latency_ms": round(latency_ms, 2),
        }
        log_event(
            "build_complete",
            component=type(self).__name__,
            trace_id=trace_id,
            fields=fields,
            level=logging.INFO,
        )
        record_metric(
            "context_builder.latency_ms",
            latency_ms,
            tags={"component": type(self).__name__},
        )


class PassthroughContextBuilder(ContextBuilder):
    """Phase 1 no-op: no memory store, no vector index, no external calls."""

    async def build(self, query: str, agent_context: AgentContext) -> RequestContext:
        trace_id = agent_context.trace_id
        session_id = agent_context.session_id
        self._emit_start(trace_id=trace_id, session_id=session_id)
        started = time.perf_counter()

        context = RequestContext(
            user_query=query,
            conversation_history=[],
            retrieved_context=[],
            session_id=agent_context.session_id,
            trace_id=agent_context.trace_id,
            system_prompt=agent_context.system_prompt,
        )

        latency_ms = (time.perf_counter() - started) * 1000
        self._emit_complete(
            latency_ms=latency_ms,
            retrieved_context_count=len(context.retrieved_context),
            trace_id=trace_id,
            session_id=session_id,
        )
        return context

Follow the work.

Occasional updates on SignalFoundry, MarketCompass, and what we are building at CompassFoundry Labs.

No spam. Unsubscribe anytime.