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 aChatOpenAIpointed at the local engine server and the engine-level serialization/idle-marker guard.runner—AgentRunner: onecreate_agentper turn, streamed as raw token text for the existingStreamingResponse(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 theerudidatabase).
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
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 ( |
required |
Yields a ready-to-use saver.
Source code in backend/src/agents/checkpoint.py
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
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | |
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
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
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
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
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
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | |
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
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
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
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
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
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 asstr, 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_idset +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
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
2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 | |
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
1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 | |
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.
- 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 lastSUMMARY_KEEP_MESSAGESmessages, the fallback paired with theSUMMARY_TRIGGER_MESSAGEStrigger. - 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).
- 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. - 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. - 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.
- 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 gaincount(all) - count(kept) - summary_cap(W)is belowPOST_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
498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 | |
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
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
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
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
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
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
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
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
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
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
feed(text)
¶
Consume a delta; return the events it completes (may be empty).
Source code in backend/src/agents/think_splitter.py
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
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
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
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
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
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
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
web_search(query, runtime)
async
¶
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 |