/

Developer Guide

Agent Service Architecture

Bounded context: Knowledge & Assistance (shared with chat-service) · Stack: Python / FastAPI · Code-level walkthrough: Agent Service

The agent-service answers one question at a time: it retrieves relevant documentation, decides whether to call a tool for live platform data, and produces a cited natural-language answer. It holds no conversation state — that’s chat-service’s job — and it never trusts its own judgement about what it’s allowed to cite or access without checking.


Architectural Style

Three real interface-based ports, Python’s Protocol standing in for what the Go services do with explicit interfaces: LLMClient (agent/llm/base.py) is implemented by OpenAICompatClient; Embedder (embeddings/base.py) is implemented by an OpenAI embeddings adapter; Reranker (retrieval/reranker/base.py) is implemented by a no-op reranker today. Each port is swappable without touching the code that depends on it.

Tools follow the same microkernel shape sensor-service uses for sensor types: Tool[ArgsT] (agent/tools/base.py) is a generic Protocolsearch_docs, sensor, twin, and downstream each implement it and register themselves in a ToolRegistry (agent/tools/registry.py). The agentic loop never branches on which tool it’s calling; it asks the registry for JSON schemas to hand the LLM, and looks up whichever tool the LLM decided to call by name.

graph LR
    subgraph Driving
        ASK["routes/ask.py"]
    end
    subgraph "Core — agent/loop.py"
        LOOP["Agent.answer\nbounded by max_tool_hops"]
    end
    subgraph "Ports"
        P1{{"LLMClient"}}
        P2{{"Tool[ArgsT]"}}
    end
    subgraph "Adapters"
        LLM["OpenAICompatClient"]
        T1["search_docs"]
        T2["sensor"]
        T3["twin"]
        T4["downstream"]
    end
    REG["ToolRegistry\nagent/tools/registry.py"]
    ASK -->|"direct call — no port"| LOOP
    LOOP --> P1
    P1 -.->|implements| LLM
    LOOP -->|"schemas() · get(name)"| REG
    REG --> P2
    P2 -.->|implements| T1
    P2 -.->|implements| T2
    P2 -.->|implements| T3
    P2 -.->|implements| T4

Each hop of the loop is one call to the LLM with the current message history and every registered tool’s schema. If the model calls no tool, the loop returns the answer; if it calls one or more, the loop runs them, appends their results as tool messages, and starts another hop — up to max_tool_hops, so a model that keeps calling tools can’t run forever. Citations are checked against what was actually retrieved (extract_citations/strip_hallucinated in citations.py) before the answer goes out — a citation the model invented rather than pulled from a real retrieved chunk gets stripped, not trusted.

Retrieval is a separate pipeline the loop calls into through the search_docs tool, not something the loop orchestrates directly:

graph LR
    Q["question"] --> EMB["embed_query — Embedder port"]
    EMB --> VEC["vector_search"]
    Q --> KW["keyword_search"]
    VEC --> RRF["Reciprocal Rank Fusion merge"]
    KW --> RRF
    RRF --> RR["Reranker port\n(no-op today)"]
    RR --> CHUNKS["top_k_final chunks"]

Vector and keyword search both take the caller’s permissions as an argument and filter at the query level — an inaccessible chunk never reaches the merge step, let alone the reranker or the model. Authorization here is enforced twice, independently: once at retrieval (which chunks the model is even allowed to see) and once at the tool level (which live platform data a tool call is allowed to return).


Key Architectural Decisions


Integration

For the ingestion pipeline, the chunking strategy, evals, and the API, see the Agent Service internals page.