Skip to content

Agents

LangChain layer that builds and runs the chat agent: model factory, prompts, tools, middleware and the conversation checkpointer.

src.agents

LangChain/LangGraph agent layer for Erudi.

A thin transverse layer (consumed by domains/conversations and domains/arena services) that wraps the engine behind LangChain primitives:

  • model_factory — build a ChatOpenAI pointed at the local engine server and the engine-level serialization/idle-marker guard.
  • runner — AgentRunner: one create_agent per turn, streamed as raw token text for the existing StreamingResponse(text/plain) contract.
  • middleware — per-turn request-time middleware (KB merge, image/tool-result hygiene), imported lazily by the runner so LangChain stays out of boot (#160).
  • prompts — size-adaptive system prompt construction.
  • checkpoint — PostgreSQL checkpointer wiring (AsyncPostgresSaver on the erudi database).

src.agents.checkpoint

LangGraph PostgreSQL checkpointer wiring.

The agent's conversation state (message history + summaries) is persisted by a LangGraph AsyncPostgresSaver in the same embedded-PostgreSQL database as the business schema (one erudi database, one cluster backup captures everything). The LangGraph-managed tables (checkpoints, checkpoint_writes, …) live side by side with the SQLAlchemy tables but are owned exclusively by the saver.

The saver is held open for the whole application lifetime (FastAPI lifespan enters the CM on startup and exits it on shutdown, BEFORE the cluster stops) and exposed on app.state.checkpointer; endpoints reach it via get_checkpointer. In tests the client fixture injects an InMemorySaver.

get_checkpointer(request)

FastAPI dependency returning the app-wide checkpointer from app.state.

Set during lifespan startup (AsyncPostgresSaver). In tests the client fixture injects an InMemorySaver onto app.state.checkpointer.

Source code in backend/src/agents/checkpoint.py
def get_checkpointer(request: Request) -> BaseCheckpointSaver:
    """FastAPI dependency returning the app-wide checkpointer from ``app.state``.

    Set during ``lifespan`` startup (``AsyncPostgresSaver``). In tests the
    ``client`` fixture injects an ``InMemorySaver`` onto
    ``app.state.checkpointer``.
    """
    return request.app.state.checkpointer

open_checkpointer(conn_string) async

Open an AsyncPostgresSaver held for the whole app lifetime.

AsyncPostgresSaver.from_conn_string is itself an async context manager that CLOSES its psycopg connection on __aexit__, so we wrap it and keep it entered across the lifespan (the lifespan enters this CM manually and exits it on shutdown). setup() creates/migrates the LangGraph tables (idempotent).

Parameters:

Name Type Description Default
conn_string str

Raw psycopg URI (postgresql://… — NOT the postgresql+psycopg:// SQLAlchemy form), e.g. PostgresHandle.psycopg_url.

required

Yields a ready-to-use saver.

Source code in backend/src/agents/checkpoint.py
@asynccontextmanager
async def open_checkpointer(conn_string: str) -> AsyncIterator[AsyncPostgresSaver]:
    """Open an ``AsyncPostgresSaver`` held for the whole app lifetime.

    ``AsyncPostgresSaver.from_conn_string`` is itself an async context manager
    that CLOSES its psycopg connection on ``__aexit__``, so we wrap it and keep
    it entered across the lifespan (the lifespan enters this CM manually and
    exits it on shutdown). ``setup()`` creates/migrates the LangGraph tables
    (idempotent).

    Args:
        conn_string: Raw psycopg URI (``postgresql://…`` — NOT the
            ``postgresql+psycopg://`` SQLAlchemy form), e.g.
            ``PostgresHandle.psycopg_url``.

    Yields a ready-to-use saver.
    """
    async with AsyncPostgresSaver.from_conn_string(conn_string) as saver:
        await saver.setup()  # creates/migrates LangGraph tables (idempotent)
        logger.info("Conversation checkpointer ready (PostgreSQL)")
        yield saver

src.agents.kb_mode

Per-turn KB mode routing for conversation/arena (issue #84).

The KB mode is DERIVED from the model, never a user toggle: - KB attached & the size tier allows context & the model's tool calls are VERIFIED to work on this engine's wire (#298) -> AGENTIC: the KB is exposed as the search_knowledge_base tool and the model decides when to consult it (no systematic injection). - KB attached & tier allows & not verified -> SYSTEMATIC: excerpts retrieved up front and merged request-time (unchanged). - otherwise -> PLAIN: zero tools (#129) — plain chat never pays the tool-scaffolding cost, whatever the model's capability.

Agentic gating (#298): tool-calling reliability is a per-model WIRE property (#273 matrix — same server, some templates parse, others leak raw JSON or get swallowed, #295), so the blanket #288 kill switch became the tri-state config.KB_AGENTIC_MODE:

agentic iff should_use_kb(llm) AND (flag is True
                                    OR (flag is None AND supports_tools
                                        AND supports_tools_wire is True))

Flag None (default) = per-model routing on the verified capability; True = force agentic (debug); False = force systematic (kill switch). A NULL/False wire verdict always routes systematic.

Factored here so conversation and arena share one decision. Retrieval is injected as a callable so each caller keeps its own failure policy (conversation degrades to no-context, arena raises); it is only invoked in systematic mode.

TurnPlan(system_prompt, tools, kb_context_block, kb_language_line, context) dataclass

The runner bundle for one turn: prompt, tools, KB block, tool context.

plan_turn(llm, *, question, retrieve, custom_prompt=None, starred_messages=None, web_search_enabled=False, effort_section=None)

Decide the turn's mode and build the runner bundle.

retrieve is only called in systematic mode: agentic mode defers retrieval to the model's tool call, and plain mode has no KB.

effort_section is the graded reasoning instruction for this turn (1.1.2, src.agents.reasoning_effort) or None when the level needs no instruction. Every mode takes it in the same slot -- after the tool regime, before the user's own instructions -- so the level means the same thing on a plain, a systematic and an agentic turn.

web_search_enabled is the conversation's toggle (#310, copied from the global default at creation; arena passes the global setting directly). The web_search tool joins the turn iff the toggle is on AND the model is wire-capable — the SAME gate as agentic KB (#301): supports_tools AND supports_tools_wire is True. It rides plain turns (plain+web becomes a tool turn) and agentic-KB turns (both tools); the systematic path stays zero-tool, toggle or not — a non-wire model cannot execute tools, and the debug force-systematic flag deliberately keeps its zero-tool contract. The tool is ALWAYS exposed when gated in, online or not: offline turns get the tool's honest deterministic failure text instead of a missing tool.

Source code in backend/src/agents/kb_mode.py
def plan_turn(
    llm,
    *,
    question: str,
    retrieve: Callable[[], List[KbExcerpt]],
    custom_prompt: Optional[str] = None,
    starred_messages: Optional[List[str]] = None,
    web_search_enabled: bool = False,
    effort_section: Optional[str] = None,
) -> TurnPlan:
    """Decide the turn's mode and build the runner bundle.

    ``retrieve`` is only called in systematic mode: agentic mode defers
    retrieval to the model's tool call, and plain mode has no KB.

    ``effort_section`` is the graded reasoning instruction for this turn
    (1.1.2, ``src.agents.reasoning_effort``) or ``None`` when the level needs
    no instruction. Every mode takes it in the same slot -- after the tool
    regime, before the user's own instructions -- so the level means the same
    thing on a plain, a systematic and an agentic turn.

    ``web_search_enabled`` is the conversation's toggle (#310, copied from the
    global default at creation; arena passes the global setting directly). The
    web_search tool joins the turn iff the toggle is on AND the model is
    wire-capable — the SAME gate as agentic KB (#301): supports_tools AND
    supports_tools_wire is True. It rides plain turns (plain+web becomes a
    tool turn) and agentic-KB turns (both tools); the systematic path stays
    zero-tool, toggle or not — a non-wire model cannot execute tools, and the
    debug force-systematic flag deliberately keeps its zero-tool contract.
    The tool is ALWAYS exposed when gated in, online or not: offline turns get
    the tool's honest deterministic failure text instead of a missing tool.
    """
    # Deferred (#160): importing the tools module pulls the LangChain ``@tool``
    # machinery, needed on turns only — never at boot. Deterministic tools are
    # carried by KB turns only (the KB tool is added in agentic mode); plain
    # chat is zero-tool (#129) unless the web toggle turns it into a tool turn.
    from src.agents.tools import (
        TurnToolContext,
        calculator,
        search_knowledge_base,
        web_search,
    )
    from src.core import config

    base_tools = [calculator]

    # #298 agentic gate: the tri-state flag arbitrates, the per-model verified
    # wire capability decides in the default (flag None) state.
    flag = config.KB_AGENTIC_MODE
    supports_tools = bool(getattr(llm, "supports_tools", False))
    wire = getattr(llm, "supports_tools_wire", None)
    agentic = should_use_kb(llm) and (
        flag is True or (flag is None and supports_tools and wire is True)
    )

    # #310 web gate: strictly the verified wire capability — the KB tri-state
    # flag never bypasses it (a forced-agentic debug run on an unverified wire
    # still gets no web tool).
    wire_capable = supports_tools and wire is True
    web = web_search_enabled and wire_capable

    def _log_web(mode_allows_tools: bool) -> None:
        if not web_search_enabled:
            return
        if web and mode_allows_tools:
            logger.info(
                "Turn tools: web_search enabled "
                "(decided_by=web toggle on + verified wire capability)"
            )
        elif not wire_capable:
            logger.info(
                "Turn tools: web_search unavailable "
                "(model tool-call wire capability not verified)"
            )
        else:
            logger.info("Turn tools: web_search unavailable (systematic KB path is zero-tool)")

    if agentic:
        if flag is True:
            decided_by = "flag force-agentic (ERUDI_KB_AGENTIC=1)"
        else:
            decided_by = (
                "per-model verified wire capability "
                "(supports_tools=True, supports_tools_wire=True)"
            )
        budget = get_prompting_strategy(_param_size(llm))["kb_token_budget"]
        _log_web(mode_allows_tools=True)
        logger.info(
            f"Turn mode: agentic KB (kb_id={getattr(llm, 'kb_id', None)}, "
            f"decided_by={decided_by}, web_search={'on' if web else 'off'})"
        )
        return TurnPlan(
            system_prompt=build_kb_agentic_system_prompt(
                llm,
                custom_prompt=custom_prompt,
                starred_messages=starred_messages,
                web_search=web,
                effort_section=effort_section,
            ),
            tools=[*base_tools, search_knowledge_base, *([web_search] if web else [])],
            kb_context_block=None,
            kb_language_line="",
            context=TurnToolContext(
                kb_id=llm.kb_id,
                kb_token_budget=budget,
                web_token_budget=budget,
            ),
        )

    # Systematic: retrieve() encapsulates is_attached + tier + failure policy.
    excerpts = retrieve()
    if excerpts:
        # Name the gate that kept this KB turn off the agentic path (#298).
        if flag is False:
            decided_by = "flag force-systematic (ERUDI_KB_AGENTIC=0)"
        elif not supports_tools:
            decided_by = "model does not declare tool support"
        elif wire is None:
            decided_by = "tool-call wire capability unverified (NULL)"
        else:
            decided_by = "tool-call wire capability verified unreliable (False)"
        _log_web(mode_allows_tools=False)
        logger.info(
            f"Turn mode: systematic KB (kb_id={getattr(llm, 'kb_id', None)}, "
            f"excerpts={len(excerpts)}, decided_by={decided_by})"
        )
        return TurnPlan(
            system_prompt=build_kb_system_prompt(
                llm,
                custom_prompt=custom_prompt,
                starred_messages=starred_messages,
                effort_section=effort_section,
            ),
            # Zero tools on the systematic path (#288): the context is injected
            # directly, so the model only has to answer. Carrying the calculator
            # here bought nothing for document Q&A and made leak-prone models
            # (e.g. Llama 3.1 8B) wrap the answer in a raw calculator tool-call
            # JSON. Same #129 rationale that made plain chat zero-tool.
            tools=[],
            kb_context_block=build_kb_context_block(excerpts=excerpts, question=question),
            kb_language_line=answer_language_line(question),
            context=None,
        )

    if not getattr(llm, "is_attached_to_kb", False):
        plain_reason = "no KB attached"
    elif not should_use_kb(llm):
        plain_reason = "size tier disables KB context"
    else:
        plain_reason = "KB retrieval returned no excerpts"
    _log_web(mode_allows_tools=True)
    logger.info(
        f"Turn mode: plain (reason={plain_reason}, " f"web_search={'on' if web else 'off'})"
    )
    # Zero tools in plain mode (#129): the calculator was a demo tool whose
    # scaffolding cost hurt every model (and wrecked small ones) for no product
    # value outside KB turns. The web toggle is the ONE exception (#310):
    # a wire-capable model with the toggle on carries web_search alone.
    if web:
        budget = get_prompting_strategy(_param_size(llm))["kb_token_budget"]
        return TurnPlan(
            system_prompt=build_agent_system_prompt(
                llm,
                custom_prompt=custom_prompt,
                starred_messages=starred_messages,
                web_search=True,
                effort_section=effort_section,
            ),
            tools=[web_search],
            kb_context_block=None,
            kb_language_line="",
            context=TurnToolContext(web_token_budget=budget),
        )
    return TurnPlan(
        system_prompt=build_agent_system_prompt(
            llm,
            custom_prompt=custom_prompt,
            starred_messages=starred_messages,
            effort_section=effort_section,
        ),
        tools=[],
        kb_context_block=None,
        kb_language_line="",
        context=None,
    )

should_use_kb(llm)

True when the model has a KB attached AND its size tier allows context.

Source code in backend/src/agents/kb_mode.py
def should_use_kb(llm) -> bool:
    """True when the model has a KB attached AND its size tier allows context."""
    if not getattr(llm, "is_attached_to_kb", False):
        return False
    return get_prompting_strategy(_param_size(llm)).get("use_kb_context", False)

src.agents.language

Question-language detection for the KB prompt's answer-language line.

Multilingual RAG drifts toward English (the "semantic attractor" — measured in the language-drift literature), and a generic "same language" line is the weakest counter-measure. The strongest prompt-level move is a dynamic instruction written IN the target language ("Réponds en français."), which needs the question's language: py3langid (resident ~2 MB model, BSD), with a confidence floor so short/ambiguous turns fall back to the generic line instead of guessing.

detect_language(text)

ISO 639-1 code of text's language, or None when unconfident.

Source code in backend/src/agents/language.py
def detect_language(text: str) -> Optional[str]:
    """ISO 639-1 code of ``text``'s language, or None when unconfident."""
    text = (text or "").strip()
    if not text:
        return None
    identifier = _get_identifier()
    if identifier is None:
        return None
    try:
        language, probability = identifier.classify(text)
    except Exception:
        # Per-turn and best-effort: the generic line is the fallback, and the
        # text itself is already in the request's INFO records.
        logger.debug("Language detection failed for this turn", exc_info=True)
        return None
    return language if probability >= MIN_CONFIDENCE else None

src.agents.middleware

Per-turn request-time agent middleware (KB merge, image/tool-result hygiene).

Factored out of runner.py so the runner module itself stays free of module-level LangChain imports: this module subclasses AgentMiddleware at class-definition time, so it is imported LAZILY (inside the runner's methods) and the whole LangChain agent stack only loads on the first turn, not at boot (issue #160).

past_tool_result_ids(messages)

Message ids of the tool results strip_stale_tool_results marks.

Message ids, not tool-call ids: a parser with deterministic call ids (mlx_vlm's kimi_k2 parser emits functions.<name>:0) reuses the same call id every turn, while message ids are unique once _ensure_message_ids ran. The compaction counter designates past results through this set, so it counts any list LangChain hands it -- suffixes, the reversed pool of trim_messages, partial copies -- as sent, without knowing where the list came from.

Source code in backend/src/agents/middleware.py
def past_tool_result_ids(messages) -> frozenset:
    """Message ids of the tool results ``strip_stale_tool_results`` marks.

    Message ids, not tool-call ids: a parser with deterministic call ids
    (mlx_vlm's kimi_k2 parser emits ``functions.<name>:0``) reuses the same
    call id every turn, while message ids are unique once
    ``_ensure_message_ids`` ran. The compaction counter designates past
    results through this set, so it counts any list LangChain hands it --
    suffixes, the reversed pool of ``trim_messages``, partial copies -- as
    sent, without knowing where the list came from.
    """
    messages = list(messages)
    keep = last_human_index(messages)
    if keep is None:
        return frozenset()
    return frozenset(
        message.id
        for message in messages[:keep]
        if message.type == "tool"
        and message.id is not None
        and getattr(message, "name", None) in STALE_TOOL_RESULT_MARKERS
    )

strip_stale_tool_results(messages, *, all_past=False)

messages as sent: past turns' KB/web results replaced by their markers.

Pure (copies, never mutates). A result is past when it lies before the last user message -- the positional rule _StripStaleToolResults applies to every request. all_past=True replaces every such result: the summarizer's pool holds only past turns, and the amber-warning projection judges the NEXT request, where the turn just answered is past.

Source code in backend/src/agents/middleware.py
def strip_stale_tool_results(messages, *, all_past: bool = False) -> list:
    """``messages`` as sent: past turns' KB/web results replaced by their markers.

    Pure (copies, never mutates). A result is past when it lies before the
    last user message -- the positional rule ``_StripStaleToolResults``
    applies to every request. ``all_past=True`` replaces every such result:
    the summarizer's pool holds only past turns, and the amber-warning
    projection judges the NEXT request, where the turn just answered is past.
    """
    messages = list(messages)
    if all_past:
        keep = len(messages)
    else:
        keep = last_human_index(messages)
        if keep is None:
            return messages
    for i, message in enumerate(messages[:keep]):
        messages[i] = stale_result_copy(message)
    return messages

src.agents.model_factory

Build a ChatOpenAI pointed at the local engine's OpenAI-compatible server.

The engine (MLX/CUDA/CPU) spawns a child server and exposes its /v1/chat/completions endpoint; ChatOpenAI(base_url=...) talks to it. get_model_and_tokenizer is the authority that spawns/selects the child and hands back the base_url; the engine no longer parses SSE itself — token streaming is owned by this ChatOpenAI layer.

The client is an Erudi_Chat_OpenAI (src.agents.chat_model): a ChatOpenAI whose async stream carries the two #573 wall-clock budgets (prompt-sized before the first token, fixed between tokens) in place of langchain's single uniform stream_chunk_timeout.

build_chat_model(llm, *, temperature, top_p, max_tokens, repetition_penalty=DEFAULT_REPETITION_PENALTY, repetition_context_size=DEFAULT_REPETITION_CONTEXT_SIZE, disable_thinking=False, auto_output_budget=True, sampling=None, effort_plan=None, kb_additions='', preflight_retry=True, record_usage=False)

Resolve the engine child for llm and wrap it as a ChatOpenAI.

SYNC and potentially slow: get_model_and_tokenizer spawns/probes the child server under the engine lock, so call this via run_in_threadpool from async code (the agent runner does).

The model field MUST go through the engine's _payload_model_value — mlx_vlm.server resolves it via get_cached_model(request.model) so MLX sends the real preloaded model path, while llama.cpp sends the alias; hardcoding either would break the other.

Params are set on the constructor (NOT via .bind — LangChain v1 rejects pre-bound models passed to create_agent).

max_tokens is normally only the FALLBACK output budget: the client recomputes a real one per model call from the window it is running in (src.agents.output_budget). auto_output_budget=False turns that off for the callers whose budget is deliberate -- the one-shot title path and the capped compaction summary.

kb_additions is the KB text this turn's requests carry (block, joins, language line): a request that carried it stamps its size, so the ratio it measured excludes a block the next request no longer holds. preflight_retry=False turns off the single retry with a smaller cap after a context-check rejection (the summary client: a smaller cap would truncate the summary).

Every client asks the server for usage (stream_usage=True): the last chunk then carries the real prompt size, which the client stamps with its own estimate of the request (Erudi_Chat_OpenAI._astream). record_usage=True (the conversation and Arena clients only) also pushes each call's usage to the engine handle the client was built against (note_call_usage), where the MLX engine measures what a turn really used; the summary and title clients push nothing.

effort_plan carries the turn's reasoning effort (1.1.2). Its wire_effort rides the NATIVE reasoning_effort request field, not extra_body: it is a first-class field of all three layers (the ChatOpenAI constructor, llama-server, mlx_vlm). None (no plan, or a level no native lever carries) leaves it unset, and langchain drops unset fields from the payload -- so the request body stays byte-identical to today's.

Source code in backend/src/agents/model_factory.py
def build_chat_model(
    llm,
    *,
    temperature: float,
    top_p: float,
    max_tokens: int,
    repetition_penalty: float = DEFAULT_REPETITION_PENALTY,
    repetition_context_size: int = DEFAULT_REPETITION_CONTEXT_SIZE,
    disable_thinking: bool = False,
    auto_output_budget: bool = True,
    sampling: Optional[SamplingDefaults] = None,
    effort_plan: Optional[EffortPlan] = None,
    kb_additions: str = "",
    preflight_retry: bool = True,
    record_usage: bool = False,
) -> ChatOpenAI:
    """Resolve the engine child for ``llm`` and wrap it as a ``ChatOpenAI``.

    SYNC and potentially slow: ``get_model_and_tokenizer`` spawns/probes the
    child server under the engine lock, so call this via ``run_in_threadpool``
    from async code (the agent runner does).

    The ``model`` field MUST go through the engine's ``_payload_model_value`` —
    mlx_vlm.server resolves it via ``get_cached_model(request.model)`` so MLX
    sends the real preloaded model path, while llama.cpp sends the alias;
    hardcoding either would break the other.

    Params are set on the constructor (NOT via ``.bind`` — LangChain v1 rejects
    pre-bound models passed to ``create_agent``).

    ``max_tokens`` is normally only the FALLBACK output budget: the client
    recomputes a real one per model call from the window it is running in
    (``src.agents.output_budget``). ``auto_output_budget=False`` turns that off
    for the callers whose budget is deliberate -- the one-shot title path and
    the capped compaction summary.

    ``kb_additions`` is the KB text this turn's requests carry (block, joins,
    language line): a request that carried it stamps its size, so the ratio
    it measured excludes a block the next request no longer holds.
    ``preflight_retry=False``
    turns off the single retry with a smaller cap after a context-check
    rejection (the summary client: a smaller cap would truncate the summary).

    Every client asks the server for usage (``stream_usage=True``): the last
    chunk then carries the real prompt size, which the client stamps with its
    own estimate of the request (``Erudi_Chat_OpenAI._astream``).
    ``record_usage=True`` (the conversation and Arena clients only) also
    pushes each call's usage to the engine handle the client was built
    against (``note_call_usage``), where the MLX engine measures what a turn
    really used; the summary and title clients push nothing.

    ``effort_plan`` carries the turn's reasoning effort (1.1.2). Its
    ``wire_effort`` rides the NATIVE ``reasoning_effort`` request field, not
    ``extra_body``: it is a first-class field of all three layers (the
    ``ChatOpenAI`` constructor, llama-server, mlx_vlm). ``None`` (no plan, or a
    level no native lever carries) leaves it unset, and langchain drops unset
    fields from the payload -- so the request body stays byte-identical to
    today's.
    """
    # Deferred (#160): langchain_openai only loads on the first turn, not at
    # boot -- the subclass that inherits from it is built on the same first call.
    chat_openai_class = erudi_chat_openai_class()

    engine = config.LLM_Engine
    handle, _tokenizer = engine.get_model_and_tokenizer(llm.id, llm.link)
    model_field = engine._payload_model_value(handle)

    # The ALLOCATED window of the child just resolved (llama-server: read from
    # /props after its fit; MLX: the --max-kv-size bound stamped at spawn, or
    # the memory bound the child was lowered to after load when memory binds).
    # Handed to the client so the first-chunk watchdog ceiling scales with the
    # window a full-window prompt can now legitimately fill -- without it, the
    # dynamic context window would recreate the #573 kill (a healthy long
    # prefill cut at the fixed 900 s ceiling). Fresh per turn: this factory
    # runs on every turn, after any model swap. None = window unknown.
    window_probe = getattr(engine, "effective_context_tokens", None)
    effective_window = window_probe() if callable(window_probe) else None

    # The MEMORY value for the SAME client: the ONE canonical working window
    # (min of the allocated window and the memory ceiling, over the known
    # candidates -- src.engines.working_window). Only the output budget reads
    # it; the first-chunk watchdog and preflight retry keep the raw allocated
    # window above. ``working_context_tokens`` calls ``MemoryBudget.from_engine``
    # (the loaded child's static facts are read from disk once and cached on
    # its handle), which is safe here because this factory runs in a
    # threadpool per turn.
    working_window = working_context_tokens(engine)
    # What a digit costs on this child's tokenizer, for the output budget (a
    # static fact read once per child from its tokenizer.json, MLX only).
    digit_tokens = budget_digit_tokens(engine)

    # Extra sampling params absent from the OpenAI wire schema. mlx_vlm.server reads
    # the HF names natively; llama.cpp engines translate them to their wire names
    # (repeat_penalty / repeat_last_n) via ``_translate_payload_kwargs``. Sent via
    # ChatOpenAI.extra_body so they land in the local server's chat-completions
    # body. (getattr keeps non-server engines / test stubs working via identity.)
    # The MLX translation also stamps a fresh random ``seed`` per request: this
    # factory runs once per turn, so every generation gets its own, which is what
    # makes temperature / top_p / top_k actually vary the output on Apple Silicon
    # (mlx_vlm.server replays a fixed default seed when the request has none).
    # #388: a resolved per-model profile supplies the repetition controls and,
    # ONLY when it defines them, top_k / min_p / presence_penalty. Without a
    # profile (or for a model without hints, whose profile is the fallback)
    # the body is byte-identical to the #129-validated one above.
    if sampling is not None:
        raw_kwargs = sampling.wire_kwargs()
    else:
        raw_kwargs = {
            "repetition_penalty": repetition_penalty,
            "repetition_context_size": repetition_context_size,
        }
    # Suppress reasoning at the chat-template level (#266): one-shot utility
    # calls (e.g. conversation titles) run on a ~12-token budget that a thinking
    # model would burn entirely inside <think>. mlx_vlm.server reads the
    # per-request ``enable_thinking`` field natively (it overrides the server
    # default); llama.cpp engines translate it to ``chat_template_kwargs`` in
    # their ``_translate_payload_kwargs``. Chat paths never pass
    # ``disable_thinking``, so their request body stays byte-identical to today.
    if disable_thinking:
        raw_kwargs["enable_thinking"] = False
    translate = getattr(engine, "_translate_payload_kwargs", lambda kw: kw)
    extra_body = translate(raw_kwargs)

    # A stream that does not end normally flags the child it ran against
    # (``Erudi_Chat_OpenAI.abandon_hook``; the MLX engine then sends a barrier
    # before its next prefix-cache reset). Bound NOW to this handle, never
    # re-read from the engine later: a stream finalized after a model swap
    # must flag its own child, not whichever child is loaded by then.
    note_abandoned = getattr(engine, "note_stream_abandoned", None)
    abandon_hook = functools.partial(note_abandoned, handle) if callable(note_abandoned) else None
    # Same binding for the usage a call reports: it belongs to the turn's
    # measurement window on THIS child.
    note_usage = getattr(engine, "note_call_usage", None)
    usage_hook = (
        functools.partial(note_usage, handle) if record_usage and callable(note_usage) else None
    )
    note_start = getattr(engine, "note_call_start", None)
    call_start_hook = (
        functools.partial(note_start, handle) if record_usage and callable(note_start) else None
    )

    # Log the extra_body AS SENT (post-translation, so llama.cpp's wire names
    # show up), one key=value per entry on the same line: the optional profile
    # keys (top_k / min_p / presence_penalty, #388) are otherwise invisible in
    # the INFO log and a QA pass cannot confirm they reached the server.
    extra_body_desc = ", ".join(f"{key}={value}" for key, value in extra_body.items())
    wire_effort = effort_plan.wire_effort if effort_plan is not None else None
    logger.info(
        f"ChatOpenAI built: model={model_field}, base_url={handle['base_url']}/v1, "
        f"temperature={temperature}, top_p={top_p}, max_tokens={max_tokens}, "
        f"reasoning_effort={wire_effort or 'unset'}, "
        f"extra_body=[{extra_body_desc}]"
    )
    return chat_openai_class(
        base_url=f"{handle['base_url']}/v1",
        # Every inference child (llama-server, mlx_vlm.server) is spawned with a
        # per-spawn `--api-key` so nothing else on the loopback interface can
        # drive it, and the handle is the only place that key lives. A handle
        # without one keeps the literal: an empty api_key would make the OpenAI
        # client fall back to reading OPENAI_API_KEY from the environment.
        # Deliberately absent from the log line above.
        api_key=handle.get("api_key") or "not-needed",
        model=model_field,
        temperature=temperature,
        top_p=top_p,
        max_tokens=max_tokens,
        # Native field of all three layers (1.1.2): llama-server reads "none"
        # as enable_thinking=false and forwards any other value to the chat
        # template; mlx_vlm normalizes it the same way. None = unset, dropped
        # from the payload.
        reasoning_effort=wire_effort,
        extra_body=extra_body,  # restore small-model coherence (repetition controls)
        timeout=None,  # cold model load can stall several seconds before first token
        max_retries=0,  # don't silently double-submit a slow local generation
        # langchain's ONE uniform per-chunk budget (120 s) cannot tell a long
        # prefill from a hang and killed healthy long-history turns (#573).
        # Off here; ``Erudi_Chat_OpenAI._astream`` enforces the two budgets that
        # replace it.
        stream_chunk_timeout=None,
        effective_context_tokens=effective_window,
        working_context_tokens=working_window,
        auto_output_budget=auto_output_budget,
        kb_additions=kb_additions or "",
        digit_tokens=digit_tokens,
        preflight_retry=preflight_retry,
        abandon_hook=abandon_hook,
        usage_hook=usage_hook,
        call_start_hook=call_start_hook,
        streaming=True,
        # ``stream_options.include_usage``: both local servers then end the
        # stream with a usage chunk (mlx_vlm 0.6.17 and llama-server alike);
        # a server that sends none leaves every consumer on its estimate.
        stream_usage=True,
    )

src.agents.prompts

System-prompt construction for the conversation/arena agent.

Three prompts: - build_agent_system_prompt — size-tier prompt for plain assistants (reuses build_system_prompt / get_prompting_strategy). - build_kb_system_prompt / build_kb_agentic_system_prompt — KB prompts that COMPOSE instead of replacing (#129): the size-tier persona stays as the base (byte-identical to the plain path for the same model) and the KB regime is an APPENDED section. The historical replacement prompt ("You are {name}, a document analyst") measurably hurt everyday use — a 48-conversation baseline eval on a 7B saw 8/12 everyday questions trigger a pointless search, 3 outright refusals, "not in your documents" tails on correct answers, and doubled latency; on a 0.6B, "Source:" reflexes and raw tool syntax leaked into everyday prose. The agentic append therefore scopes the trigger to document-related questions while KEEPING the search-before-claiming-absence discipline (hardened for issue #84: soft phrasing under-called the tool). The systematic append frames the auto-retrieved excerpts as possibly irrelevant and opens an answer-normally escape hatch: the strict "answer ONLY from the excerpts" contract measurably refused everyday questions (0/2 on a 6-question matrix — capital-city and sleep-advice questions got "the information is not in the documents"). The ONE canonical abstention clause rides the per-turn reminder, SCOPED to document questions (stacked refusal rules measurably over-abstain).

The long-term-memory injection is gone: the running conversation summary lives in the LangGraph checkpointer (via SummarizationMiddleware).

answer_language_line(question)

The dynamic answer-language request, localized to the question's language (generic fallback when detection is unconfident). Appended by the runner's middleware AFTER the question — the one spot this model demonstrably honors (eval T5: in-question language requests are followed and even persist to the next turn).

Source code in backend/src/agents/prompts.py
def answer_language_line(question: str) -> str:
    """The dynamic answer-language request, localized to the question's
    language (generic fallback when detection is unconfident). Appended
    by the runner's middleware AFTER the question — the one spot this
    model demonstrably honors (eval T5: in-question language requests
    are followed and even persist to the next turn)."""
    return _LANGUAGE_LINES.get(detect_language(question), _GENERIC_LANGUAGE_LINE)

build_agent_system_prompt(llm, *, starred_messages=None, custom_prompt=None, web_search=False, effort_section=None)

Build the size-adaptive system prompt for llm as a real SystemMessage.

The old hand-rolled flow merged the system text into the first user message (some local models lack a system role); the OpenAI-compatible servers handle a proper system message per the model's chat template, so we pass it as-is.

web_search=True (#310) appends the scoped web section — the plain path without it stays byte-identical (regression-pinned). effort_section (1.1.2) takes the same slot on the zero-tool concat path as it does in _compose_prompt: after the regime, before the user's instructions.

Source code in backend/src/agents/prompts.py
def build_agent_system_prompt(
    llm,
    *,
    starred_messages: Optional[List[str]] = None,
    custom_prompt: Optional[str] = None,
    web_search: bool = False,
    effort_section: Optional[str] = None,
) -> str:
    """Build the size-adaptive system prompt for ``llm`` as a real ``SystemMessage``.

    The old hand-rolled flow merged the system text into the first user message
    (some local models lack a system role); the OpenAI-compatible servers handle
    a proper system message per the model's chat template, so we pass it as-is.

    ``web_search=True`` (#310) appends the scoped web section — the plain path
    without it stays byte-identical (regression-pinned). ``effort_section``
    (1.1.2) takes the same slot on the zero-tool concat path as it does in
    ``_compose_prompt``: after the regime, before the user's instructions.
    """
    strategy = _tier_strategy(llm)

    if web_search:
        size_category = strategy["system_prompt_size_category"]
        return _compose_prompt(
            llm,
            [_web_section(size_category, with_kb=False)],
            size_category=size_category,
            custom_prompt=custom_prompt,
            starred_messages=starred_messages,
            effort_section=effort_section,
        )

    sys_prompt = build_system_prompt(
        model_name=llm.name,
        size_category=strategy["system_prompt_size_category"],
        starred_messages=starred_messages or None,
    )

    if effort_section:
        sys_prompt += f"\n\n{effort_section}"

    if custom_prompt and custom_prompt.strip():
        sys_prompt += f"\nAdditional instructions: {custom_prompt.strip()}"

    return sys_prompt

build_kb_agentic_system_prompt(llm, *, custom_prompt=None, starred_messages=None, web_search=False, effort_section=None)

SYSTEM prompt for a TOOL-CALLING KB assistant (issues #84 / #129).

Composes the size-tier persona with an appended KB-tool section whose trigger is SCOPED to document-related questions (the broad "for ANY question" imperative over-triggered on everyday questions) while keeping the #84 discipline: never claim the information is absent before a search has come back empty. Tiny/small tiers get a compact variant. When the model searches, the tool result carries a localized language line in tail position (format_kb_tool_result); otherwise the tier persona's "user's language" line applies.

Source code in backend/src/agents/prompts.py
def build_kb_agentic_system_prompt(
    llm,
    *,
    custom_prompt: Optional[str] = None,
    starred_messages: Optional[List[str]] = None,
    web_search: bool = False,
    effort_section: Optional[str] = None,
) -> str:
    """SYSTEM prompt for a TOOL-CALLING KB assistant (issues #84 / #129).

    Composes the size-tier persona with an appended KB-tool section whose
    trigger is SCOPED to document-related questions (the broad "for ANY
    question" imperative over-triggered on everyday questions) while keeping
    the #84 discipline: never claim the information is absent before a
    search has come back empty. Tiny/small tiers get a compact variant.
    When the model searches, the tool result carries a localized language
    line in tail position (``format_kb_tool_result``); otherwise the tier
    persona's "user's language" line applies.
    """
    strategy = _tier_strategy(llm)
    size_category = strategy["system_prompt_size_category"]
    kb_section = (
        _KB_AGENTIC_SECTION_COMPACT
        if size_category in _COMPACT_KB_TIERS
        else _KB_AGENTIC_SECTION_FULL
    )
    sections = [kb_section]
    if web_search:
        # KB section first, then the web section with the arbitration clause
        # (#310): document questions go to the documents, fresh facts to the
        # web — the two triggers must never compete.
        sections.append(_web_section(size_category, with_kb=True))
    return _compose_prompt(
        llm,
        sections,
        size_category=size_category,
        custom_prompt=custom_prompt,
        starred_messages=starred_messages,
        effort_section=effort_section,
    )

build_kb_context_block(*, excerpts, question)

Per-turn KB block: attributed excerpts + grounding reminder, with the scaffolding LOCALIZED to the question's language.

The runner's middleware merges it into the model request's LAST user message (request-time only — never persisted): on small local models, instructions dissolve with turn depth when they live in the system prompt, while the tail of the last user message stays in the effective window. The answer-language line is NOT here: it goes AFTER the question (answer_language_line) — run-4 eval showed the model treats pre-question lines as document-block metadata and ignores them, while user-voiced post-question requests are followed.

Source code in backend/src/agents/prompts.py
def build_kb_context_block(*, excerpts: List["KbExcerpt"], question: str) -> str:
    """Per-turn KB block: attributed excerpts + grounding reminder, with
    the scaffolding LOCALIZED to the question's language.

    The runner's middleware merges it into the model request's LAST user
    message (request-time only — never persisted): on small local models,
    instructions dissolve with turn depth when they live in the system
    prompt, while the tail of the last user message stays in the effective
    window. The answer-language line is NOT here: it goes AFTER the
    question (``answer_language_line``) — run-4 eval showed the model
    treats pre-question lines as document-block metadata and ignores
    them, while user-voiced post-question requests are followed.
    """
    scaffold = _SCAFFOLDS.get(detect_language(question), _SCAFFOLDS["en"])
    blocks = "\n\n".join(
        f"[Document: {excerpt.source_file}]\n{excerpt.text}" for excerpt in excerpts
    )
    return f"{scaffold['header']}\n\n{blocks}\n\n{scaffold['reminder']}"

build_kb_system_prompt(llm, *, custom_prompt=None, starred_messages=None, effort_section=None)

SYSTEM prompt for a KB assistant on the systematic path (no tools).

Composes the size-tier persona with the appended excerpts contract (#129) — the persona base stays byte-identical to the plain path, so attaching a KB no longer turns the assistant into a refusal-prone "document analyst" for everyday questions.

Source code in backend/src/agents/prompts.py
def build_kb_system_prompt(
    llm,
    *,
    custom_prompt: Optional[str] = None,
    starred_messages: Optional[List[str]] = None,
    effort_section: Optional[str] = None,
) -> str:
    """SYSTEM prompt for a KB assistant on the systematic path (no tools).

    Composes the size-tier persona with the appended excerpts contract
    (#129) — the persona base stays byte-identical to the plain path, so
    attaching a KB no longer turns the assistant into a refusal-prone
    "document analyst" for everyday questions.
    """
    strategy = _tier_strategy(llm)
    return _compose_prompt(
        llm,
        [_KB_SYSTEMATIC_SECTION],
        size_category=strategy["system_prompt_size_category"],
        custom_prompt=custom_prompt,
        starred_messages=starred_messages,
        effort_section=effort_section,
    )

src.agents.runner

AgentRunner — the shared conversation/arena streaming primitive.

One create_agent per turn. The turn is captured as STRUCTURED EVENTS (#90): _astream_events yields dicts — {"t":"answer",...}, {"t":"thinking",...}, {"t":"tool_call",...}, {"t":"tool_result",...} — so thinking and tool activity are surfaced instead of dropped. astream_text is a thin projection over those events with two modes selected by emit_events:

  • emit_events=True (conversations): yields the event dicts unchanged; the conversation service frames them as NDJSON and persists a replayable trace.
  • emit_events=False (arena / default): yields ONLY answer text as str, dropping thinking + tool events — byte-for-byte the old plain-text contract, so arena and its wire stay untouched. Reasoning stays hidden there because the same splitter strips inline <think> before the text is yielded.

  • Conversation: thread_id set + summarize=True + a checkpointer → history is restored from the checkpointer (only the new message is sent), and old turns are summarized in the agent state.

  • Arena: thread_id=None + summarize=False + no checkpointer → a stateless single-model call.

Thinking events come from two sources (#554). The primary one is the dedicated reasoning channel: both local servers extract each family's chain-of-thought server-side (llama-server's default --reasoning-format auto, mlx_vlm's native split) and Erudi_Chat_OpenAI re-attaches it to every streamed chunk as additional_kwargs["reasoning_content"]. The fallback is the streaming ThinkSplitter on the content channel, for families whose markers the server parser does not know -- and Arena's plain-text projection depends on it to keep inline <think> out of its wire. A turn that ends with reasoning but no answer text (and no tool result to fall back to, #90) yields a curated empty-answer line -- a NORMAL answer picked by finish_reason, never the ERROR sentinel, so the trace survives persistence -- and, on stateful runs, writes that line into the thread state in place of the empty assistant message.

On tool-carrying turns (#297), text a model hop streams BEFORE its tool call is not the answer — it is pre-answer narration (often hallucinated guessing on small local models: an invented payload figure narrated at length, THEN the search_knowledge_base call, THEN the grounded answer). The capture loop therefore BUFFERS each hop's post-splitter answer text instead of yielding it: the moment the hop's first tool_call_chunk arrives, the buffer is re-emitted as thinking events (before the tool_call event) and further text in that hop streams as thinking too; a hop that ends WITHOUT a tool call flushes its buffer as answer at stream end. The accepted cost is that on agentic turns the final answer's text appears at hop end rather than token-by-token; plain and systematic (zero-tool) turns are untouched and still stream live.

Everything runs inside engine.generation_guard() so model resolution + the whole stream serialize on the single-model engine and the idle-cleanup monitor never reaps the model mid-stream.

LangChain imports are deferred to the methods that use them (issue #160): this module is imported at boot by the conversation/arena services, but the agent stack is only needed on the FIRST turn, so keeping the imports function-scoped keeps import src.main fast. build_chat_model stays a module-level name — tests monkeypatch runner.build_chat_model — and is itself LangChain-free at import time (ChatOpenAI is deferred inside it).

AgentRunner(checkpointer=None)

Streams an agent turn as structured events. Shared by conversation and arena.

_astream_events is the single capture loop (answer / thinking / tool_call / tool_result); astream_text projects it to either event dicts or plain answer text via emit_events (see the module docstring). Pass a checkpointer (the app-wide AsyncPostgresSaver) for stateful conversations; arena constructs it with checkpointer=None.

Source code in backend/src/agents/runner.py
def __init__(self, checkpointer: Optional[BaseCheckpointSaver] = None):
    self.checkpointer = checkpointer

astream_oneshot(*, llm, prompt_text, temperature, top_p, max_tokens) async

Stateless one-shot stream (no agent/checkpointer), e.g. title generation.

Still routed through engine.generation_guard so it serializes with conversation/arena generations and keeps the model pinned while streaming. Failures are swallowed (the caller falls back to a default).

Yields ANSWER text only (#266): inline <think> reasoning is stripped by a ThinkSplitter, mirroring the two other streaming paths.

Source code in backend/src/agents/runner.py
async def astream_oneshot(
    self,
    *,
    llm,
    prompt_text: str,
    temperature: float,
    top_p: float,
    max_tokens: int,
) -> AsyncIterator[str]:
    """Stateless one-shot stream (no agent/checkpointer), e.g. title generation.

    Still routed through ``engine.generation_guard`` so it serializes with
    conversation/arena generations and keeps the model pinned while streaming.
    Failures are swallowed (the caller falls back to a default).

    Yields ANSWER text only (#266): inline ``<think>`` reasoning is stripped
    by a ``ThinkSplitter``, mirroring the two other streaming paths.
    """
    from langchain_core.messages import HumanMessage

    engine = config.LLM_Engine
    async with engine.generation_guard():
        try:
            model = await run_in_threadpool(
                build_chat_model,
                llm,
                temperature=temperature,
                top_p=top_p,
                max_tokens=max_tokens,
                # One-shot calls (titles) never want reasoning -- it would
                # burn the whole tiny token budget inside <think>. The
                # splitter below is the safety net for models/engines where
                # chat-template-level suppression does not apply.
                disable_thinking=True,
                # ...and the native effort field says the same thing to the
                # templates ``enable_thinking`` does not reach (1.1.2): a
                # template that grades its reasoning reads "none" here and
                # nothing at all from the chat-template kwarg.
                effort_plan=NO_REASONING_PLAN,
                # ...and that tiny budget is the point: the caller sized it
                # for a 2-4 word title, so the automatic window-sized budget
                # must not replace it.
                auto_output_budget=False,
                sampling=resolve_sampling_defaults(llm),
            )
        except Exception:
            # The caller falls back to a default title: recovered, but the
            # cause is worth the traceback.
            logger.warning(
                f"One-shot model construction failed: llm={getattr(llm, 'id', '?')} "
                f"({getattr(llm, 'name', '?')}); using the default",
                exc_info=True,
            )
            return
        splitter = ThinkSplitter()
        # Same rule as a conversation turn: a title that does not end
        # normally (its caller went away) flags the child it ran against,
        # through the hook bound to the build-time handle. ``aclosing``
        # closes ``BaseChatModel.astream`` here, but that generator does
        # not close the client's own stream deterministically, so the
        # HTTP response may close -- and the client-level flag may land --
        # only after the guard is released. This flag makes the next
        # prefix reset send a barrier first, which waits for that request.
        abandon_hook = getattr(model, "abandon_hook", None)
        try:
            try:
                async with contextlib.aclosing(
                    model.astream([HumanMessage(prompt_text)])
                ) as chunks:
                    async for chunk in chunks:
                        text = getattr(chunk, "text", "")
                        if not text:
                            continue
                        for event in splitter.feed(text):
                            if event["t"] == "answer":
                                yield event["text"]
            except Exception:
                logger.warning(
                    f"One-shot streaming failed: llm={getattr(llm, 'id', '?')} "
                    f"({getattr(llm, 'name', '?')}); using the default"
                    f"{_child_crash_suffix(engine)}",
                    exc_info=True,
                )
                return
        except BaseException:
            _flag_abandoned(abandon_hook)
            raise
        # Stream completed normally: drain the splitter. An unclosed
        # <think> flushes as thinking and is dropped on purpose -- the
        # caller then falls back to its default title.
        for event in splitter.flush():
            if event["t"] == "answer":
                yield event["text"]

astream_text(*, llm, user_message, system_prompt, params, thread_id=None, summarize=False, kb_context_block=None, kb_language_line='', tools=None, context=None, supports_vision=None, effort_plan=None, compaction_threshold=None, emit_events=False, announce_idle_reload=False) async

Project the turn's event stream (:meth:_astream_events).

emit_events=True yields the event dicts unchanged (conversations frame them as NDJSON). emit_events=False (arena / default) yields ONLY answer text as str -- thinking and tool events are dropped and inline <think> is stripped, preserving the old plain-text wire byte-for-byte. Error paths ride an answer event carrying the ERROR sentinel string, so in str mode the sentinel is yielded exactly as before (the conversation service maps it to an error event on the wire; DB persistence unchanged).

compaction_threshold is the conversation's compaction threshold (#607, a whole percent): required with summarize=True, where it sizes the trigger, the keep and the futility guard; it also reaches the memory brake with the turn's measurement. None on a turn that never compacts (arena).

announce_idle_reload: whether this turn may tell the user the model reloads because the idle timeout unloaded it (a conversation that already has turns; a new one shows its first-reply hint). The idle record is consumed either way, before the model is resolved, so a turn cancelled before the load loses the notice.

Source code in backend/src/agents/runner.py
async def astream_text(
    self,
    *,
    llm,
    user_message: str | list,
    system_prompt: str,
    params: GenParams,
    thread_id: Optional[str] = None,
    summarize: bool = False,
    kb_context_block: Optional[str] = None,
    kb_language_line: str = "",
    tools: Optional[list] = None,
    context: Optional[Any] = None,
    supports_vision: Optional[bool] = None,
    effort_plan: Optional[EffortPlan] = None,
    compaction_threshold: Optional[int] = None,
    emit_events: bool = False,
    announce_idle_reload: bool = False,
) -> AsyncIterator:
    """Project the turn's event stream (:meth:`_astream_events`).

    ``emit_events=True`` yields the event dicts unchanged (conversations frame
    them as NDJSON). ``emit_events=False`` (arena / default) yields ONLY answer
    text as ``str`` -- thinking and tool events are dropped and inline
    ``<think>`` is stripped, preserving the old plain-text wire byte-for-byte.
    Error paths ride an ``answer`` event carrying the ERROR sentinel string, so
    in str mode the sentinel is yielded exactly as before (the conversation
    service maps it to an ``error`` event on the wire; DB persistence
    unchanged).

    ``compaction_threshold`` is the conversation's compaction threshold
    (#607, a whole percent): required with ``summarize=True``, where it
    sizes the trigger, the keep and the futility guard; it also reaches
    the memory brake with the turn's measurement. ``None`` on a turn that
    never compacts (arena).

    ``announce_idle_reload``: whether this turn may tell the user the model
    reloads because the idle timeout unloaded it (a conversation that
    already has turns; a new one shows its first-reply hint). The idle
    record is consumed either way, before the model is resolved, so a turn
    cancelled before the load loses the notice.
    """
    # ``aclosing``: closing THIS generator closes the capture loop (and,
    # through it, LangGraph) right away, inside the generation guard.
    async with contextlib.aclosing(
        self._astream_events(
            llm=llm,
            user_message=user_message,
            system_prompt=system_prompt,
            params=params,
            thread_id=thread_id,
            summarize=summarize,
            kb_context_block=kb_context_block,
            kb_language_line=kb_language_line,
            tools=tools,
            context=context,
            supports_vision=supports_vision,
            effort_plan=effort_plan,
            compaction_threshold=compaction_threshold,
            announce_idle_reload=announce_idle_reload,
        )
    ) as events:
        async for event in events:
            if emit_events:
                yield event
            elif event["t"] == "answer":
                yield event["text"]

GenParams(temperature, top_p, max_tokens) dataclass

Per-request generation parameters (resolved from payload-or-conversation).

compaction_cutoff(messages, working_window, counter, *, pct, overhead=0, allocated_window=None)

Index of the first message a compaction keeps (0: nothing to compact).

THE cutoff rule, pure: the summarization middleware uses it, and so does the amber-warning projection (which has no middleware instance). counter counts real tokens as sent; overhead is O, what the request carries beyond the state (system prompt, KB block, tool schemas), already scaled. pct is the conversation's compaction threshold: the keep budget and the futility guard read the trigger through it.

  1. With a known window, the token cutoff alone: the earliest suffix that fits max(1, keep_token_budget(W, pct) - O) tokens, however many messages that is (token pressure is the only trigger there, so a count bound is not needed). Without a window, the message cutoff alone: the last SUMMARY_KEEP_MESSAGES messages, the fallback paired with the SUMMARY_TRIGGER_MESSAGES trigger.
  2. Never past the LAST user message: the question the current turn answers is never summarized away mid-turn (a tool round after it can be large on its own).
  3. Never ON a user message: LangChain inserts the summary as a user message, and two user messages in a row make the strict chat templates (Gemma, Mistral) reject every later turn, so the kept tail always starts with an assistant message and the state alternates [summary, answer, question, ...]. On an OLDER user message the cutoff moves FORWARD to the next assistant message (keeping less, so the budget holds); only on the current question -- or when no assistant message lies between the cutoff and it -- does it move BACK to the answer before it.
  4. Tool-pair safe: an AI message is never separated from its tool results. A token cut on a tool result of an EARLIER turn (before the last user message) moves FORWARD past the round's results, so the call and its results are summarized together and the budget holds -- mirroring step 3's forward move; landing on an older user message, step 3 then applies. A cut on a tool result AFTER the current question (mid-turn) has nothing to move forward to and moves back to its call (LangChain's _find_safe_cutoff_point), as does the message cutoff without a window and any move back of step 3.
  5. If only the previous summary would be summarized (cutoff <= 1 after a summary), nothing is: re-summarizing a summary alone adds nothing and would reset the prefix cache for nothing.
  6. Futility guard -- only with a known window, where the token trigger is the only trigger, and only when it fired (count + O >= trigger_tokens(W, pct); the amber-warning projection also asks below it): a compaction whose gain count(all) - count(kept) - summary_cap(W) is below POST_COMPACTION_MARGIN_TOKENS (256, the keep margin's floor in a different role: the smallest gain worth a summary call, whatever the threshold) -- nothing at all included -- is skipped unless the request would overflow without it: against the allocated window (O + count + 512 > W_alloc, the output budget's floor) or the working window (O + count > W). A small gain is not worth a summary call and a prefix-cache reset; any compaction that may save an overflowing turn is taken (the gain is reckoned with the summary's CAP, the real summary is often shorter).

Termination: every compaction strictly lowers the number of messages before the last user message; steps 2-3 bound the cutoff at the answer before the current question, and there step 5 returns 0 on the next hop -- the kept tail can stay above the trigger, so termination rests on step 5, not on a margin under the trigger.

With ordinary messages the kept tail stays inside the token budget. It exceeds it only by the answer before the current question when the cut falls ON that question (steps 2-3), by a last message larger than the budget on its own (kept whole, summarized on a later turn, never truncated), and by a tool round kept with its call when it lies after the current question (mid-turn) or right before it with no answer in between (step 3 moves back onto it) -- accepted: there the alternatives drop the question or split the round, which breaks the conversation.

Source code in backend/src/agents/runner.py
def compaction_cutoff(
    messages: list,
    working_window: Optional[int],
    counter: Any,
    *,
    pct: int,
    overhead: int = 0,
    allocated_window: Optional[int] = None,
) -> int:
    """Index of the first message a compaction keeps (0: nothing to compact).

    THE cutoff rule, pure: the summarization middleware uses it, and so does
    the amber-warning projection (which has no middleware instance).
    ``counter`` counts real tokens as sent; ``overhead`` is O, what the
    request carries beyond the state (system prompt, KB block, tool schemas),
    already scaled. ``pct`` is the conversation's compaction threshold: the
    keep budget and the futility guard read the trigger through it.

    1. With a known window, the token cutoff alone: the earliest suffix that
       fits ``max(1, keep_token_budget(W, pct) - O)`` tokens, however many
       messages that is (token pressure is the only trigger there, so a
       count bound is not needed). Without a window, the message cutoff
       alone: the last ``SUMMARY_KEEP_MESSAGES`` messages, the fallback
       paired with the ``SUMMARY_TRIGGER_MESSAGES`` trigger.
    2. Never past the LAST user message: the question the current turn
       answers is never summarized away mid-turn (a tool round after it can
       be large on its own).
    3. Never ON a user message: LangChain inserts the summary as a user
       message, and two user messages in a row make the strict chat
       templates (Gemma, Mistral) reject every later turn, so the kept tail
       always starts with an assistant message and the state alternates
       ``[summary, answer, question, ...]``. On an OLDER user message the
       cutoff moves FORWARD to the next assistant message (keeping less, so
       the budget holds); only on the current question -- or when no
       assistant message lies between the cutoff and it -- does it move BACK
       to the answer before it.
    4. Tool-pair safe: an AI message is never separated from its tool
       results. A token cut on a tool result of an EARLIER turn (before the
       last user message) moves FORWARD past the round's results, so the
       call and its results are summarized together and the budget holds --
       mirroring step 3's forward move; landing on an older user message,
       step 3 then applies. A cut on a tool result AFTER the current
       question (mid-turn) has nothing to move forward to and moves back to
       its call (LangChain's ``_find_safe_cutoff_point``), as does the
       message cutoff without a window and any move back of step 3.
    5. If only the previous summary would be summarized (cutoff <= 1 after a
       summary), nothing is: re-summarizing a summary alone adds nothing and
       would reset the prefix cache for nothing.
    6. Futility guard -- only with a known window, where the token trigger is
       the only trigger, and only when it fired (``count + O >=
       trigger_tokens(W, pct)``; the amber-warning projection also asks below
       it): a compaction whose gain ``count(all) - count(kept) -
       summary_cap(W)`` is below ``POST_COMPACTION_MARGIN_TOKENS`` (256, the
       keep margin's floor in a different role: the smallest gain worth a
       summary call, whatever the threshold) -- nothing at all included --
       is skipped unless the request would overflow without it: against the
       allocated window (``O + count + 512 > W_alloc``, the output budget's
       floor) or the working window (``O + count > W``). A small gain is not
       worth a summary call and a prefix-cache reset; any compaction that may
       save an overflowing turn is taken (the gain is reckoned with the
       summary's CAP, the real summary is often shorter).

    Termination: every compaction strictly lowers the number of messages
    before the last user message; steps 2-3 bound the cutoff at
    the answer before the current question, and there step 5 returns 0 on
    the next hop -- the kept tail can stay above the trigger, so termination
    rests on step 5, not on a margin under the trigger.

    With ordinary messages the kept tail stays inside the token budget. It
    exceeds it only by the answer before the current question when the cut
    falls ON that question (steps 2-3), by a last message larger than the
    budget on its own (kept whole, summarized on a later turn, never
    truncated), and by a tool round kept with its call when it lies after
    the current question (mid-turn) or right before it with no answer in
    between (step 3 moves back onto it) -- accepted: there the alternatives
    drop the question or split the round, which breaks the conversation.
    """
    from langchain.agents.middleware import SummarizationMiddleware
    from langchain_core.messages import HumanMessage, ToolMessage

    safe_point = SummarizationMiddleware._find_safe_cutoff_point
    last_human = last_human_index(messages)
    if _known_window(working_window):
        keep_budget = max(1, keep_token_budget(working_window, pct) - overhead)
        cutoff = _token_cutoff(messages, keep_budget, counter)
        if (
            last_human is not None
            and cutoff < last_human
            and isinstance(messages[cutoff], ToolMessage)
        ):
            # A tool round of an EARLIER turn: move FORWARD past its results,
            # so the call and its results are summarized together. Moving
            # back to the call would keep the whole round, over the budget.
            while isinstance(messages[cutoff], ToolMessage):
                cutoff += 1
        else:
            cutoff = safe_point(messages, cutoff)
    elif len(messages) <= SUMMARY_KEEP_MESSAGES:
        cutoff = 0
    else:
        cutoff = safe_point(messages, len(messages) - SUMMARY_KEEP_MESSAGES)
    if cutoff <= 0:
        return 0
    if last_human is not None:
        cutoff = min(cutoff, last_human)
    if (
        last_human is not None
        and cutoff < last_human
        and isinstance(messages[cutoff], HumanMessage)
    ):
        # An OLDER user message: move FORWARD to the next assistant message
        # (tool results after a call are fine, they follow it). Moving back
        # would keep the answer before it -- one that did not fit the budget.
        forward = cutoff
        while forward <= last_human and isinstance(messages[forward], (HumanMessage, ToolMessage)):
            forward += 1
        if forward <= last_human:
            cutoff = forward
    # Only on the current question (or with no assistant message between the
    # cutoff and it): every step moves strictly back, so the loop ends.
    while cutoff > 0:
        message = messages[cutoff]
        if isinstance(message, HumanMessage):
            cutoff -= 1
        elif isinstance(message, ToolMessage):
            paired = safe_point(messages, cutoff)
            # LangChain answers an orphaned tool result (no matching call)
            # by moving FORWARD, which could land back on a user message:
            # step back past it instead.
            cutoff = paired if paired < cutoff else cutoff - 1
        else:
            break
    if cutoff <= 1 and messages and _is_previous_summary(messages[0]):
        return 0
    if cutoff > 0 and _known_window(working_window):
        total = counter(messages)
        if total + overhead >= trigger_tokens(working_window, pct):
            gain = total - counter(messages[cutoff:]) - summary_cap(working_window)
            overflows = overhead + total > working_window or (
                _known_window(allocated_window)
                and overhead + total + OUTPUT_BUDGET_FLOOR_TOKENS > allocated_window
            )
            # The keep margin's 256-token floor, in another role: the
            # smallest gain worth a summary call. It does not scale with the
            # threshold.
            if gain < POST_COMPACTION_MARGIN_TOKENS and not overflows:
                return 0
    return cutoff

compaction_reason(engine, working_window, allocated_window)

memory when the machine's memory bounds the window the compaction ran against, length otherwise.

Memory bounds it when the engine says so (memory_bound_tokens: the CPU and CUDA engines size their window from the memory budget at spawn, MLX lowers its child's to the memory ceiling -- the criterion the amber warning and the overflow copy use), or when the memory ceiling folded the working window below the allocated one -- or stood alone, the allocated window unknown (the compact-as-early-as-possible clamp). The message-count fallback, with no window at all, is length.

Source code in backend/src/agents/runner.py
def compaction_reason(
    engine: Any, working_window: Optional[int], allocated_window: Optional[int]
) -> str:
    """``memory`` when the machine's memory bounds the window the compaction
    ran against, ``length`` otherwise.

    Memory bounds it when the engine says so (``memory_bound_tokens``: the CPU
    and CUDA engines size their window from the memory budget at spawn, MLX
    lowers its child's to the memory ceiling -- the criterion the amber
    warning and the overflow copy use), or when the memory ceiling folded the
    working window below the allocated one -- or stood alone, the allocated
    window unknown (the compact-as-early-as-possible clamp). The
    message-count fallback, with no window at all, is ``length``.
    """
    if _memory_bound_of(engine) is not None:
        return COMPACTION_REASON_MEMORY
    if _known_window(working_window) and (
        not _known_window(allocated_window) or working_window < allocated_window
    ):
        return COMPACTION_REASON_MEMORY
    return COMPACTION_REASON_LENGTH

counter_weight(message)

The counter's weight for a message nothing was frozen for (rule 2 with the counter's settings: CJK one token per character, floor 1.2).

Source code in backend/src/agents/runner.py
def counter_weight(message: Any) -> float:
    """The counter's weight for a message nothing was frozen for (rule 2 with
    the counter's settings: CJK one token per character, floor 1.2)."""
    return fresh_weight(message, dense=COUNTER_DENSE_TOKENS, weight_floor=COUNTER_WEIGHT_FLOOR)

frozen_weights(messages)

({message id: weight}, r) for a list of messages AS SENT, decided once from the whole list (token_accounting.message_weights with the counter's settings).

Source code in backend/src/agents/runner.py
def frozen_weights(messages: Any) -> tuple[dict, Optional[float]]:
    """``({message id: weight}, r)`` for a list of messages AS SENT, decided
    once from the whole list (``token_accounting.message_weights`` with the
    counter's settings)."""
    messages = list(messages)
    weights, ratio = message_weights(
        messages, dense=COUNTER_DENSE_TOKENS, weight_floor=COUNTER_WEIGHT_FLOOR
    )
    by_id = {
        message.id: weight
        for message, weight in zip(messages, weights)
        if getattr(message, "id", None) is not None
    }
    return by_id, ratio

keep_token_budget(working_window, pct)

Tokens a compaction may keep at threshold pct: max(1, min(0.4 W, T - cap - max(256, T // 5))), T the trigger.

Source code in backend/src/agents/runner.py
def keep_token_budget(working_window: int, pct: int) -> int:
    """Tokens a compaction may keep at threshold ``pct``:
    ``max(1, min(0.4 W, T - cap - max(256, T // 5)))``, ``T`` the trigger."""
    trigger = trigger_tokens(working_window, pct)
    margin = max(POST_COMPACTION_MARGIN_TOKENS, trigger // POST_COMPACTION_MARGIN_DIVISOR)
    room_under_trigger = trigger - summary_cap(working_window) - margin
    return max(1, min(int(KEEP_FRACTION * working_window), room_under_trigger))

real_token_count(messages, weights_by_id=None, past_ids=frozenset())

THE token counter of compaction, in real tokens, over the messages AS SENT: each message costs ceil(weight * chars/4 of that copy).

weights_by_id is frozen once per call from the full state (frozen_weights), so the count works on any list LangChain hands it -- suffixes, the reversed pool of trim_messages, partial copies (same id, shorter content: they cost their share). A ToolMessage whose id is in past_ids is sent as its marker, and a marker costs the weight of its OWN text, never the frozen weight of the result it replaces. A message nothing was frozen for (a new summary, a re-ided copy) gets the counter's rule 2 on the fly. Additive: count(a + b) == count(a) + count(b).

A distinct function on purpose: handed count_tokens_approximately itself, LangChain's SummarizationMiddleware swaps in a variant that rescales the count with the last AI message's reported usage -- a stale total that counts messages compaction already removed.

Source code in backend/src/agents/runner.py
def real_token_count(
    messages: Any, weights_by_id: Optional[dict] = None, past_ids: frozenset = frozenset()
) -> int:
    """THE token counter of compaction, in real tokens, over the messages AS
    SENT: each message costs ``ceil(weight * chars/4 of that copy)``.

    ``weights_by_id`` is frozen once per call from the full state
    (``frozen_weights``), so the count works on any list LangChain hands it --
    suffixes, the reversed pool of ``trim_messages``, partial copies (same id,
    shorter content: they cost their share). A ToolMessage whose id is in
    ``past_ids`` is sent as its marker, and a marker costs the weight of its
    OWN text, never the frozen weight of the result it replaces. A message
    nothing was frozen for (a new summary, a re-ided copy) gets the counter's
    rule 2 on the fly. Additive: ``count(a + b) == count(a) + count(b)``.

    A distinct function on purpose: handed ``count_tokens_approximately``
    itself, LangChain's ``SummarizationMiddleware`` swaps in a variant that
    rescales the count with the last AI message's reported usage -- a stale
    total that counts messages compaction already removed.
    """
    weights_by_id = weights_by_id or {}
    total = 0
    for message in messages:
        message_id = getattr(message, "id", None)
        if past_ids and message_id in past_ids:
            message = stale_result_copy(message)
        if _is_marker(message):
            weight = counter_weight(message)
        else:
            weight = weights_by_id.get(message_id) if message_id is not None else None
            if weight is None:
                weight = counter_weight(message)
        total += weighted_cost(message, weight)
    return total

summarization_triggers(working_window, pct)

The one-clause trigger list for SummarizationMiddleware.

working_window is the ONE canonical working context window (src.engines.working_window.canonical_working_window -- the minimum of the allocated window and the memory ceiling, over whichever candidates are known). With a known window, trigger_tokens(W, pct) -- pct is the conversation's compaction threshold -- is the only trigger: token pressure. The memory fold happens at the call site, so this function sees a single value: None (no window and no memory ceiling), like any non-positive or non-int value, leaves the SUMMARY_TRIGGER_MESSAGES message fallback alone.

Deliberately never ("fraction", ...): that form needs a model.profile our local chat clients do not carry (the middleware's __init__ would raise). The middleware tests the token clause against count + O in real tokens (_should_summarize).

Source code in backend/src/agents/runner.py
def summarization_triggers(working_window: Optional[int], pct: int) -> list:
    """The one-clause trigger list for ``SummarizationMiddleware``.

    ``working_window`` is the ONE canonical working context window
    (``src.engines.working_window.canonical_working_window`` -- the minimum of
    the allocated window and the memory ceiling, over whichever candidates are
    known). With a known window, ``trigger_tokens(W, pct)`` -- ``pct`` is the
    conversation's compaction threshold -- is the only trigger: token
    pressure. The memory fold happens at the call
    site, so this function sees a single value: ``None`` (no window and no
    memory ceiling), like any non-positive or non-int value, leaves the
    ``SUMMARY_TRIGGER_MESSAGES`` message fallback alone.

    Deliberately never ``("fraction", ...)``: that form needs a
    ``model.profile`` our local chat clients do not carry (the middleware's
    ``__init__`` would raise). The middleware tests the token clause against
    ``count + O`` in real tokens (``_should_summarize``).
    """
    if _known_window(working_window):
        return [("tokens", trigger_tokens(working_window, pct))]
    return [("messages", SUMMARY_TRIGGER_MESSAGES)]

summarize_trim_budget(working_window, allocated_window, pct)

Tokens of history the summarizer reads at threshold pct (see the constants above).

No known working window: LangChain's own 4000. The allocated-window term is dropped when the allocation is unknown.

Source code in backend/src/agents/runner.py
def summarize_trim_budget(
    working_window: Optional[int], allocated_window: Optional[int], pct: int
) -> int:
    """Tokens of history the summarizer reads at threshold ``pct`` (see the
    constants above).

    No known working window: LangChain's own 4000. The allocated-window term
    is dropped when the allocation is unknown.
    """
    if not _known_window(working_window):
        return SUMMARY_TRIM_DEFAULT_TOKENS
    cap = summary_cap(working_window)
    terms = [
        max(SUMMARY_TRIM_DEFAULT_TOKENS, trigger_tokens(working_window, pct) - cap),
        working_window - 2 * cap - SUMMARY_TRIM_MEMORY_MARGIN_TOKENS,
    ]
    if _known_window(allocated_window):
        terms.append(
            (allocated_window - 2 * cap - SUMMARY_TRIM_ALLOCATED_MARGIN_TOKENS)
            // SUMMARY_TRIM_HETEROGENEITY_FACTOR
        )
    return max(SUMMARY_TRIM_FLOOR_TOKENS, min(terms))

summarized_message_count(messages)

How many of the summarized messages a user would recognise as rows of the thread: their questions and the model's answers -- an answer with an empty text counts; the previous summary, tool calls and tool results do not. One per persisted message in an ordinary history; a turn that failed before its answer leaves a question the state holds without a reply.

Source code in backend/src/agents/runner.py
def summarized_message_count(messages: list) -> int:
    """How many of the summarized messages a user would recognise as rows of
    the thread: their questions and the model's answers -- an answer with an
    empty text counts; the previous summary, tool calls and tool results do
    not. One per persisted message in an ordinary history; a turn that failed
    before its answer leaves a question the state holds without a reply."""
    count = 0
    for message in messages:
        kind = getattr(message, "type", None)
        if kind == "human" and not _is_previous_summary(message):
            count += 1
        elif kind == "ai" and not getattr(message, "tool_calls", None):
            count += 1
    return count

summary_cap(working_window)

The summary's token cap for this working window: W // 8 clamped to [128, 1024]. Also the summary size the keep budget and the amber warning projection reserve.

Source code in backend/src/agents/runner.py
def summary_cap(working_window: int) -> int:
    """The summary's token cap for this working window: ``W // 8`` clamped
    to [128, 1024]. Also the summary size the keep budget and the amber
    warning projection reserve."""
    return max(SUMMARY_CAP_FLOOR_TOKENS, min(SUMMARY_CAP_CEILING_TOKENS, working_window // 8))

src.agents.think_splitter

Streaming <think>...</think> splitter -- the FALLBACK reasoning path (#554).

The primary reasoning path does not run through here: both local inference servers extract each family's chain-of-thought server-side (llama-server's default --reasoning-format auto, mlx_vlm.server's native split) into a dedicated delta field that Erudi_Chat_OpenAI carries to the runner's thinking events. This splitter stays on the CONTENT channel as the fallback for families whose markers the server parser does not know: inline <think> tags that slip through still become thinking events instead of leaking into the answer. Arena's plain-text projection (emit_events=False) depends on it -- it is what keeps stray inline reasoning off Arena's wire. It is fed the model's answer deltas and emits event dicts: text outside the tags becomes answer events, text inside becomes thinking events.

Robust to tags split across chunks (<th then ink>): only a trailing partial prefix of the tag currently being scanned for is buffered; everything before it is emitted immediately. flush() drains the buffer at stream end (an unclosed <think> flushes its remainder as thinking).

Pure string logic, no LangChain import -- unit-tested with adversarial splits.

ThinkSplitter()

Stateful streaming splitter.

Call :meth:feed per answer delta, then :meth:flush once at stream end. Both return a list of {"t": "answer"|"thinking", "text": ...} event dicts (empty-text events are never produced).

Source code in backend/src/agents/think_splitter.py
def __init__(self) -> None:
    self._inside = False
    self._buf = ""

feed(text)

Consume a delta; return the events it completes (may be empty).

Source code in backend/src/agents/think_splitter.py
def feed(self, text: str) -> list[dict]:
    """Consume a delta; return the events it completes (may be empty)."""
    if not text:
        return []
    self._buf += text
    events: list[dict] = []
    while self._buf:
        target = CLOSE_TAG if self._inside else OPEN_TAG
        idx = self._buf.find(target)
        if idx != -1:
            before = self._buf[:idx]
            if before:
                events.append({"t": self._label(), "text": before})
            # Consume the tag and flip mode; keep scanning the remainder.
            self._buf = self._buf[idx + len(target) :]
            self._inside = not self._inside
            continue
        # No complete tag: emit everything except a trailing partial tag that
        # could still complete on the next feed().
        keep = _partial_suffix_len(self._buf, target)
        emit_upto = len(self._buf) - keep
        if emit_upto > 0:
            events.append({"t": self._label(), "text": self._buf[:emit_upto]})
            self._buf = self._buf[emit_upto:]
        break
    return events

flush()

Drain the buffer in the current mode at stream end.

A leftover partial tag flushes as literal text; an unclosed <think> flushes its remainder as thinking (never leaks into an answer event).

Source code in backend/src/agents/think_splitter.py
def flush(self) -> list[dict]:
    """Drain the buffer in the current mode at stream end.

    A leftover partial tag flushes as literal text; an unclosed ``<think>``
    flushes its remainder as thinking (never leaks into an answer event).
    """
    if not self._buf:
        return []
    event = {"t": self._label(), "text": self._buf}
    self._buf = ""
    return [event]

src.agents.tools

Agent tools: calculator, knowledge-base search, and web search.

LLMs predict tokens, they don't compute: small quantized models add multi-digit numbers wrong with full confidence (measured: a different wrong total on every eval run, correct operands displayed alongside). The agent therefore carries a deterministic calculator tool; models with native function calling (Qwen, Mistral, Llama 3.1+…) invoke it through the standard agent loop. Models without it (e.g. Gemma 3 — no tool format in its chat template, never emits tool_calls) fall back to the KB prompt's no-mental-math rule. The evaluator is a strict AST whitelist — NEVER eval: numbers and arithmetic operators only (no names, calls, attributes, subscripts).

search_knowledge_base lets a tool-capable model query the user's attached knowledge base on demand instead of having excerpts injected up-front every turn; it shares per-turn context (kb_id, token budgets) with web_search through the TurnToolContext dataclass passed via create_agent(context_schema=...) and read through ToolRuntime, since create_agent accepts only one context schema.

web_search runs a metasearch query (via ddgs) and returns attributed, token-budgeted snippets so the model can ground an answer in a current external fact; it degrades to an explicit error string on any failure (offline, timeout, no results) instead of raising, so a broken search never crashes the agent loop.

TurnToolContext(kb_id=None, kb_token_budget=0, web_max_results=WEB_SEARCH_MAX_RESULTS, web_token_budget=WEB_SEARCH_DEFAULT_TOKEN_BUDGET) dataclass

Per-turn context for ALL runtime-context tools, hidden from the model.

create_agent accepts ONE context_schema (see runner.py), so the KB and web tools share this single dataclass instead of one context type per tool: passed via create_agent(context_schema=TurnToolContext) + astream(..., context=...) and read through ToolRuntime; the model only ever sees each tool's query argument. KB fields stay at their defaults on turns without the KB tool (issue #310).

calculator(expression)

Compute an arithmetic expression exactly. Use this for EVERY calculation instead of doing mental math: additions, subtractions, multiplications, divisions, percentages, powers.

Parameters:

Name Type Description Default
expression str

A pure arithmetic expression, e.g. "1240 + 1378 + 1456" or "(290 - 89) * 12". Numbers and + - * / // % ** ( ) only.

required
Source code in backend/src/agents/tools.py
@tool
def calculator(expression: str) -> str:
    """Compute an arithmetic expression exactly. Use this for EVERY
    calculation instead of doing mental math: additions, subtractions,
    multiplications, divisions, percentages, powers.

    Args:
        expression: A pure arithmetic expression, e.g. "1240 + 1378 + 1456"
            or "(290 - 89) * 12". Numbers and + - * / // % ** ( ) only.
    """
    logger.info(f"Tool invoked: calculator(expression={truncate_for_log(expression, 200)})")
    try:
        return evaluate_arithmetic(expression)
    except ValueError as exc:
        # Text the model can react to — never crash the agent loop.
        return f"Error: {exc}. Provide a pure arithmetic expression."

evaluate_arithmetic(expression)

Evaluate a pure arithmetic expression deterministically.

Returns the result as a string (integers without a trailing .0).

Raises:

Type Description
ValueError

empty input, non-arithmetic syntax, division by zero, or out-of-bounds magnitude.

Source code in backend/src/agents/tools.py
def evaluate_arithmetic(expression: str) -> str:
    """Evaluate a pure arithmetic expression deterministically.

    Returns the result as a string (integers without a trailing ``.0``).

    Raises:
        ValueError: empty input, non-arithmetic syntax, division by zero,
            or out-of-bounds magnitude.
    """
    expression = (expression or "").strip()
    if not expression:
        raise ValueError("empty expression")
    if len(expression) > _MAX_EXPRESSION_LENGTH:
        raise ValueError("expression too long")
    try:
        parsed = ast.parse(expression, mode="eval")
    except SyntaxError:
        raise ValueError("invalid arithmetic expression")
    result = _evaluate_node(parsed.body)
    if isinstance(result, float) and result.is_integer():
        return str(int(result))
    return str(result)

format_kb_tool_result(excerpts, query)

Grounded ToolMessage payload: attributed excerpts + grounding reminder + the localized answer-language line, LAST (close to generation — the spot small local models honor, per the issue #81 findings). Reuses the systematic block builder so both KB paths ground identically. Empty pool -> an explicit "not in the documents" instruction rather than silence.

Source code in backend/src/agents/tools.py
def format_kb_tool_result(excerpts: List[KbExcerpt], query: str) -> str:
    """Grounded ToolMessage payload: attributed excerpts + grounding reminder +
    the localized answer-language line, LAST (close to generation — the spot
    small local models honor, per the issue #81 findings). Reuses the systematic
    block builder so both KB paths ground identically. Empty pool -> an explicit
    "not in the documents" instruction rather than silence."""
    if not excerpts:
        return (
            "No relevant excerpts were found in the user's documents for this "
            "query. Tell the user the information is not in their documents."
        )
    block = build_kb_context_block(excerpts=excerpts, question=query)
    return f"{block}\n\n{answer_language_line(query)}"

format_web_tool_result(results, query, token_budget)

ToolMessage payload for a web search: attributed snippets with SOURCE URLS (title - href + snippet) so the model can cite, kept whole and best-first within token_budget tokens as measured by _estimate_web_tokens (the first result always survives — mirroring the KB budget rule), a citation reminder, and the localized answer-language line LAST (the spot small local models honor, same as format_kb_tool_result). Empty results are NOT an error.

Source code in backend/src/agents/tools.py
def format_web_tool_result(results: list, query: str, token_budget: int) -> str:
    """ToolMessage payload for a web search: attributed snippets with SOURCE
    URLS (``title - href`` + snippet) so the model can cite, kept whole and
    best-first within ``token_budget`` tokens as measured by
    ``_estimate_web_tokens`` (the first result always survives — mirroring the
    KB budget rule), a citation reminder, and the localized answer-language
    line LAST (the spot small local models honor, same as
    ``format_kb_tool_result``). Empty results are NOT an error."""
    if not results:
        return _WEB_NO_RESULTS_TEXT
    blocks: List[str] = []
    spent = 0
    for result in results:
        title = (result.get("title") or "").strip()
        href = (result.get("href") or "").strip()
        body = (result.get("body") or "").strip()
        block = f"[{title} - {href}]\n{body}"
        cost = _estimate_web_tokens(block)
        if blocks and spent + cost > token_budget:
            break
        blocks.append(block)
        spent += cost
    joined = "\n\n".join(blocks)
    return (
        "Web search results:\n\n"
        f"{joined}\n\n"
        "Ground your answer on these results and cite the source URLs of the "
        "results you use.\n\n"
        f"{answer_language_line(query)}"
    )

map_web_search_error(exc)

Deterministic error text for a failed web search (locked #310 contract).

Exact prefix Error during Web Search: + a cause-specific tail: - ddgs TimeoutException / builtin TimeoutError -> timed out - ddgs RatelimitException -> rate-limited - OSError/ConnectionError anywhere in the chain -> no internet - anything else -> unexpected failure ()

TimeoutError is checked BEFORE the OSError chain walk: since 3.10 the builtin is an OSError subclass and would otherwise map to "no internet".

Source code in backend/src/agents/tools.py
def map_web_search_error(exc: BaseException) -> str:
    """Deterministic error text for a failed web search (locked #310 contract).

    Exact prefix ``Error during Web Search: `` + a cause-specific tail:
      - ddgs TimeoutException / builtin TimeoutError -> timed out
      - ddgs RatelimitException -> rate-limited
      - OSError/ConnectionError anywhere in the chain -> no internet
      - anything else -> unexpected failure (<short cause>)

    ``TimeoutError`` is checked BEFORE the OSError chain walk: since 3.10 the
    builtin is an OSError subclass and would otherwise map to "no internet".
    """
    try:
        from ddgs.exceptions import RatelimitException, TimeoutException
    except ImportError:
        # The failure being mapped may BE the missing package: the mapper
        # must still return text, never raise into the agent loop.
        RatelimitException = TimeoutException = ()  # type: ignore[assignment]

    if isinstance(exc, (TimeoutException, TimeoutError)):
        tail = "the request timed out"
    elif isinstance(exc, RatelimitException):
        tail = "search engines are rate-limiting requests, try again later"
    elif _chain_has_network_error(exc):
        tail = "no internet connection"
    else:
        message = str(exc).strip()
        summary = message.splitlines()[0][:120] if message else type(exc).__name__
        tail = f"unexpected failure ({summary})"
    return f"{WEB_SEARCH_ERROR_PREFIX}{tail}"

search_knowledge_base(query, runtime) async

Search the user's personal knowledge base (their uploaded documents).

Use this whenever the question concerns the content of those documents. Answer only from what this tool returns; if it returns no excerpts, tell the user the information is not in their documents.

Parameters:

Name Type Description Default
query str

A focused natural-language search query for the documents.

required
Source code in backend/src/agents/tools.py
@tool
async def search_knowledge_base(query: str, runtime: ToolRuntime[TurnToolContext]) -> str:
    """Search the user's personal knowledge base (their uploaded documents).

    Use this whenever the question concerns the content of those documents.
    Answer only from what this tool returns; if it returns no excerpts, tell the
    user the information is not in their documents.

    Args:
        query: A focused natural-language search query for the documents.
    """
    ctx = runtime.context
    logger.info(
        f"Tool invoked: search_knowledge_base(query={truncate_for_log(query, 200)}, "
        f"kb_id={ctx.kb_id}, token_budget={ctx.kb_token_budget})"
    )
    try:
        excerpts = await run_in_threadpool(
            retrieve_kb_excerpts, query, ctx.kb_id, ctx.kb_token_budget
        )
    except Exception:
        # A broken vector store must not crash the agent loop.
        logger.exception("search_knowledge_base tool failed (degrading gracefully)")
        return "The knowledge base could not be searched right now."
    logger.info(
        f"Tool search_knowledge_base returned {len(excerpts)} excerpt(s) " f"(kb_id={ctx.kb_id})"
    )
    return format_kb_tool_result(excerpts, query)

Search the web for current or external information.

Use this when the question needs recent events, live data, or specific external facts you do not reliably know. Results include source URLs to cite in your answer.

Parameters:

Name Type Description Default
query str

A focused natural-language web search query.

required
Source code in backend/src/agents/tools.py
@tool
async def web_search(query: str, runtime: ToolRuntime[TurnToolContext]) -> str:
    """Search the web for current or external information.

    Use this when the question needs recent events, live data, or specific
    external facts you do not reliably know. Results include source URLs to
    cite in your answer.

    Args:
        query: A focused natural-language web search query.
    """
    ctx = runtime.context
    logger.info(
        f"Tool invoked: web_search(query={truncate_for_log(query, 200)}, "
        f"max_results={ctx.web_max_results}, token_budget={ctx.web_token_budget})"
    )
    try:
        results = await asyncio.wait_for(
            run_in_threadpool(_run_ddgs_text, query, ctx.web_max_results),
            timeout=WEB_SEARCH_OVERALL_TIMEOUT_S,
        )
    except Exception as exc:
        # Locked contract (#310): NEVER raise — return deterministic text the
        # model can read and react to (offline, rate-limits, timeouts...).
        # The traceback is attached only when the cause is not one of the
        # expected network outcomes, whose text says everything.
        expected = isinstance(exc, TimeoutError) or _chain_has_network_error(exc)
        logger.warning(
            f"web_search tool failed: {type(exc).__name__}: {truncate_for_log(str(exc), 300)}",
            exc_info=not expected,
        )
        return map_web_search_error(exc)
    logger.info(f"Tool web_search returned {len(results)} result(s)")
    return format_web_tool_result(results, query, ctx.web_token_budget)