atomic-agent
August 31, 2026 · View on GitHub
This is the source-of-truth for automated contributors (LLM agents, codegen, etc.). Human-facing docs live in README.md.
Mission
atomic-agent is a lightweight local operator agent runtime that:
- Embeds as a sidecar in Tauri desktop apps (stdin/stdout NDJSON).
- Ships a CLI (
atomic-agent) for local debugging. - Connects to an external
llama-server(llama.cpp) over HTTP — the LLM runtime, model weights, and binaries are not part of this project. - Keeps every LLM step under ~2.5k tokens by externalising session state, summarising results, and keeping the stable prompt prefix small.
Architectural invariants
-
Project ≠ Prompt. Session state, compressed tool results, and world snapshots live outside the model; the prompt is always a small slice.
-
Stable prefix. The prompt is
buildStablePrefix(persona +### rules+ skill catalog under### skills+### tools+### capabilities+### instructions) followed by a variable tail in mutability order:### loaded-skills(optional) →### loaded-tools(optional) →### profile(optional) →### memory-index(optional) →### session-facts(optional) →### recalled(optional) →### world→### conversation→ optional### notice(written by the no-progress loop detector and by mid-turn steering, composed in that order) →### respond(+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is whatcache_prompt + slot_idonllama-serverrelies on. -
One inference per step. No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON array of
1..Ntool calls ([{tool, args}, ...]); a "solo" step is just a length-1 array ([{...}]).Nis capped byagent.maxParallelToolCalls(default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline. -
Grammar-constrained tool calls. The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to array-only (
root ::= tool-call-array) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (qwen-think,gemma4-think) prepend a<think>...</think>/<|channel>thought...<channel|>block to the array; the seam between the close sentinel and the leading[of the array routes through a dedicated boundedprelude-trail-ws ::= ( [ \t\n\r] ){0,8}rule rather than the global unboundedws. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by src/llm/grammar/build-grammar.test.ts "bounds the whitespace between the reasoning-close sentinel and the tool-call array".Reasoning-open ownership differs by profile (
reasoningOpenEmittedByModelin src/llm/model-profile.ts).qwen-thinkprefills its open tag at the end of the prompt (### respond→<think>); the grammar prelude starts after the open tag (think-prelude ::= think-body "</think>" …) andstep-executorprepends the open tag back onto the completion before parsing. Nemotron 3.5 Lightning shares that ownership exactly: its template ends generation at<|im_start|>assistant\n<think>\n, soselectBaseProfiledetects it separately (the alias hint differs) but returnsqwen-thinkitself rather than a duplicate profile.gemma4-thinkis the opposite: Gemma 4's QAT template treats a prefilled<|channel>thought\nas the thinking-disabled marker and immediately closes the channel, dumping its reasoning intoreply.text. The fix is native turn-framing — the profile carriesturnFraming { systemOpen: "<|turn>system\n", turnClose: "<turn|>\n", assistantOpen: "<|turn>model\n" }andreasoningEmittedByModel: true.buildStablePrefixopens the prompt with<|turn>system\n<|think|>\n### system…(the<|think|>token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation),buildPromptends the tail with…### respond\nRespond now.\n\n<turn|>\n<|turn>model\ninstead of a channel prefill, and the grammar prelude includes the open sentinel (channel-prelude ::= "<|channel>thought\n" channel-body "<channel|>" prelude-trail-ws) so the model emits its own<|channel>thoughtblock.step-executor.normalizeContentdoes not prepend the open tag forreasoningEmittedByModelprofiles (it is already incompletion.content), the stream parser runs withpreOpenedThink: falseso it detects the open tag live, and the repair prompt strips/re-appends<turn|>\n<|turn>modelrather than the bare open tag.checkProfilePromptAlignedasserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by src/agent/profile-matrix.test.ts, src/llm/grammar/build-grammar.test.ts, src/prompt/build-prompt.test.ts, src/llm/profile-invariants.test.ts, src/llm/grammar/stream-parser.test.ts. -
No global singletons. Dependencies are passed explicitly.
getConfig()is the only exception. -
Session is multi-turn chat only. A session is a long-lived chat:
user message → 0..N tool steps → replyis a macro-turn, multiple turns share oneSessionState.turns[]. Two terminals exist —replyends the turn,finishends the whole session. All three frontends (CLIrun, TUI, sidecar) go throughruntime.runTurnonly; there is no one-shot goal mode.
Rare tools: tool.view and ### loaded-tools
The stable-prefix ### tools block lists frequent tools with full args schemas in # common (full) and rare tools as one-line entries under # extras (tier rare), keeping the cache-hot prefix small. The model does not see full args for a rare tool until that tool is loaded into the session and rendered in the variable tail as ### loaded-tools (not part of the stable prefix, so it does not pollute KV-cache for steps that do not use rare tools).
-
Discovery tool.
tool.view { name }(seesrc/tools/tool-view/) appends the full descriptor fornametoSessionState.loadedTools(LRU-evicted, capconfig.agent.loadedToolsCap). The next step’sbuildPromptincludes### loaded-toolswith capped token budgetconfig.agent.loadedToolsMaxTokens; the effective conversation cap subtracts that budget (seesrc/prompt/token-budget.ts). Optionalconfig.agent.autoExpandRareOnErrorre-invokes a rare tool with autoloaded schema after an invalid-args failure (src/agent/step-executor.ts). -
Contract. Follow the same idea as
skill.view: do not call a rare tool with precise arguments until its schema is present under### loaded-tools(calltool.viewfirst, or rely on autoload on error if enabled). GBNF allowstool.viewalongside other tools (grammars/tool-call.gbnf).
Skills enable / disable
Installed skills can be turned off without removing their files via skills.disabled: string[] in <stateDir>/config.json (config v8). A disabled name is filtered out of SkillRegistry.list() entirely — it disappears from the ### skills catalog block in the stable prefix, skill.view returns SkillNotFoundError, and the underlying skill directory stays put on disk so seed-starter-skills re-seeds remain idempotent. The CLI surface is atomic-agent skill enable|disable <name> and atomic-agent skill list (with an enabled/disabled column); the TUI exposes the same toggle through a dedicated Skills tab (/skills opens it, /skill enable|disable <name> mutates from chat). Editing the list invalidates KV-cache once because the stable-prefix bytes change — identical to install/uninstall today. Pinned by src/skills/skill-registry.test.ts (filtering + listAll()), src/cli/skill.test.ts (idempotent enable/disable round-trip), src/prompt/build-prompt.test.ts ("stable prefix changes deterministically when a skill is removed from the catalog"), and src/config/config-schema.test.ts (v7 → v8 transparent migration).
Parallel tool calls per step
A single LLM inference always emits a JSON array of 1..N tool calls. The runtime executes the array with class-aware concurrency: independent reads fan out, mutating tools serialise, and the wall time of the step collapses to max(group_duration) instead of the sum. This is the path that turns "scan 4 CSVs for PII" from 4 sequential os.fs.reads into one batched step.
Grammar shape (array-only)
grammars/tool-call.gbnf:
root ::= tool-call-array
tool-call ::= "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws object ws "}"
tool-call-array ::= "[" ws tool-call ( ws "," ws tool-call ){0,15} ws "]"
Why array-only. The first iteration of this feature shipped with root ::= tool-call | tool-call-array so a solo step could keep the legacy {tool, args} shape. Production traces showed that small/medium models (Qwen3-30B-A3B-Instruct in particular) almost never picked the array branch even when their <think> block reasoned about parallel reads — the GBNF sampler's first-token mass strongly favours { over [. Collapsing the root to tool-call-array removes that choice entirely: the model must start with [, which makes "one call vs many calls" a decision about array length instead of a first-token gamble. A solo step is now [{...}]. The legacy parseToolCall still accepts a bare {tool, args} for tests/replay scenarios, but llama-server will never emit one under the production grammar.
The hard upper bound on array length is 16 (grammar). The runtime soft cap is agent.maxParallelToolCalls (default 8, env ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS). Both reasoning profiles (qwen-think, gemma4-think) route the prelude into tool-call-array, so think-mode batches work the same way (see src/llm/grammar/build-grammar.ts and the matching invariant in src/llm/profile-invariants.ts).
The change to the array-only root invalidates KV-cache for any session that started under the old grammar — the stable prefix bytes change once, then stay stable. There is no hot migration path; restart with a fresh session pool.
Resource-class taxonomy
src/agent/tool-resource-class.ts maps every registered tool to one of nine classes. The batch executor groups calls by class — same-class calls run inside the group (parallel for pure_read, serial for everything else), distinct groups run concurrently with each other.
| Class | Examples | Within-group | Cross-group |
|---|---|---|---|
pure_read | os.fs.read, os.fs.glob, os.fs.grep, os.git.* (read), os.fs.list, os.fs.read_document, memory.notes.recall, tasks.list | parallel (Promise.allSettled) | parallel |
browser | browser.* | serial (Playwright is single-process) | parallel |
memory_write | memory.profile.set, memory.notes.store, os.clipboard.write, os.notify | serial | parallel |
tasks_write | tasks.schedule, tasks.cron, tasks.cancel | serial | parallel |
vision | vision.describe | serial (bounds backend load) | parallel |
fs_write | reserved | serial | parallel |
approval_gated | os.shell.run, os.fs.{write,edit,trash,patch,archive.extract}, os.proc.kill, os.http.request, skill.run_script | forbidden in batch — must be solo | — |
terminal | reply, finish | allowed only as the LAST element of a batch; runs after the non-terminal portion completes (tail-terminal barrier in executeBatch). Mid-batch or duplicated terminals are rejected by the validator. | — |
unknown | unregistered names | forbidden in batch (fail-closed) | — |
Adding a new tool requires an entry in TOOL_RESOURCE_CLASS; pinned by src/agent/tool-resource-class.test.ts which iterates DEFAULT_TOOL_DESCRIPTORS and rejects any with unknown class.
Batch executor and step pipeline
src/agent/batch-executor.ts owns the planner. The flow inside src/agent/step-executor.ts is:
- Parse.
parseToolCalls(...)returns aToolCallBatch { kind: "single" | "batch", calls: ToolCallPayload[], reasoning? }. Under the array-only production grammarkindis always"batch"(a solo step hascalls.length === 1); the"single"branch only fires for legacy bare-object input from tests / replay traces. - Validate.
validateBatchrejects multi-call batches that contain a terminal verb anywhere other than the last position (mid-batch or duplicated), an approval-gated tool, an unknown class, or exceedmaxParallelToolCalls. A terminal verb at the tail ([non-terminals..., reply]) is accepted — see the tail-terminal barrier below. A failure is treated like a parse error: the executor triggers the existing one-shot LLM retry. After two failures it surfaces asGrammarErrorwith the per-call reasons. Length-1 batches bypass these checks (legacy semantics for any tool, includingreply/finish/approval-gated). - Registry check. Missing tools throw
ToolExecutionError(categorytool) without retry — replaying the prompt would not change the registry. - Execute.
executeBatchruns in two phases. Phase 1 (synchronous loop gate): for every non-terminal call, in batch-index order,runSyncLoopGatecallstracker.check(tool, args)→tracker.recordCall(tool, args)BEFORE any tool is dispatched. Because the gate mutates theToolLoopTrackersynchronously, a duplicate call later in the same parallel batch observes the earlier sibling'srecordCall— so dup-within-batch loops are caught even though the invokes fan out. Acriticalverdict (or a tripped breaker) produces a syntheticCompressedToolResult{status:"error", details:{deniedReason:"tool-loop"}}that replaces the real invocation (the tool never runs) and is recorded so it is excluded from the no-progress streak. Terminal verbs are NEVER gated. Phase 2 (invoke):planBatchgroups the survivors, fans out, collectsBatchCallResult[]; each real outcome is fed back viatracker.recordOutcome. Failures of one call are folded into a syntheticCompressedToolResult{status:"error"}so siblings keep running.signal.abortedhalts in-flight serial groups and marks the tail ascancelled. Tail-terminal barrier: when the batch ends in aterminalverb, the executor first awaits every non-terminal group, then runs the terminal call solo. A non-terminal failure does not suppress the terminal — the model's intent "do tools, then reply" is preserved even when one tool errored (the failure lands as a normalstatus: "error"slot). The gate'swarn/critical/breakerdecisions are surfaced upward asBatchExecutionResult.loopSignals→StepOutcome.loopSignals. - Apply effects.
applyStateEffectsis invoked per result in batch-index order;recordLatestResultis "last writer wins". World snapshot updates from multiple browser calls collapse to the last batch index. - Auto-expand on error. Failed rare-tool calls trigger
autoExpandRareOnErrorindependently per batch index — each rare tool that errored gets its full descriptor injected into### loaded-toolsfor the next step. - Append turns.
appendBatchedTurnswrites Nassistant_tool_call+ Ntool_resultpairs in batch-index order. For tail-terminal batches ([..., reply]) the trailingreplycollapses into a singleassistant_replyturn after the non-terminal tool-call / tool-result pairs — the transcript readstool_call → tool_result → assistant_replyandassistant_replyis emitted exactly once. Reasoning is attached once on the firstassistant_tool_call(one inference ⇒ one<think>block); when the batch is pure terminal (length-1reply) the reasoning attaches toassistant_replydirectly. The newagent.batchToolResultCharCap(default16000, envATOMIC_AGENT_BATCH_TOOL_RESULT_CHAR_CAP) trims oldest within-batch summaries first when the combined char total overflows.
Locked invariants (pinned by tests)
Pinned by src/agent/batch-executor.test.ts, src/agent/step-executor.test.ts, src/agent/parallel-tool-calls.integration.test.ts, src/agent/loop-detector.test.ts, src/llm/grammar/tool-call-grammar.test.ts, src/llm/grammar/build-grammar.test.ts, src/tracing/trace/trace-recorder.test.ts:
- One inference per step. Batches do not start a new LLM call — they execute multiple tools after one inference completes.
- Approval-gated tools are always solo. Terminal verbs are allowed only as the LAST element of a batch. Validator rejects an approval-gated tool from any multi-call batch and rejects any terminal verb that appears mid-batch or is duplicated; one-shot retry asks the model to re-emit. A batch like
[memory.notes.store, reply]is valid —executeBatchruns the store first, awaits it, then runs thereplysolo (tail-terminal barrier). A non-terminal failure inside the batch does not suppress the tail terminal (the model's "do tools, then reply" intent survives one bad tool). Pinned byexecutes a [tool, reply] tail-terminal batch in one inference(step-executor),runs a terminal-tail call strictly AFTER every non-terminal call completesandfires the tail reply even when an earlier non-terminal call errors(batch-executor). - Result order matches batch-index order.
toolResults[i]corresponds totoolCalls[i]regardless of completion order. Pure-read fan-out reorders execution but not results. - Failures isolate. A failed call never aborts siblings; it lands in
toolResults[i]as{status: "error", details}.loop_failedonly fires on infra failures (parse/grammar/cancel), never on tool-level errors or on no-progress loops — a loop ends the turn with a graceful syntheticreply, not afailedterminal (see §"No-progress loop detection"). - Loop detection is the
ToolLoopTracker's job, threaded viaBatchExecutionContext.tracker. Per-call detection runs in the synchronous gate (phase 1 above); the composite-batch path (observeBatchComposite, synthetic label<batch>/BATCH_LOOP_LABEL) still catches two identical batches in a row but not a permuted batch (the model may legitimately reorder a set after re-thinking). See §"No-progress loop detection". - Per-step trace = N
tool_invocationevents.trace-recorder.tskeys pending parsed calls bybatchIndexso each pair is recorded with{batchIndex, batchSize}(omitted for solo steps for back-compat). - Sidecar forwards batch metadata optionally.
tool_call_started/tool_call_resultcarrybatchIndex/batchSizeonly whenbatchSize > 1; hosts that ignore them keep working. - Cross-session parallelism unchanged.
TurnControllerper-session FIFO is untouched — batches are intra-step parallelism, not inter-session.
Configuration (agent.*)
agent.maxParallelToolCalls— default8, range[1, 16]. EnvATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS. Set to1to disable batching; set to higher values to widen pure-read fan-out. Bumped from4→8after production traces showedqwen-3.5routinely emits 5–7 reads when the user requests "≥N files" — the previous cap forced two doomedparse_retryattempts in a row, both classified asGrammarError.agent.batchToolResultCharCap— default16000. EnvATOMIC_AGENT_BATCH_TOOL_RESULT_CHAR_CAP. Soft cap on combined summary length per batched step before per-result truncation.agent.loopWarningThreshold— default3. EnvATOMIC_AGENT_LOOP_WARNING_THRESHOLD. Args-only repeat count (getRepeatCount) at whichcheck()returnswarn.agent.loopCriticalThreshold— default5. EnvATOMIC_AGENT_LOOP_CRITICAL_THRESHOLD. Args+result no-progress streak (getNoProgressStreak) at whichcheck()returnscriticaland the gate vetoes the call. Clamped to be>= loopWarningThreshold.agent.loopBreakerVetoStreak— default3. EnvATOMIC_AGENT_LOOP_BREAKER_VETO_STREAK. Consecutive vetoes before the breaker trips and the turn ends with a graceful syntheticreply.agent.loopHistorySize— default40. EnvATOMIC_AGENT_LOOP_HISTORY_SIZE. Per-turn ring-buffer size for the tracker's call/outcome history. Clamped up to at leastloopWanderingEscalationso the distinct-spread window is never trimmed before it can escalate.agent.loopWanderingThreshold— default6. EnvATOMIC_AGENT_LOOP_WANDERING_THRESHOLD. Distinct-args spread (effectiveSpread) on a wandering-prone tool (os.web.fetch/os.http.request/browser.*) at whichcheck()returns awanderingwarn — an actionable redirect notice (formatWanderingRedirect), not a veto (unique calls are not blocked).agent.loopWanderingEscalation— default12. EnvATOMIC_AGENT_LOOP_WANDERING_ESCALATION. Distinct-args spread at which the wandering loop escalates onto thebreakerpath (forced gracefulreply). Clamped to be>= loopWanderingThreshold.
All are env-only; not user-config-file material.
No-progress loop detection
The runtime guards against "stuck" turns where the model re-emits the same tool call (same args, same result) without making progress. The detector is src/agent/loop-detector.ts ToolLoopTracker — one instance per turn, owned by AgentLoop.runTurn, threaded into executeStep → executeBatch via BatchExecutionContext.tracker. Ported from OpenClaw 2026.6.5; the design goal is graceful termination, never a hard failure.
Two-phase history. Each call goes through check(tool, args) (read-only verdict) → recordCall(tool, args) (commit the call) → recordOutcome(tool, args, result) (commit the result). The gate in batch-executor.ts calls check + recordCall synchronously before dispatch (phase 1), then recordOutcome after the tool returns (phase 2).
Two counters, two signals.
getRepeatCount(args-only) crossingloopWarningThreshold⇒warn: a deduped advisory notice (formatRepeatNotice) is injected into the next prompt viapendingNotice.shouldEmitWarningbuckets warnings (LOOP_WARNING_BUCKET_SIZE) so the same issue does not spam every step.getNoProgressStreak(args and result hash, interleaving-tolerant) crossingloopCriticalThreshold⇒critical: the gate vetoes the call (syntheticstatus:"error",details.deniedReason === "tool-loop", seeisLoopVetoResult) and the tool never runs. The veto instruction (formatVetoInstruction) tells the model to change approach.
Veto exclusion. Vetoed results are recorded with vetoed: true and excluded from getNoProgressStreak — otherwise the veto itself would inflate the streak. Consecutive vetoes are counted separately by breakerVetoStreak.
Wandering detector (distinct-spread). getRepeatCount / getNoProgressStreak only catch the same signature repeating. A model probing endless distinct URLs / queries / pages on one tool (e.g. guessing 8 different os.web.fetch URLs, or firing 21 search POSTs that differ only in volatile result fields) is a different failure mode. For wandering-prone tools (isWanderingProneTool: os.web.fetch, os.http.request, browser.*), check() computes effectiveSpread — the count of distinct completed argsHashes for that tool in the window, plus one when the prospective call introduces a new signature. Crossing loopWanderingThreshold ⇒ a wandering warn whose notice is an actionable redirect (formatWanderingRedirect: "stop probing URLs, run a web search or reply best-effort") rather than the repeat advisory. Crossing loopWanderingEscalation ⇒ isWanderingEscalated() returns true and the gate raises a breaker signal — the unique call is vetoed and the turn ends gracefully. Bulk reads over distinct files (os.fs.read) are deliberately not wandering-prone — scanning many files is legitimate work.
Volatile-stripping in hashToolOutcome. Before hashing a generic (non-shell) result's details, stripVolatile recursively drops VOLATILE_RESULT_KEYS (timestamp, ts, date, time, timeTotal, timeTotalSeconds, durationMs, sizeDownload, requestId/request_id, id, traceId/trace_id, sentAt, createdAt, deliveredAt). Without this, per-call timings/sizes (e.g. timeTotalSeconds, sizeDownload on os.http.request) make every result hash unique, so a repeated dead/identical endpoint never registers as a no-progress streak. Mirrors OpenClaw's stripVolatileSendIds.
Breaker → graceful reply. When breakerVetoStreak reaches loopBreakerVetoStreak (consecutive-veto path) or a wandering loop crosses loopWanderingEscalation, the gate raises a breaker signal. AgentLoop then ends the turn with a forced synthetic reply (formatForcedLoopReply) recorded as a normal assistant_reply turn — reason: "reply", session stays pending. No loop_failed, no ModelError. hashToolOutcome keys results on error details / shell exit codes / volatile-stripped summary+details so two genuinely different results break the streak.
Trace. loop_detected events carry level (warn | critical | breaker) and detector (generic_repeat | no_progress | wandering) — see src/tracing/trace/trace-event.ts.
Pinned by src/agent/loop-detector.test.ts, src/agent/batch-executor.test.ts (veto single call / siblings survive / terminal never vetoed / breaker escalation), and src/agent/agent-loop.test.ts ("ends the turn with a graceful reply (not loop_failed) when the breaker trips").
Out of scope (deferred)
Speculative batching (the runtime guessing that the model "should" have batched and rewriting the next step's prompt), per-class concurrency limits beyond the binary parallel/serial split, dependency analysis (B uses A's output) — the model decides what is independent, the runtime trusts it.
Layout rules (enforced)
- Feature-based folders under
src/. Max 2 levels of nesting. - Each folder has an explicit
index.tswith named exports (noexport *). - One responsibility per file. File name describes it exactly. No
utils.ts,helpers.ts,misc.ts,common.ts. - Max 300 lines per file. If you cross that, split before adding new code.
- File naming:
kebab-case, verb-noun for actions (build-prompt.ts,apply-patch.ts). - Function naming:
camelCase, verb-first (buildPrompt,applyPatch). - Tests are colocated with source:
build-prompt.test.tsnext tobuild-prompt.ts. - Config lives in
src/config/— read it before touching env vars.
Mouse support
The TUI is clickable. Ink has no mouse layer, so this is built in src/tui/mouse/:
- Reporting —
enableMouseTrackingwrites\x1b[?1002h\x1b[?1006h(button-event tracking + SGR coordinates). 1002 adds motion-while-held to plain 1000 button events — what drag in the composer needs; 1003 any-motion tracking is deliberately not requested: nothing in the UI hovers, and any-motion reports are a constant wakeup stream. The controller also carriessuspend()/resume()/isSuspended()— the same sequences, but the controller and its restore registration stay live, which is what the text-selection windows ride on. Paired with aprocess.on("exit")restore, likealt-screen.ts. - Decoding —
decodeMouseEventsis a pure function over a stdin chunk returning{ events, text, rest }. It understands SGR and legacy X10, buffers a report split across two reads, and passes a lone trailingESCstraight through (buffering it would delay the Escape key by one keystroke). - Stream split —
createMouseStdinreads the real TTY, hands Ink aPassThroughcarrying only the keyboard bytes, and proxiesisTTY/setRawMode/ref/unrefto the real stdin. Without this the reports reach Ink's key parser and get typed into the chat buffer. - Hit testing —
MouseTargetRegistryresolves a cell to a component. Ink exposes no absolute positions, but every node keeps its Yoga node, andabsoluteRectsumsgetComputedLeft/Topup the parent chain — the same walkrender-node-to-output.tsdoes when painting, so the rectangle is exactly where the node was drawn. Ancestors withoverflow: hiddenclip the result. Ties resolve innermost-first (higher layer, then smaller box, then later mount). - Layers —
MOUSE_LAYER_BASE/_PANEL/_MODAL.TuiAppraises the registry floor to_MODALwhenever a modal, confirm or picker owns the keyboard (isPanelModalOpen, shared withhandleAppKey), so a click cannot reach the list rendered behind a modal.
Navigation. The breadcrumb in the status bar is the one clickable navigation control: clicking it opens the menu, exactly as Esc on an empty idle prompt does. An earlier draft of this layer made a Run / Observe / Manage pill strip clickable, but the menu registry replaced that strip — reinstating pills would give one job two competing controls.
Interaction contract. First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing *-key-bindings.ts handler with a synthetic Enter / arrow key (synthetic-key.ts), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (rowColToCursor, clamped to the line length).
The trade-off. While reporting is on, the terminal stops doing its own drag-to-select — a terminal-level constraint no TUI can bypass. Hence tui.mouse (config v40, default true), --mouse / --no-mouse, and /mouse on|off at runtime; tui-command.ts owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (\x1b[?1007h) turns the wheel into cursor keys.
Text selection. selection-passthrough.ts calls MouseTrackingController.suspend() for a 10 s window with a chat notice, so the terminal's own selection/copy works everywhere. The primary, terminal-agnostic trigger is a plain drag that starts on dead content: MouseTargetRegistry.dispatch returns whether a target consumed the event, drag-intent.ts watches the TuiApp subscription for an unclaimed left press followed by held motion (once per press; wheel never participates), and onSelectionDragIntent opens the window via beginWindow("drag") — the notice says to drag again, because the triggering gesture was already reported away. Drags that start on live targets (composer, rows, buttons) are consumed presses and never trigger it. The modifier paths ride the same window: many terminals bypass reporting natively (Shift on kitty / WezTerm / GNOME Terminal / Windows Terminal, Option on iTerm2) and select instantly with zero code; a terminal that reports the shifted or alt-modified press instead (Apple Terminal) trips observe() and gets the window. Events decoded mid-suspension are swallowed in tui-command.ts before the hit test — which also keeps stale motion from re-arming the drag detector. The window auto-resumes; a /mouse off during it stays off (the passthrough reads the live controller through a getter and finds nothing to resume). /mouse off remains the permanent form.
The toggle is not a prop. tui-command.ts hands TuiApp the mouse source unconditionally, whatever tui.mouse said at startup. The mounted tree cannot be re-parented from a plain let reassignment, so gating that prop on the startup value silently made /mouse on a no-op for the rest of the session. The live gate is the tracking controller: it decides whether the terminal reports at all, and whether decoded reports are forwarded to the source.
Testing. Escape sequences, decoder and stream split are unit-tested; mouse-app.test.tsx drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target. tui-command.mouse.test.ts covers the other end — that a runtime /mouse on actually reaches the source the tree subscribed to at mount, that a shift- or alt-modified press opens the selection window instead of clicking, and that a drag intent suspends, notifies and auto-resumes.
Module map
| Folder | Responsibility |
|---|---|
src/config/ | User config file (<stateDir>/config.json) + env-based bootstrap, single getConfig() |
src/sidecar/ | NDJSON protocol + router + typed event schema |
src/cli/ | run, index, repl, tui, serve commands |
src/http/ | OpenAI-compatible HTTP API + atomic admin routes for atomic-agent serve |
src/llm/ | HTTP client for external llama-server + GBNF grammar |
src/prompt/ | Prompt builder, stable prefix, token budget. See PROMPT.md for full anatomy of the stable prefix and variable tail. |
src/session/ | Session state + sqlite persistence |
src/agent/ | Agent loop + step executor + parallel batch executor (batch-executor.ts) + resource-class taxonomy (tool-resource-class.ts) + no-progress loop detector |
src/tools/ | Tool registry + individual tools. OS tools: shell.run (direct-exec by default; routes to a sh -c subshell when needsShellInterpretation sees shell metacharacters | & ; > < $ \`` or a pre-joined command line in cmdwith emptyargs— the common ENOENT trap where the model puts a whole command line incmd; the guard still inspects a tokenised view of the full line so hardline/dangerous rules match), fs.read(w/offset/limit/lineNumbers), fs.write, fs.list, fs.glob, fs.locate_project(fuzzy project-name → directory over bounded sources, see §"Project path resolution"),fs.grep(bundled ripgrep),fs.edit(atomic string replace),fs.read_document(PDF/DOCX/XLSX/RTF/ODT/PPTX/legacy .doc → plain text via pure-JS),fs.archive.list/fs.archive.read_entry/fs.archive.extract(zip/tar/tar.gz/gz via pure-JS; zip-slip + bomb guards),fs.hash(md5/sha1/sha256/sha512 streaming),fs.diff(unified diff, jsdiff),fs.patch(dry-run default, all-or-nothing apply),fs.watch(chokidar one-shot, timeout-capped),git.status/git.log/git.diff/git.show/git.blame/git.branch(read-only shell-out with structured parse),proc.list/proc.kill(ps/tasklist + approval),http.request(curl + host allowlist +config.http.approvalMode), web.search(configured provider; keyless Exa with a DuckDuckGo fallback by default, SearXNG/Brave selectable viaweb.search.; Exa/Brave use an env API key when present, see §"Web search reliability"), web.fetch(read a known URL as markdown/text),clipboard., window.*, notify`. |
src/compressor/ | Result compressor, log summariser |
src/sandbox/ | git worktree + sandboxed command runner |
src/approval/ | Approval gate and event wiring |
src/tracing/ | Structured logger + metrics + trace recorder (src/tracing/trace/) |
src/replay/ | Trace-based replay: drift detection + optional LLM re-inference |
src/memory/ | Memory fabric: ProfileStore (key/value facts, pinned + contextual) + MemoryStore (FTS5 freeform notes) + async end-of-turn reflection that writes into both. See MEMORY.md. |
src/runtime/ | bootstrap.ts (assembles AgentRuntime) + turn-controller.ts (per-session FIFO queue + per-session event hook map; the only path into AgentLoop.runTurn) + steering-inbox.ts (out-of-band per-session mailbox for messages that arrive mid-turn). See §"Concurrency contract" and §"Mid-turn steering". |
src/tasks/ | Durable queue of deferred runTurn submissions: TaskStore (SQLite), TaskRunner (drain + retry/backoff), task-backoff, task-schedule (cron / interval / at resolver). See §"Durable tasks" and §"Background autonomy". |
src/scheduler/ | One-process Scheduler (single setInterval) that polls TaskStore.listDue via TaskRunner.runDue. The only periodic timer in the runtime. See §"Background autonomy". |
src/http/route-webhooks.ts + webhook-template.ts + webhook-session-store.ts | Generic POST /api/webhooks/:name ingress. Always materialises into a TaskRecord, never calls runTurn directly. See §"Background autonomy". |
src/tools/tasks/ | Agent-facing self-scheduling tools (tasks.schedule, tasks.cron, tasks.list, tasks.cancel, tasks.show), gated by tasks.agentToolsEnabled. |
src/llm/provider/ | Provider abstraction layer (LlmProvider interface) + LlamaServerProvider adapter. Text completion stays on LlamaServerClient.complete / completeStream (legacy /completion extension with GBNF + slot ids); vision routes through LlamaServerProvider.describeImage against /v1/chat/completions with OpenAI-shape image_url content blocks. See §"Vision (multimodal input)". |
src/llm/fallback/ | Cross-provider circuit breaker (ProviderFallbackChain) that wraps the llmComplete / llmCompleteStream seams and fails over between configured provider ids when the active one is unavailable. Timer-free lazy probe. See §"Provider fallback chain". |
src/tools/vision/ | vision.describe tool + loadImageFile helper. Registered whenever config.vision.enabled is true and a provider is constructed; the actual capability gate (capabilities.vision) is a dynamic getter that re-reads ModelProfile on every check, so vision availability tracks ModelProfileManager hot-swaps without a restart. See §"Vision (multimodal input)". |
src/channels/telegram/ | TelegramChannel (lifecycle + live-control), inbound-handler (slash commands + dispatch into runTurn), outbound-sender (chunked replies + 429 retry), approval-bridge (inline-keyboard approvals with 8-min auto-deny), pairing-mode (60s window for first-DM owner claim), telegram-settings (config.json + .env persistence), telegram-bot-factory (grammy adapter). The only module that imports grammy. See §"Telegram remote-control channel". |
src/tui/telegram/ | TUI "Telegram" tab: telegram-panel-state + telegram-actions + telegram-panel-reducer (pure UI state slice), tui-telegram-orchestrator (the only TUI module that touches runtime.telegramChannel), telegram-key-bindings, and the telegram-panel / telegram-token-prompt / telegram-pairing-modal components. See §"Telegram remote-control channel". |
src/mcp/ | MCP (Model Context Protocol) client subsystem. McpManager (lifecycle for N McpClient instances), mcp-client (the only file that imports @modelcontextprotocol/sdk — together with mcp-sampling-handler for SDK type shapes), mcp-tool-adapter (McpToolMeta → ToolDefinition), mcp-resource-class (per-server trust → ResourceClass resolver), mcp-descriptor-builder (rare-tier descriptors), mcp-grammar-builder (dynamic mcp-server-tool GBNF fragment), mcp-sampling-handler (forwards sampling/createMessage to LlamaServerClient with slotId: -1), mcp-resource-tools + mcp-prompt-tools (aggregate read-only mcp.{resource,prompt}.* tools dispatching by server arg). See §"MCP client". |
src/tui/mouse/ | TUI mouse layer: mouse-tracking (1000+1006 enable/disable), parse-mouse-events (SGR + legacy X10 decoder), mouse-stdin (splits mouse bytes out of the stream Ink reads), mouse-registry (Yoga-based hit testing), selection-passthrough / drag-intent (hands the terminal its drag-to-select back for a 10 s window), mouse-context / mouse-list-row (React glue + the shared click-to-select-then-activate row), synthetic-key (wheel/second-click → the panel's own key handler). See §"Mouse support". |
Secrets and process environment
Skills that need API keys (Notion, GitHub, etc.) read them from process.env. The agent populates process.env once at bootstrap from the optional file <stateDir>/.env via loadDotenvFromStateDir in src/config/load-dotenv.ts, invoked from src/config/load-config.ts immediately after stateDir is resolved and before ensureUserConfigFileSync. Shell-exported variables always win — the loader only sets a key when it is currently unset or empty. Missing file is a silent no-op. The parser is deliberately tiny (KEY=VALUE per line, optional surrounding quotes, # comments, blank lines; no interpolation, no export prefix, no multiline values) so we do not depend on the dotenv package.
The startup read is defensive about transient locks (#59). A failing read of an existing .env is retried up to 3 attempts with 50/150 ms backoff when the errno code is EPERM, EACCES, EBUSY, or EAGAIN (the family Windows antivirus and sync clients surface; on POSIX an EACCES is almost always permanent and simply costs one ~200 ms loop before the warning). Any other code fails fast, and a missing file (ENOENT) stays the silent no-op above. The load outcome travels on the runtime config as config.dotenv (DotenvLoadResult: the .env path, exists, loaded/skipped variable names, and error carrying the errno code plus attempt count; values never cross this surface, and parse diagnostics name line numbers, not line content). A failure that survives the retries is printed to stderr by the loader and repeated by the TUI as a warn-variant system chat message with platform-specific guidance, because the stderr line scrolls away before the alt screen takes over. On the write side, after setDotenvKey tightens the .env ACL via src/config/windows-acl.ts (icacls /inheritance:r /grant:r), it probe-reads the file as the current process and rolls the ACL back with icacls /reset when the probe fails, so a wrong-principal grant cannot leave behind a file the agent itself can no longer read. Pinned by src/config/load-dotenv-retry.test.ts (retry-then-success, persistent-failure warning, fail-fast codes, silent ENOENT), src/config/load-config.test.ts ("carries the .env load outcome as config.dotenv" / "reports an unreadable .env in config.dotenv.error without throwing"), and src/config/windows-acl.test.ts (tighten/probe/rollback).
There is currently no per-tool env filtering. runCommand in src/sandbox/command-runner.ts inherits the full agent process.env, so every spawned subprocess (os.shell.run, runSkillScript, the managed llama-server, future MCP servers) sees every variable loaded from .env. Tightening this — per-skill env_vars whitelist + safe-baseline filtering (PATH, HOME, USER, LANG, TERM, XDG_*) — is tracked as a separate effort and pinned by no tests yet. Do not assume isolation when designing new skills that handle highly sensitive secrets; document the shared-env reality in the skill's SKILL.md instead.
Web search reliability
os.web.search defaults to web.search.provider = "exa" with a
["duckduckgo"] fallback. Exa's MCP endpoint answers keyless when
EXA_API_KEY is unset, and that keyless tier returns HTTP 429 under sustained
agent load — a GAIA validation campaign logged 1341 Exa returned HTTP 429
errors, 44% of all tool failures in the run (#179). Two mechanisms keep that
from silently deciding answer quality:
- Retry before falling through. transport/retry-after.ts
owns the schedule;
searchHttpretries a 429 against the same provider (default 2 retries, 500 ms doubling) before returning it. Without this, one transient 429 permanently downgraded a session to the weakest provider in the chain, because the orchestrator advances on any throw. A serverRetry-Afterwins over the local schedule; both are clamped toMAX_RETRY_AFTER_MS(10 s) so one hostile header cannot stall a turn. The header rides the existingcurl -wmeta line via%header{retry-after}(curl >= 7.83; older curl emits the literal format string, which is read as absent). Retries are spent, not skipped, when the limit is real — the fallback chain remains the backstop. - Name the degradation. tool/warn-missing-search-key.ts
emits one stderr line at tool construction when the primary provider reads
an
apiKeyEnvthat resolves to nothing. The fallback chain works as designed, so nothing hard-fails; the run just produces weaker groundings than configured. Warning once at construction (not per search) is deliberate: a long autonomous run would drown in a per-query warning.
The result cache and the #241 provider cooldown survive the process
(#256): given a stateDir, transport/search-cache.ts
and transport/provider-cooldown.ts
mirror both to <stateDir>/web-search-cache.json /
web-search-cooldown.json, so a campaign that runs one agent process per
task inherits the last process's answers, parks, and strikes instead of
starting cold and re-spending quota — the demand-side half of #179 that a
longer TTL cannot reach, because a per-task process dies long before any
TTL binds. Key, TTL (cacheTtlMinutes, default 60), the 256-entry FIFO
cap, and the park/escalation ladder are unchanged — only the storage
moved. Rows already expired and cooldown records staler than two doubling
ceilings are dropped on load, a park that lapsed while nothing ran reads
as expired, every write goes through tmp-file + rename, and a missing or
corrupt file starts cold while a failed write is swallowed — persistence
is an optimisation and must never fail a search. Concurrent processes
race benignly (last writer wins; no locking). web.search.persistCache: false (config v46) opts back into the in-memory pair for workloads that
want a cold cache per run. The key is still the exact query string, so
near-miss rephrasings still miss — query normalisation is deferred by
#256 for separate measurement.
Pinned by retry-after.test.ts,
search-http.test.ts
(retry-then-succeed, Retry-After precedence, give-up-after-maxRetries,
non-429 untouched, old-curl tolerance),
warn-missing-search-key.test.ts,
web-search-tool.test.ts
("warns once at construction, not once per search"; the persistent-cache
describe: cross-instance hit, persistCache: false, no stateDir),
search-cache.test.ts
and provider-cooldown.test.ts
(round-trip, load-time expiry/eviction, ladder-across-restart, corrupt and
malformed files), and the #256 seam case in
bootstrap.test.ts, which boots the real
runtime against a pre-seeded stateDir and proves os.web.search answers
from the file — so deleting the stateDir wiring in createAgentRuntime
or registerOsTools fails loudly instead of silently shipping the
in-memory behaviour.
HTTP retry contract
os.web.fetch and os.http.request both retry transient failures
(429/502/503/504 plus curl's timeout exit 28) with exponential backoff capped
at retryMaxDelayMs, honouring a server-sent Retry-After. The RFC 9110
value grammar lives once in retry-after-header.ts
— both tools read the header off curl differently (%{header_json} vs
%header{retry-after}) but normalise it through the same parser.
os.http.request additionally guards non-idempotent methods. Unlike
web.fetch, it can POST. An origin may already have processed a request whose
response never arrived, so a blind replay risks a double submit — the one
failure mode a retry layer must not introduce. A GET is replayed on any
retryable status or a timeout; a POST is replayed only on 429/503 that also
carries a Retry-After, which is an explicit "I did not process this, come
back". A bare 502/504 or a timeout on a POST is returned as-is.
Pinned by http-request-retry.test.ts
(retryable statuses, Retry-After precedence and clamping, give-up, stable-4xx
passthrough, timeout handling, and the four non-idempotent-method safety cases)
and retry-after-header.test.ts.
Build & test
npm install
npm run lint # tsc noEmit
npm test # vitest run
npm run build # compile to dist/
The CLI entry is src/cli/index.ts; the sidecar entry is src/sidecar/main.ts.
LLM provider abstraction
Text completion, vision, embeddings, and sub-calls route through plugin-registered providers instead of calling LlamaServerClient directly from the agent loop.
Registry and transport
ProviderRegistry(src/llm/provider/registry/provider-registry.ts) —registerProviderKind(kind, factory)+fromConfig(config). Built-in kinds self-register in register-built-in-providers.ts:llama-server,openai-compatible,qwen-openai-compatible,openrouter,aimlapi,gemini,subscription-cli.subscription-cli(src/llm/provider/subscription-cli/) — drives an already-signed-in vendor CLI (claude,codex) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised byentry.subscriptionCli.cli; every CLI-specific byte (argv builders, output parsers, hints) lives behind aCliAdapterDescriptor, so a new vendor CLI is a descriptor plus aSUBSCRIPTION_CLISentry and never a new provider kind. It declaresnative_toolswhile never returningtool_calls: an emptytoolCallssends step-executor down its guarded recovery ladder, whereasgrammarwould throw out ofparseToolCallson any drift and pay for a second CLI invocation on the repair path.LlmProvider(src/llm/provider/llm-provider.ts) —complete,completeStream,describeImage,health,close,capabilities, optionaltoolCallAdapter+streamConsumer.toolTransport—grammar(GBNF on llama-server) vsnative_tools(OpenAItools/tool_calls). Resolved byresolveActiveToolTransportfromconfig.llm.toolTransport(autofollows the active provider).- Name escape — qualified tool names use
__for dots (os.fs.read→os__fs__read) in openai-tool-call-adapter.ts.reply/finishare synthetic OpenAI functions alongside registry tools. - Vendor presets (src/tui/providers/provider-presets.ts) — 19 named cloud/local endpoints (Anthropic, Groq, Moonshot, Perplexity, Qwen/DashScope, SambaNova, …) that all resolve to the existing
openai-compatiblekind withbaseUrlprefilled. Adding a vendor is a preset entry, not a provider kind. The one documented exception issubscription-cli— a subprocess backend has no baseUrl, no key and no HTTP path, so a preset cannot express it; within that kind the preset philosophy re-applies one level down (a new vendor CLI is a descriptor entry, never a new kind). Vendors that do not authenticate withAuthorization: BearersetapiKeyHeader(Anthropic:x-api-key) plus any mandatory staticheaders(Anthropic:anthropic-version); both are copied onto the saved config entry by providers-wizard-build-entry.ts and applied to both request paths by the single openai-auth-headers.ts builder, so discovery and chat cannot disagree. The bar for a new entry: probe<baseUrl>/v1/modelswith the headers the preset will actually send and get either 200 with adataarray, or a 401/403 that rejects the credential — a 401 whose body names a header the preset does not send (x-api-key header is required,Invalid bearer tokenfor what is an API key) is a failing probe, not a passing one. Either way the same host must answer 404 for a bogus sibling path; a gateway that rejects everything before routing proves nothing. - Bundled catalogs —
OPENROUTER_MODELS_CATALOG(split acrossopenrouter-frontier-chat-models.ts/openrouter-open-weight-chat-models.ts) andAIMLAPI_MODELS_CATALOGare offline snapshots regenerated from each vendor's public/modelsendpoint; the shared row builders live in model-catalog-entry.ts. Refresh = re-pull the endpoint, remap (context_length,input_modalities→ vision,supported_parameters→ tools, price × 1e6 → USD/1M) and update the date in each file header.scoreChatin the OpenRouter fetcher ranks vendors; it must not gate them — the Anthropic/Gemini exclusions it used to carry hid ~40 served models from the picker. - Model search (src/llm/provider/model-search.ts) — one ranked, multi-term scorer over model ids plus catalog metadata (vendor,
vision/text,tools,cache, context shorthand like1m,free/cheap/routed). Tag matching is exact equality, so a context window is tagged three ways — as displayed (1.0m), floored to the whole unit (1m, the bucket a window falls in rather than a>=filter: 1_310_720 answers to both1mand1.3m, a 2M window only to2m), and, when the window is an exact multiple of 1024, in binary (131_072 answers to128k). Add a tag rather than changing format-model-details.ts: the display string is what the rows render. Terms are ANDed, matches are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order so the picker does not jitter per keystroke. Used byfilterModelIds(TUI modal picker + Cloud pane) and byatomic-agent models search. Row rendering is shared through format-model-details.ts — do not re-implement the price/context/capability strings in a frontend.
Bootstrap wiring
src/runtime/bootstrap.ts constructs providerRegistry, wires llmComplete / llmCompleteStream through providerRegistry.activeText, and passes toolTransport + toolCallAdapter into AgentLoop. When capabilities.supportsSlotAffinity === false (cloud), step execution uses slotId: -1.
Optional config.llm (v24) lists providers[], activeTextProvider, activeEmbeddingProvider, toolTransport, userModels[], costTracking. When omitted, resolveLlmConfig synthesizes a single local-llama entry from localModels.* — local-only installs stay byte-stable.
Hot-swap
ProviderRegistry.setActive(id) / swapActive(id) closes the previous provider and switches the active text backend without process restart. TUI Providers tab (src/tui/providers/) is the only surface that calls this seam.
Credential check before save
A cloud provider is verified before anything reaches disk. src/llm/provider/verify/ is UI-free: verifyProviderKey(target) posts one max_tokens: 1 completion through openAiFetch (deliberately not openAiPostJson — a key check must not spend the retry budget), and classifyVerifyResponse maps the answer onto ok | invalid_key | no_balance | model_unavailable | rate_limited | unreachable | timeout | provider_error | cancelled. Status codes alone do not settle it: prepaid services answer 401/403 once credit runs out, OpenAI sends 429 insufficient_quota, and Gemini answers 400 for a bad key, so the body is consulted for billing/key wording first. pickProbeModels picks the cheapest paid OpenRouter model — a free model answers 200 on a key with no balance, which would make the check meaningless.
verifyWizardBeforeSave (src/tui/providers/verify-wizard-before-save.ts) is the single seam; both the wizard (ProvidersOrchestrator.completeWizard) and first-run onboarding (CloudProviderOnboarding) go through it. Only invalid_key and no_balance block a save (isBlockingVerifyStatus); everything else saves and reports, so an offline machine stays configurable. Esc cancels a check in flight (cancelSubmit → providers_wizard_verify_cancelled), and a verdict arriving after a cancel is dropped.
A cancelled check is inert, at both call sites. verifyProviderKey samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and classifyVerifyResponse returning still comes back as an ordinary verdict — never "cancelled". Both callers therefore re-ask after the await whether the answer is still wanted: completeWizard on abort.signal.aborted, CloudProviderOnboarding on the same signal plus its mount check, at the success and the failure exit. A React submitting flag cannot carry this on its own: it is captured in the submit closure and the cancel handler resets it, so Enter after Esc — and two key events drained from stdin in one turn — read a stale false and started a second check racing the first to write the same provider. Re-entry is guarded on the in-flight AbortController ref instead, which is written before the first await and cleared only by the run that owns it or by a cancel.
Pinned by src/llm/provider/verify/classify-verify-response.test.ts, verify-provider-key.test.ts, pick-probe-models.test.ts, src/tui/providers/verify-wizard-before-save.test.ts, providers-wizard-target.test.ts, the completeWizard cases in providers-orchestrator.test.ts, and the cancel-then-resolve cases in src/tui/components/cloud-provider-onboarding.test.tsx.
Locked invariants
-
Local llama-server path unchanged when no cloud provider is active. Grammar, slots, and GBNF tests remain the reference behaviour.
-
Stable prefix untouched. Cloud providers receive the same monolithic prompt string; no chat-message refactor in v1.
-
One inference per step survives.
toolCallsToBatchproduces the sameToolCallBatchshape asparseToolCalls. -
atomic-agent servenever proxies upstream. HTTP/v1/chat/completionsalways funnels intoruntime.runTurn. -
Plugin registration only. New provider kinds call
registerProviderKind; no central switch statements. -
Every default tool ships a structured
argsJsonSchema.ToolDescriptor.argsJsonSchemais consumed exclusively bydescriptorsToOpenAiTools(openai-tool-call-adapter.ts) to populatefunction.parameterson the OpenAItoolspayload. Without it, cloud providers fall back to{ type: "object", additionalProperties: true }— which is what we shipped originally and what enabled theos.shell.runsilent-arg-drop bug (model double-serialisedargsinto a JSON string, the provider accepted it, the tool coerced the non-array to[]without warning, the model never learned). The canonical map lives in src/prompt/default-tool-args-schemas.ts; it is merged intoDEFAULT_TOOL_DESCRIPTORSviaattachDefaultArgsJsonSchema. MCP descriptors carry the server'sinputSchemaverbatim through the same field. Adding a new tool requires an entry inDEFAULT_TOOL_ARGS_SCHEMAS(pinned by src/prompt/default-tool-args-schemas.test.ts "attaches a schema to every default descriptor that has one registered"). Local llama-server with GBNF does not consume this field — the grammar already constrains the shape. -
os.shell.runrejects non-arrayargsstructurally. A non-array, non-JSON-array-stringargsvalue now returns{ status: "error" }instead of silently dropping the operator's intent. JSON-stringified arrays (the cloudnative_toolsdouble-serialise pattern) are auto-coerced back tostring[]. Pinned by src/tools/os/os-tools.test.ts ("returns a structured error whenargsis an object" / "is a scalar string" / "recovers a JSON-stringified arrayargs"). -
Subscription CLIs are driven, never impersonated.
subscription-clishells out to the vendor CLI's documented headless mode (--print) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes--bare(whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clearsANTHROPIC_API_KEY. It always disables the child's own tools as far as the CLI allows —--tools ""+--strict-mcp-configonclaude,-s read-only+--ignore-user-configoncodex, which confines rather than removes them — so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which wouldE2BIGon a full two-zone prompt. Pinned by claude-cli-adapter.test.ts ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv"). -
A broken stdin pipe is expected, not fatal. Because the prompt travels on stdin, every spawn site that writes it carries an
errorlistener onchild.stdin— command-runner.ts and stream-cli-completion.ts. A CLI that rejects the request (signed out, unknown model, rate-limited) exits without draining stdin, and a prompt past the ~64 KiB pipe buffer then raisesEPIPE; anerroron a stream with no listener is fatal, andinstallGlobalErrorHandlersdeliberately preserves that, so the operator would lose the whole session instead of seeingSubscriptionCliAuthError. The same fires on our own Ctrl+C, wherestop("abort")SIGTERMs the child mid-write. Broken-pipe codes are absorbed and the child's own exit code and stderr report the failure;CommandResult.inputTruncatedcovers the CLIs that exit 0 regardless (codex), which would otherwise pass a half-delivered prompt off as a good completion. Every other stdin error still travels. Relatedly,streamCliCommand'sfinallycancels the SIGKILL escalation only once the child has exited — clearing it unconditionally cancelled the timerstophad just armed, leaving one orphan per aborted turn behind any child that traps SIGTERM. Pinned by command-runner.test.ts, run-cli-completion.test.ts and stream-cli-completion.test.ts ("force-kills a child that traps SIGTERM instead of orphaning it").
Embeddings
Symmetric EmbeddingProviderRegistry (src/memory/embeddings/embedding-provider-registry.ts) with OpenAiEmbeddingProvider / OpenRouterEmbeddingProvider for POST /v1/embeddings. Hybrid recall degradation contract unchanged.
llama-server modes
atomic-agent supports two modes for the llama-server backend (config.llama.mode):
external(default) — user runsllama-serverout-of-band; runtime reads the URL fromconfig.llama.url(env fallbackATOMIC_AGENT_LLAMA_URL).managed—atomic-agentdownloads the llama.cpp binary fromAtomicBot-ai/atomic-llama-cpp-turboquant-nightlyGitHub Releases into<stateDir>/llamacpp/backend/and GGUF models into<stateDir>/llamacpp/models/<id>/. The server is not spawned by the runtime; operators control lifecycle viaatomic-agent llama start|stop|status|update. Managed start auto-pulls a newer zip whenlocalModels.managed.autoUpdateis true (default since config v41). A failed check or download never blocks start — the existing binary is used. The two entry points differ deliberately: TUI auto-start brings the daemon up first and runs the update afterwards, off the start path, so the user never faces a typeable prompt with no model behind it; that pass also refuses to stop the live daemon (keepDaemonRunning), so the swap lands on the next start. CLImodels startis an explicit one-shot command, so it still updates before starting. Both bound the download with a timeout.
Invariant (preserved): the agent runtime never starts a llama-server process. It only connects. Managed-mode lifecycle lives entirely in the atomic-agent llama CLI so runtime code paths stay single-mode.
When the server is unreachable, the sidecar emits an llm_unavailable event with the current mode and a hint. In managed mode the hint points at atomic-agent llama start; in external mode it points at checking llama.url / ATOMIC_AGENT_LLAMA_URL.
Current memory model
Today the runtime persists session-scoped state only:
SessionState.turns[]for the full multi-turn transcriptknownFacts[]for compact session factsloadedSkills[]for skill bodies loaded viaskill.viewloadedTools[]for full rare-tool descriptors loaded viatool.view(see §"Rare tools: tool.view and loaded-tools")worldSnapshotfor compressed browser state
SessionState.turns[] stores the full history in memory and in the sessions DB unchanged. Prompt-time compression happens only at the buildPrompt boundary via packConversation: older turns get folded into a single deterministic summary: N older turns dropped (...) line so the variable tail of the prompt stays bounded without losing traceability. The visible tail always includes the latest user turn.
Prompt-section caps live in the config:
agent.tokenBudget— compact ceiling for the upper prompt: stable prefix plus a shared budget for### loaded-skills+### session-facts(known facts and loaded skill bodies).agent.conversationMaxTokens(default 32000) — safety-net cap for the### conversationsection; typical sessions stay well under it.agent.worldSnapshotMaxTokens(default 8000) — safety-net cap for the### worldsection; the snapshot is already compressed byaria-compressor, so this only clips pathological cases with a[truncated]marker.
At bootstrap LlamaServerClient.fetchProps() reads the model's physical n_ctx (from default_generation_settings.n_ctx, with a root n_ctx fallback) and stores it on ModelProfile.contextWindow. buildPrompt then clamps the effective conversation cap to the actual available room so the prompt cannot overflow llama-server regardless of how large the user-configured cap is. If llama-server is restarted with a different n_ctx, restart the runtime.
There is currently no dedicated workspace-memory, retrieval, embeddings, or resource-summary subsystem in src/. Do not describe those modules as implemented unless they are added to the codebase first.
Memory fabric
A three-channel cross-session memory subsystem lives in src/memory/ and exposes itself to the agent via six tools in src/tools/memory/. The full description is in MEMORY.md; this section is the engineering summary. The v2 roadmap (paths B+C+E+P: reactive graph, periodic consolidation, vote curation, procedure templates) lives in MEMORY_FABRIC_V2.md and rolls out in strict-gated phases. Plan-level deviation from doc §9 invariant 2: v2 pays the stable-prefix KV-cache invalidation twice (once when ### lessons lands in phase 5, once when ### procedures lands in phase 7b) instead of the doc's intended single combined release — the strict-gates rollout requires evaluation windows between the two prefix-touching phases.
Memory-v2 phase 1B — hybrid FTS5 + embedding recall (opt-in)
Lives in src/memory/embeddings/ and is off in config until the operator enables it from the TUI Models tab (download + start embedding model). When turned on, it adds a second llama-server process dedicated to /embedding requests and blends BM25 hits with cosine similarity over a memory_embeddings table (schema v5).
Two-daemon lifecycle. llama-server cannot serve /completion and /embedding from the same process — the --embeddings flag forces pooling-only mode. The CLI therefore manages two parallel daemons:
- Chat daemon (primary): existing flow, port
localModels.managed.port(default 19091), pid<stateDir>/llamacpp/llama-server.pid. - Embedding daemon (optional secondary): new flow, port
localModels.embeddings.port(default 19092), pid<stateDir>/llamacpp/llama-embed.pid. Built bybuildEmbeddingServerArgswith--embeddings --pooling <kind> --ctx-size 2048and a dedicated embedding model GGUF (nomic-embed-text-v1.5~84 MB,bge-small-en-v1.5~33 MB — both inEMBEDDING_MODELS_CATALOG).
atomic-agent models start calls startChatAndEmbeddingDaemons which is atomic in the chat-primary sense: if the chat daemon fails to start, the function rejects and the embedding daemon is never spawned. If the embedding daemon fails (model missing, port collision, daemon refuses health) the chat daemon stays up and the call returns { embedding: { error } } — the runtime then degrades to FTS5-only recall and logs a warning. atomic-agent models stop always tries to kill both pid files, so half-broken states resolve in a single command. New CLI subcommands: models list-embeddings, models pull-embedding <id>, models use-embedding <id>|--disable.
Graceful degradation contract. Every layer that touches the embedding daemon is non-throwing:
LlamaEmbeddingClient.embedwraps every transport / shape / dim mismatch failure as a typedEmbeddingUnavailableErrorso callers can branch without sniffing messages.EmbeddingWriter.writeForreturnsbooleanand never throws — failures land in theagent.memory.embeddings.fallback_to_fts5counter (tagged byreason) and the row stays FTS5-only-recallable.recallHybridshort-circuits to BM25-only when the embedding client / store is null, whenembed()fails, or when the corpus exceedsmemory.embeddings.bruteForceCeiling(default 200). The overflow case emitsagent.memory.embeddings.brute_force_overflowso dashboards spot when the JS-side brute-force cosine has outgrown its budget — sqlite-vec / ANN migration is the deferred follow-up.- Bootstrap probes the embedding daemon's
/healthonce with a short timeout; failure flips the runtime to text-only without aborting startup. The probe outcome is recorded inagent.memory.embeddings.daemon_health(ok | unreachable | disabled).
Storage. Schema v5 adds memory_embeddings (memory_id, model, dim, embedding BLOB, created_at) keyed by (memory_id, model) so a single corpus can host multiple model versions during an A/B rollout. embedding is raw Float32 little-endian (dim * 4 bytes). FOREIGN KEY ON DELETE CASCADE from memories(id) cleans up automatically on MemoryStore.remove — foreign_keys=ON is set on the connection at construction time. Stores are wired in bootstrap via MemoryStore.attachEmbeddings({ writer, store }), which is intentionally late-bound: MemoryStore owns the SQLite handle, EmbeddingStore opens against the same handle once the daemon is confirmed healthy.
Read/write paths. Synchronous MemoryStore.store() kicks off a fire-and-forget embedding write (zero latency penalty for existing callers); MemoryStore.storeAsync() is the awaited sibling for tests that need the row to be hybrid-recallable on the next turn. MemoryStore.recallHybridAsync() is the new public entry point — it always goes through recallHybrid and is observably identical to the legacy recall() when embeddings are not attached. createDefaultMemoryContextProvider was switched to the async variant; the MemoryContextProvider interface already accepted Promise<MemoryContext>.
Locked invariants. Pinned by src/memory/embeddings/embedding-client.test.ts, src/memory/embeddings/embedding-store.test.ts, src/memory/embeddings/hybrid-recall.test.ts, src/memory/memory-schema.test.ts, src/local-llm/daemon-lifecycle.test.ts:
- Two-daemon separation is structural, not optional.
llama-server --embeddingsforces pooling-only mode; a single instance cannot serve both/completionand/embedding. Anyone adding embedding usage MUST go through the secondary daemon's URL. - Embedding writes never block the agent loop.
MemoryStore.store()is synchronous; the embedding write is fire-and-forget. Tests that need determinism callstoreAsync(). - Failure paths are observability-only. Bootstrap, recall, write — none of them throw on embedding-daemon outages. Every degradation is a metric counter, never a status-
failedturn. - Cosine path skips on overflow. When
countByModel(model) > bruteForceCeiling, cosine is not computed; FTS5-only result returns andbrute_force_overflowincrements. This is the signal to wiresqlite-vecor trim the corpus. - FK cascade is load-bearing.
memories.remove(id)must wipe the matchingmemory_embeddingsrow in the same transaction; the testcascades delete from memories -> memory_embeddingspins this. - Config gates everything.
memory.embeddings.enabled=falseORlocalModels.embeddings.enabled=falseORlocalModels.embeddings.modelId=null⇒ no second daemon, no embedding writes, no hybrid recall. Bootstrap never constructs anEmbeddingClientagainst an absent daemon.
Configuration. Added in user config v12 — older files transparently migrate with both blocks disabled.
memory.embeddings.enabled(defaultfalse; TUI setstruewhen the embedding daemon is running — seepersistEmbeddingHybridRecall+LocalModelsOrchestrator.reconcileHybridRecallFromDaemon).memory.embeddings.fts5Weight/vectorWeight(defaults0.5/0.5, both in[0, 1]).memory.embeddings.bruteForceCeiling(default200).localModels.embeddings.enabled(defaultfalse; flipped by TUI pull/activate/E) — must betruefor the second daemon to be considered at startup.localModels.embeddings.modelId(defaultnull— must be one ofEmbeddingModelIdonce chosen in TUI).localModels.embeddings.port(default19092).
Out of scope (deferred to follow-up). sqlite-vec virtual table integration (the JS brute force handles current corpus sizes; sqlite-vec graduates the schema without touching EmbeddingStore callers), ANN indexes, cross-model query embedding fallback (when the active model changes, the existing rows under a different model are simply skipped — there is no auto-reembed sweep yet), embedding-side reflection (the writer is only invoked by MemoryStore.store; reflection's own writes happen through the same path so they get embedded too, but there is no dedicated "embed everything" CLI command). All deferred items keep the wire-shape of memory_embeddings stable so no future migration is forced.
Memory-v2 phase 2 — reactive link graph (opt-in)
Lives in src/memory/links/ and is enabled by default (memory.links.enabled=true, config v22). It gives memories a typed, directed graph layer that is grown by an end-of-turn LLM sub-call (link-generator) and consumed by MemoryContextProvider as BFS expansion on top of the BM25/cosine hits.
Storage. Schema v6 adds:
memory_links (
from_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
to_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL,
PRIMARY KEY (from_id, to_id, kind)
)
Composite PK allows multiple link kinds between the same pair (a note can both RELATES_TO and CONTRADICTS another note). FK cascade fires on either side, so MemoryStore.remove(id) automatically wipes every edge touching the removed row — no orphan links. kind is bounded by LINK_KINDS = { RELATES_TO, CAUSED_BY, REFERENCES, CONTRADICTS, DUPLICATES, SUPERSEDES }; LinkStore.add and the parser both reject anything outside this set. Self-loops are rejected at insert time. Indexes idx_memory_links_to (reverse direction) and idx_memory_links_kind exist so BFS can walk in either direction in O(deg).
BFS expansion. LinkStore.expand(seedIds, { depth, maxExpanded, kinds? }) walks outgoing + incoming edges at each hop so the recall layer does not need the LLM to author symmetric edges. Returns ids in BFS order with seeds excluded; deduplicated. Depth is clamped to [1, 3] to bound BFS fan-out; maxExpanded (default 50, runtime-config default 12) hard-caps the result so a hot node cannot blow up the recall set. Empty seed ⇒ empty result.
link-generator reflection sub-call. A standalone runner in src/memory/links/link-generator-runner.ts that fires after the main reflection runner returns. Composition is wrapped by createLinkAwareReflectionRunner — the agent loop still calls reflectionRunner.reflect(input); the decorator runs base reflection first, then materialises input.recalledMemoryIds (added to ReflectionInput in phase 2 — populated from state.recalledNotes in agent-loop) into {id, body} candidates via MemoryStore.get(id) and fires the link-generator.
Anti-feedback-loop guard (mirrors phase 7a invariant 18 from MEMORY_FABRIC_V2.md §13.7.4): the parser drops every LINK whose endpoints are not in the surfaced-id allowlist for this turn. Without it, a runaway model could connect arbitrary ids and pollute the graph permanently. Self-loops, malformed lines, unknown kinds, and duplicate triples are all silently filtered — one bad line never invalidates the rest of the batch.
Read-side integration. createDefaultMemoryContextProvider takes an optional links block; when memory.links.enabled=true it calls linkStore.expand(recalledBaseIds, …), hydrates the expanded ids via MemoryStore.get, and folds them into recalled after the base hits so BM25/cosine ranking is preserved at the head. The ### memory-index section then dedupes against the expanded set. When links is omitted, the provider is byte-identical to phase 1B.
Slot affinity (cross-phase invariant 2). LinkGeneratorRunner rides the same dedicated reflection slot (slotManager.reserveReflectionSlot()) as the base reflection runner. The main agent slot's KV cache is never touched. When only one slot is available, both runners fall back to slotId: -1 (no cache reuse) — still safe because the main slot is untouched.
Locked invariants. Pinned by src/memory/links/link-store.test.ts, src/memory/links/link-generator-parser.test.ts, src/memory/links/link-generator-runner.test.ts, src/memory/memory-schema.test.ts, src/memory/memory-context-provider.test.ts:
- FK cascade on both sides. Deleting either endpoint memory wipes the link in the same transaction. No orphan rows possible.
- Self-loops are rejected.
LinkStore.add({fromId: a, toId: a, …})throwsLinkValidationError; the parser drops them silently. Self-loops add zero recall signal and break BFS termination guarantees. link-generatorrides the reflection slot. Cross-phase invariant 2 — never the main agent slot. No newsetIntervals introduced; the runner is fire-and-forget after the agent reply, identical pattern toReflectionRunner.- Allowlist gates every persisted edge. Parser drops links whose endpoints are not in the per-turn surfaced-id set. Without this guard the graph could grow edges between memories the LLM never saw — a feedback-loop hazard pinned ahead of phase 7a.
- Recall stays byte-identical when
memory.links.enabled=false.createDefaultMemoryContextProvidershort-circuits without touchingLinkStorewhen the optional block is missing. Phase 1B test snapshots remain valid. - BFS is bounded. Depth clamped to
[1, 3],maxExpandedcaps the result, BFS expands both directions. Hot-node fan-out cannot blow up the recall set. autoGenerate=falsestill allows manual graph growth. Withenabled=true; autoGenerate=false, recall expansion works but the link-generator LLM call is skipped — useful when external tooling (or a futurememory.links.addtool, deferred) is the only graph writer.- Multi-hop demo lives in the test suite.
src/memory/memory-context-provider.test.ts > "phase 2: expands recalled via link graph when enabled (multi-hop demo)"pins the synthetic acceptance from the execution plan — only the seed matches the BM25 query, andA → B → Clink traversal surfaces both downstream notes.
Configuration. Added in user config v13 — older files transparently migrate with memory.links populated from defaults (everything disabled).
memory.links.enabled(defaulttrue, config v22) — master switch for both recall expansion and link generation.memory.links.autoGenerate(defaulttrue) — fire the link-generator after reflection. Set tofalseto keep the schema + expansion machinery without the extra LLM round-trip.memory.links.expansionDepth(default1) — BFS depth on recall. Clamped to[1, 3].memory.links.maxExpanded(default12) — hard cap on expanded-id count per recall turn.memory.links.maxLinksPerCall(default4) — hard cap on persisted edges per link-generator call.memory.links.minCandidates(default2) — skip the LLM call when the surfaced set has fewer than this many ids.memory.links.generatorTimeoutMs(default8000) — hard timeout for the link-generator LLM call.
Metrics. All env-only, exported from src/tracing/agent-metrics.ts:
agent.memory.link_generator(counter, tagged byoutcome ∈ {ok, none, skipped, aborted, timeout, failed}).agent.memory.link_generator.duration_ms(histogram).agent.memory.links_written(counter, tagged bysource ∈ {link_generator, tool, reflection}).agent.memory.link_expansion.hits(counter, tagged byexpandedbucket +depth).
Out of scope (deferred). Agent-facing memory.links.add / memory.links.list tools (the LLM can still grow the graph implicitly via the link-generator; explicit tool access is deferred until a use case actually demands it), weighted BFS ordering beyond the current weight-tiebreaker, graph-aware reflection (the link-generator currently consumes only recalledMemoryIds; consuming the graph neighbourhood of those ids during reflection extraction is a follow-up), and a CLI debugger (atomic-agent memory links show) for graph inspection. Neighbour-evolver landed in phase 3 (see below).
Memory-v2 phase 3 — neighbor-evolver (opt-in)
A reactive metadata-refinement layer that lets a new reflection turn enrich the tags of existing memories without ever touching their content. Modules in src/memory/evolution/ + the grammar/parser/runner pieces inside src/memory/reflection/. Default disabled; opt in via memory.evolution.enabled = true after phase 2 is live in your config.
Grammar. REFLECTION_GRAMMAR in reflection-grammar.ts gained an evolve alternative on entry:
entry ::= set | note | evolve
evolve ::= "EVOLVE #" digits " [tags=" taglist "]" "\n"
A bounded grammar shape (digits = [0-9]+, taglist ::= tag ("," tag){0,9}) keeps malformed completions cheap and the cap deterministic. Touching the grammar invalidates the reflection slot's KV cache for one call; the main agent slot is unaffected.
Parser. reflection-parser.ts extracts EVOLVE lines into ReflectionEvolve { targetId, addTags }. Hardened: empty tag lists are dropped, non-positive ids rejected, malformed lines silently skipped, duplicate EVOLVE entries for the same targetId collapse to last-writer-wins. Lower-cases tags, drops invalid tokens, caps tag count to 10.
Store-level surface. MemoryStore.evolveTags(id, addTags, { leaseMs, now? }) in memory-store.ts is the single mutation entry point. The signature does not accept a content delta — the append-only invariant on MemoryEntry.content is defended at both the runner level (post-parser, content is not even extracted from the LLM line) and the store level (no SQL path can write content). Returns a discriminated EvolveTagsResult:
applied— tags grew,updated_atbumped.skipped_lease_held—consolidating_atlease is fresh, B↔C contention guard fires.skipped_no_change— every proposed tag was already present,updated_atis not bumped (preserves legacy FIFO eviction ordering).missing— target row no longer exists.
Companion lease helpers: acquireConsolidationLease(id, leaseMs, now?) (single-writer atomic check+stamp via SQL WHERE consolidating_at IS NULL OR consolidating_at <= ?), releaseConsolidationLease(id), getConsolidatingAt(id). Phase 5's consolidator will be the only acquire caller; phase 3 only reads the lease through evolveTags. The consolidating_at column itself landed dormant in v4 (phase 1A migration) so there is no schema change in phase 3.
Runner. NeighborEvolver in neighbor-evolver.ts is a non-LLM, fire-safe post-parser writer:
- Enforces
maxPerWrite(default2) as a hard cap; overflow →skipped_cap_hit(the dropped directives still record metrics). - Honours an optional
allowlist(aSet<number>of surfaced ids for the current turn) — directives whosetargetIdis not in the set are dropped withskipped_not_in_allowlist. This is the anti-feedback guard (same idea as the link-generator phase 2's allowlist). - Wraps
MemoryStore.evolveTagsand foldsMemoryValidationError→skipped_invalid. Throws nothing. - Emits one of seven structured outcomes per directive, each tagged through
AgentMetrics.recordMemoryEvolution.
Reflection wiring. ReflectionRunnerDeps.neighborEvolver? (optional) in reflection-runner.ts. When present, the runner applies parsed EVOLVE directives after SET + NOTE writes (so a NOTE created earlier in the same completion is never the target of its own EVOLVE — the surfaced-id allowlist already excludes brand-new rows). The runner reads input.recalledMemoryIds (already plumbed in phase 2) and turns it into the allowlist passed to the evolver. Sequencing inside ReflectionRunner.runOne is now:
parse → write SET → write NOTE → link-generator (phase 2) → neighbor-evolver (phase 3)
No new LLM call. Phase 3 is pure post-parser bookkeeping on top of the same reflection completion. Bootstrap constructs the evolver only when memory.evolution.enabled === true and threads it into buildReflectionRunner.
Locked invariants (pinned by src/memory/memory-store-evolve.test.ts, src/memory/evolution/neighbor-evolver.test.ts, src/memory/reflection/reflection-parser.test.ts):
contentis byte-stable across N evolve calls. Cross-phase invariant 5 from MEMORY_FABRIC_V2.md §13.7.7. Pinned by bothMemoryStore.evolveTags(preserves content byte-stable across N evolves) andNeighborEvolver(invariant 5 — content byte-stable across N evolve calls). No SQL path on the evolve surface ever writescontent; the LLM grammar never carries it either.- B↔C lease honoured.
evolveTagsskips whenconsolidating_at IS NOT NULL AND (now - consolidating_at) <= leaseMs. Stale leases (elapsed > leaseMs) are ignored — the phase-5 consolidator is expected to release on success, but a crashed consolidator can never permanently freeze a row. - Allowlist filters before applyOne. A directive whose
targetIdis not ininput.recalledMemoryIdsnever reachesMemoryStore. Pinned byfilters by allowlist when provided. maxPerWritecap is hard. DirectiveN+1and beyond never callevolveTags. Excess is counted underskipped_cap_hitso dashboards can spot models that routinely propose more evolves than the budget allows.- Tag-cap overflow keeps existing tags. When the target already has
MEMORY_MAX_TAGS(16) tags, every proposed addition is dropped silently — the store returnsskipped_no_changeand existing tags win. Pinned bydoes not write past MEMORY_MAX_TAGS. updated_atis not bumped onskipped_no_change. Legacy FIFO eviction (memory.eviction.utilityWeighted=false) keeps its expected ordering when an evolve is a no-op.enabled=falseis the default. Older configs auto-migrate to v14 withmemory.evolution.enabled = false; parser still recognises EVOLVE but the runner silently drops directives becauseneighborEvolverisundefined. Flip the flag to opt in.- Schema unchanged. Phase 3 reuses
consolidating_at(added dormant in v4 / phase 1A). No new tables, no new migration step;MEMORY_SCHEMA_VERSIONstays at6.
Configuration. Added in user config v14 — older files transparently migrate with memory.evolution populated from defaults (everything disabled).
memory.evolution.enabled(defaulttrue, config v21) — master switch. Off ⇒ no evolver constructed, EVOLVE lines parsed and dropped.memory.evolution.maxPerWrite(default2) — hard cap on applied directives per reflection turn.memory.evolution.leaseMs(default60000) — B↔C lease window in ms.
Metrics. Two counters in src/tracing/agent-metrics.ts:
agent.memory.evolution.applied(counter, tagged bysession_id).agent.memory.evolution.skipped(counter, tagged bysession_id+reason ∈ {lease_held, no_change, not_in_allowlist, cap_hit, missing, invalid}).
The dual-counter shape matches the scorecard's §3.A.4 (applied ≥ 1) and §3.B.2 (skipped{reason=lease_held} ≥ 1) asserts.
Out of scope (deferred). context column on memories (the doc's EVOLVE [context=...] branch — phase 3 sticks to tags-only, which is what the scorecard tests; adding context is a separate schema change), automatic neighbour discovery on writes (today the LLM proposes EVOLVE targets explicitly — discovery-driven evolution depends on phase 5's clustering pass), and an agent-facing memory.notes.evolve tool. The consolidator-side acquireConsolidationLease write path lands in phase 5; phase 3 only reads the lease.
Memory-v2 phase 4 — bi-temporal ProfileStore
profile_facts no longer overwrites on conflict. Every ProfileStore.set produces a new row and the previous active row for the same key is flipped into the supersession chain inside one SQLite transaction. The renderer (### profile) keeps showing only the active row; the chain is exposed to the agent via the new memory.profile.history tool.
Schema v6 → v7 in memory-schema.ts. The migration renames the legacy profile_facts to profile_facts_legacy, creates the v7 shape (id INTEGER PRIMARY KEY AUTOINCREMENT, valid_from, superseded_by, supersedes, created_at, updated_at), copies every legacy row into the v7 table with valid_from = legacy.updated_at and superseded_by = NULL (preserving pinned + keywords), then drops the legacy table. The migration is idempotent; restarting after a successful v7 boot is a no-op. Active-row uniqueness is enforced by the partial unique index idx_profile_active_key ON profile_facts(key) WHERE superseded_by IS NULL — the storage-layer guard for MEMORY_FABRIC_V2.md cross-phase invariant 6.
Store-level surface (profile-store.ts):
set(key, value, opts?)— always inserts a new row. When an active row forkeyexists it gets flipped in the same transaction. Because SQLite's partial unique index is immediate, not deferred, the writer first stamps the parent'ssuperseded_bywith a sentinel self-pointer (= id) so the partial index releases the active slot, then inserts the new row, then patches the parent'ssuperseded_byto the realnewId. The intermediate self-pointer is never visible outside the transaction. Returns aProfileFactwithid,validFrom,supersedes,supersededBypopulated.get(key)/list()— return active rows only (superseded_by IS NULL).getById(id)— returns any row regardless of supersession (used by history walks).history(key)— returns the full chain invalid_from ASC, id ASCorder. The active row, when present, is the last entry. Cross-key supersession chains are not traversed automatically —historyis per-key on purpose so the rendered timeline matches the column header the user sees in### profile.remove(key)— deletes the active row only. Historical (superseded) rows are kept on disk sohistory(key)keeps working. Thesuperseded_byself-pointer is a soft pointer (no FK constraint) precisely so this deletion never cascades into the historical chain.
Cross-key supersession. When the LLM emits SET full_name=Alex [supersedes=name], the parser captures supersedes: "name" on the ReflectionFact. The reflection runner threads this through as set(...).supersedesKey; the store then flips both the same-key active row (if any) and the cross-key name active row to the new row. Same-key supersession always wins for the supersedes back-pointer payload; cross-key parents become superseded but their supersedes column stays NULL (they were not themselves replacing anything). Pinned by profile-store-bitemporal.test.ts ("cross-key supersession via supersedesKey opt flips the source row").
Parser markers. reflection-parser.ts extractSetMarker was extended with two new clauses, both still semicolon-separated inside the existing trailing [...]:
supersedes=KEY— extracts a cross-key hint. Validated against the sameKEY_PATTERNProfileStoreuses, so a malformed RHS (spaces, special chars, oversized) is dropped at parse time and never reaches the writer. Pinned bydrops a malformed supersedes RHS.valid_from=now— the only accepted literal. Any other RHS (explicit timestamp, future date, ISO string) is dropped silently. The runtime always stampsvalid_fromfrom the live clock so the LLM cannot rewrite past history. The field carries through toReflectionFact.validFromas"now" | nulland is consumed by the metrics/audit path only — the store ignores it today.
The GBNF reflection grammar itself is unchanged: value ::= [^\n]{1,200} already accepts the marker syntax. Parser-level validation drops malformed markers without re-prompting.
Reflection prompt. reflection-prompt.ts REFLECTION_STABLE_PREFIX gained a "Bi-temporal versioning" paragraph teaching the LLM about the marker. This is the only prompt change in phase 4 — it invalidates the reflection slot's KV cache for one call. The main agent slot's stable prefix is untouched (none of phase 4's wiring leaks into buildStablePrefix).
Tool: memory.profile.history { key } (profile-history.ts). Read-only. Returns the full chain in temporal order with an active flag on each row and a human-readable summary line ( * [#id] ISO-time: value (active) / [#id] ISO-time: value → #N). Registered alongside memory.profile.{set,remove,list} when memory.profile.enabled is true; resource class is pure_read so it fans out cleanly in parallel batches. Validation errors fold into a structured status: "error" tool result instead of throwing.
Reflection wiring. writeFacts in reflection-runner.ts now threads fact.supersedes (when present) into ProfileStore.set as supersedesKey. Same-key supersession does not need this — every same-key write auto-chains via the partial unique index path. The supersession hint is purely for cross-key cases. Sequence inside ReflectionRunner.runOne is unchanged:
parse → write SET → write NOTE → link-generator (phase 2) → neighbor-evolver (phase 3)
Metrics. New counter agent.memory.profile.superseded in agent-metrics.ts, tagged by key, previous_id, next_id. Fires once per parent flipped — a cross-key supersession that touches two parents will fire twice with distinct previous_ids. (Today the writer fires it only on the structural same-key parent; cross-key supersession parents do not emit the metric — see // TODO below.)
Locked invariants (pinned by profile-store-bitemporal.test.ts, profile-history.test.ts, reflection-parser.test.ts phase-4 cases, reflection-runner.test.ts scenario 4.A — SET with supersedes marker produces a bi-temporal chain):
- At most one active row per key. Enforced by the partial unique index
idx_profile_active_key. Pinned by4.A.3 — partial unique index forbids two active rows for one key. - Every SET preserves history. Scenario 4.A:
ru → enproduces a 2-row chain with therurow marked superseded. Pinned byscenario 4.A — language change preserves history. remove(key)keeps the historical chain. Only the active row is deleted. Pinned byremove() deletes only the active row, history chain intact.- Legacy v6 rows migrate transparently.
valid_from = updated_at,superseded_by = NULL,pinned+keywordspreserved. Pinned bylegacy v3 row migrates to v7 with valid_from = updated_at and active state. - Renderer untouched.
### profilefilters vialist()which already returns active-only rows. Stable-prefix bytes do not change because of phase 4. - Marker validation drops malformed RHS at parse time.
supersedes=not a valid key→supersedes: null. Pinned bydrops a malformed supersedes RHS. valid_fromalways stamped by the runtime clock. The parser strips the literalnow; the store reads it fromDate.now()(or the injectednowarg). Pinned bydrops a non-'now' valid_from RHS silently.- No new top-level config. Phase 4 is always-on once the v7 migration has run. No feature flag. Operators can still call
memory.profile.setwithpinned=false; keywords=...exactly as before — the new fields are additive.
Out of scope (deferred). Cross-key history walks via getById (today history(key) is per-key; following supersedes / supersededBy across keys is left as a manual chain walk for the agent), vote_score column on profile_facts (phase 7a), per-parent metric emission on cross-key supersession (today only the same-key parent fires agent.memory.profile.superseded), an explicit bitemporal.enabled config flag (the schema migration is forward-only so a runtime toggle would have no on-disk effect), and memory.profile.bitemporal.maxHistoryPerKey capping (today the chain grows unbounded — practically bounded by reflection rate which is at most one SET per turn per key).
Memory-v2 phase 5 — lessons + cold-path consolidator (C-half)
Phase 5 closes the read/write loop of the memory fabric: clusters of related episodes (notes linked via memory_links) are distilled into Lesson rows by an out-of-band cold-path consolidator, surfaced into the prompt as ### lessons pointers, archived from ### memory-index but kept fully readable by id, and recalled on demand through the new memory.lessons.recall tool. The agent never sees the full principle body in the stable prefix — the pointer-only surface keeps the variable tail bounded and the rare-tool model of tool.view applies verbatim.
Schema v7 → v8 in memory-schema.ts. Two additive changes:
ALTER TABLE memories ADD COLUMN consolidated_into INTEGER+CREATE INDEX idx_memories_consolidated. A non-NULL value means the row has been folded into a lesson and is dropped from### memory-index(butget(id)still returns it — load-bearing for citation walks from lessonparent_ids).CREATE TABLE lessons(id PK,activation,principle,tags,status ∈ {"active","deprecated"},success_count,failure_count,parent_ids JSON,working_dir,created_at,updated_at,deprecated_at) +lessons_ftsFTS5 virtual table (porter unicode61onactivation, principle, tags) + INSERT/UPDATE/DELETE triggers keeping the FTS index in sync.
Both migrations are idempotent — restarting after a successful v8 boot is a no-op. The legacy schema migration test was extended to seed a minimal memories table at v6 so the v8 ALTER finds its target on partial fixtures.
Stores.
- memory-store.ts gained
archiveInto(parentIds, lessonId, now?)(single atomic UPDATE that stampsconsolidated_into = lessonIdfor every parent) andgetConsolidatedInto(id).list({ excludeArchived: true })andlistIndex({ excludeArchived: true })filterconsolidated_into IS NULL. Archived rows stay readable byget(id)so the agent can still inspect a lesson's parents via theparent_idsarray returned bymemory.lessons.recall. - lessons/lesson-store.ts is a self-contained store with
create,getById(returns deprecated rows too — used for direct-id lookups from### lessons),recall { query, k, includeDeprecated? }(BM25 againstlessons_fts; defaults to active-only),listIndex { limit, workingDir? }(compact pointer view for the### lessonssection),markDeprecated,bumpSuccess/bumpFailure,pickOverflowForDeprecation, andcountAll. All writes go through the same validators (LESSON_ACTIVATION_MAX_LENGTH,LESSON_PRINCIPLE_MAX_LENGTH,LESSON_MAX_TAGS,LESSON_TAG_MAX_LENGTH) — there is no second back door, identical toProfileStore/MemoryStore.
Prompt surface. The variable tail gained a ### lessons section rendered by lessons-renderer.ts — one *<id> [tags] activation line per lesson, ordered as the recall returned them, capped at memory.lessons.maxTokens (default 400) with a [truncated] marker. Placement is between ### profile and ### memory-index (mutability order in the tail). The renderer never emits the principle body — that requires an explicit memory.lessons.recall { id } call. Token cost is subtracted from the effective conversation cap in token-budget.ts.
Stable-prefix change (KV-cache invalidation #1). stable-prefix.ts's persona text was extended to mention ### lessons and how to materialise the full body via memory.lessons.recall { id }. This invalidates the main agent slot's KV cache for one cold start across the whole runtime — a planned one-time event for phase 5. The reflection slot is untouched (phase 4 already invalidated that one). There is no hot migration path; restart with a fresh session pool. Phase 7b is the second and final planned invalidation (### procedures).
Read-side wiring. memory-context-provider.ts was extended with an optional lessons block. When configured AND a non-empty user message arrives, the provider calls LessonStore.recall(userMessage) for the top-K (default 5) hits and stores them on the ephemeral SessionState.recalledLessons field. stripEphemeral in session-state.ts drops recalledLessons before SQLite persistence — recomputed every turn against the live user message. When lessons is not configured the provider returns the byte-identical pre-phase-5 { recalled, index } shape so older callers stay stable; phase-5 consumers see { recalled, index, lessons }. The ### memory-index rendering also flips to excludeArchived: true so consolidated parents disappear from the pointer surface (but stay readable by id).
Agent tool: memory.lessons.recall { id?, query?, k? } (tools/memory/lessons-recall.ts). Read-only (pure_read resource class — fans out cleanly in parallel batches). { id } returns one full lesson including deprecated rows (so the agent can resolve a stale pointer); { query } does a BM25 search against active lessons only. Output is { id, activation, principle, tags, parentIds }. Validation errors fold into structured status: "error" tool results instead of throwing. The GBNF grammar tool-call.gbnf was extended to accept lessons.recall alongside the existing memory.* tools; the prompt descriptor lives in default-tool-descriptors-b.ts at tier frequent so the full argsSchema is always in the stable prefix.
Consolidator (cold path). consolidator/consolidator-job.ts is the single orchestrator. One tick (runOnce) does:
- Select candidates.
MemoryStore.list({ excludeArchived: true, beforeCreatedAtMs: now - cooldownMs })— rows that have aged beyondmemory.consolidation.cooldownMs(default0; production deployments should bump this so freshly-written notes have time to attractRELATES_TOlinks). - Cluster. consolidator/clustering.ts — undirected BFS over
LinkStore.listOutgoing+listIncomingto find connected components, filtered byminClusterSize(default3). WhenrequireSharedTag=true(defaultfalse), each component is trimmed to the members sharing the single most common tag; components with no shared tag are dropped. The chosen algorithm is "CC + tag-intersection" — a deliberate tradeoff between recall (loose CC) and precision (strict tag intersection); pinned by clustering.test.ts (eight cases including empty input, size floor, external edges, the trimming path, therequireSharedTagdrop path, themaxClusterscap, and bidirectional traversal). - Acquire leases. Per-member
MemoryStore.acquireConsolidationLease(id, leaseMs). If any member is already leased (phase 3 neighbour-evolver or a concurrent tick), the entire cluster is skipped and members released — clusters are atomic units. Lease TTL is hardcoded to60_000ms today; outliving the tick is fine because the next tick will re-acquire. - Distill. consolidator/distill-runner.ts — one LLM call per cluster on the reflection slot (so the main agent slot's KV cache is never disturbed by consolidation). Prompt + GBNF in distill-prompt.ts + distill-grammar.ts — the model must emit either
LESSON activation="..."; principle="..."[; tags=...]or the explicit abstain sentinelLESSON activation="(no consensus)"; principle="(no durable advice)". distill-parser.ts recognises the sentinel askind: "none"; the consolidator counts these inlessonsAbstainedand leaves the parents un-archived (abstain ≠ archive — the cluster can be retried in a future tick when more episodes accumulate). - Persist + archive + rewire. On
kind: "lesson", the consolidator callsLessonStore.create(...)thenMemoryStore.archiveInto(parentIds, lessonId)in that order. Link rewiring is deliberately deferred —memory_linksrows pointing at archived members stay intact (the BFS view still works for postmortem walks); a future "link compaction" pass can collapse them. - Release leases + record metrics. Every member's lease is released even on failure. Per-cluster failures (LLM throw / parse error / timeout) are caught and logged; sibling clusters in the same tick keep running — the tick's outcome is
okif at least one lesson landed,failedonly if every cluster errored,noneotherwise.
Scheduler seam — scoped setInterval carve-out. The consolidator owns its own setInterval with period memory.consolidation.intervalMs (default 300_000 = 5 min). This is the second carve-out from the §"Background autonomy" invariant that "Scheduler is the only periodic timer in the runtime" — the first was Telegram long-polling, the second is the consolidator. The carve-out is deliberate and bounded: the loop is owned by consolidator-job.ts only, every tick is wrapped in a try/catch with a running re-entry guard, and ticks never block — distillation runs on the reflection slot with its own timeout (memory.consolidation.distillTimeoutMs, default 45_000). New cold-path jobs of this shape must not add a third timer without an analogous AGENTS.md review.
Bootstrap wiring. runtime/bootstrap.ts constructs LessonStore unconditionally (it owns a SQLite handle on the shared memory.sqlite file, must be closed by shutdown), wires it into memory.lessons.recall registration (gated by memory.lessons.enabled), and threads it into createDefaultMemoryContextProvider. The ConsolidatorJob is constructed and started only when memory.lessons.enabled && memory.consolidation.enabled; the distill runner shares the reflection slot reserved earlier for link-generation. shutdown() calls consolidatorJob?.stop() before lessonStore.close() so a final tick never touches a closed handle.
Configuration (memory.lessons.* and memory.consolidation.*). User config v15; parseUserConfigFile transparently migrates v14 → v15 filling defaults. Keys:
memory.lessons.enabled(defaulttrue) — master switch. Controls### lessonsrendering,memory.lessons.recallregistration, and the consolidator.memory.lessons.recallK(default5) — top-K for the read-side BM25 surface.memory.lessons.maxTokens(default400) — token cap on the rendered### lessonsblock.memory.lessons.indexLimit(default20) — cap onLessonStore.listIndexrows (today read by the recall path only — phase 6 will surface this as### lessons-indexanalogous to### memory-index).memory.lessons.maxEntries(default200) — hard ceiling on active lesson rows; phase 6 enforces this via deprecation sweep.memory.lessons.deprecationAgeMs(default30 days) — age threshold for the phase-6 deprecation sweep.memory.consolidation.enabled(defaulttrue) — master switch for the cold-path job.memory.consolidation.intervalMs(default300_000= 5 min) — period of the scopedsetInterval.memory.consolidation.cooldownMs(default0) — minimum age of an episode before it is eligible. Bump in production so freshly-written notes have time to attract links.memory.consolidation.minClusterSize(default3) — size floor.memory.consolidation.maxClustersPerTick(default5) — soft cap on clusters processed per tick.memory.consolidation.requireSharedTag(defaultfalse) — whentrue, clusters are trimmed by the majority-tag rule (see clustering above).memory.consolidation.distillTimeoutMs(default45_000) — per-cluster LLM timeout.
Metrics. agent-metrics.ts:
- Counters:
agent.memory.lessons.{created,deprecated,recalled},agent.memory.consolidation.run(tagged byoutcome ∈ {"ok","none","failed"}). - Histograms:
agent.memory.consolidation.distill_latency_ms,agent.memory.consolidation.clusters(per-tick cluster count).
Locked invariants (pinned by consolidator-job.test.ts, clustering.test.ts, distill-parser.test.ts, lesson-store.test.ts, lessons-renderer.test.ts, memory-store-archive.test.ts, build-prompt.test.ts):
### lessonsis pointer-only. The renderer never emitsprinciple. Full bodies require an explicitmemory.lessons.recall { id }call. Pinned bylessons-renderer.test.ts("does not leak principle").- Archived memories remain readable by id.
MemoryStore.get(archivedId)returns the row; onlylist({ excludeArchived: true })/listIndexdrop it. Pinned bymemory-store-archive.test.ts. - One LLM call per cluster, on the reflection slot. The consolidator never touches the main agent slot. Distillation is always serial within a tick (one cluster → one call → next cluster); parallel cross-cluster fan-out is deferred.
- Abstain ≠ archive. When the model emits the
(no consensus)sentinel, the cluster is recorded as abstained, parents stay active, leases are released. Pinned byconsolidator-job.test.ts("treats abstain output as 'none' and does not write a lesson"). - Per-cluster failures isolate. A throw / parse error / timeout on one cluster does not abort the tick — sibling clusters keep running. Pinned by
consolidator-job.test.ts("isolates per-cluster failures and lets a second cluster succeed"). - Lease semantics are shared with phase 3. Phase 3 (neighbour-evolver) reads the lease in
evolveTags; phase 5 is the only acquirer. The B↔C boundary is theconsolidating_atcolumn landed dormant in the v4 migration. Pinned bymemory-store-evolve.test.ts("evolve respects consolidation lease"). - Stable-prefix mention is one-time. Adding the
### lessonsparagraph to the persona is one byte change. Pinned bybuild-prompt.test.ts("stable prefix mentions ### lessons in the persona" + the existing hash-stability test for non-phase-5 mutations). recalledLessonsis ephemeral.stripEphemeralremoves it before persistence; recomputed every turn. Pinned bysession-state.test.ts.- Re-entry guard. Two concurrent
runOncecalls collapse to one — the second returns a zero-summary. Pinned byconsolidator-job.test.ts("re-entry guard returns a zero-summary when another tick is in flight"). - Idempotency across ticks. A second tick with no new candidates is
{ outcome: "none", clustersConsidered: 0, lessonsCreated: 0 }. Pinned byconsolidator-job.test.ts("is idempotent on a second tick"). - Cooldown is enforced server-side. Episodes younger than
memory.consolidation.cooldownMsare filtered at the candidate-selection step, not by the cluster pass. Pinned byconsolidator-job.test.ts("respects cooldownMs"). - Memory-context-provider shape stays backwards-compatible. When the
lessonsblock is not configured, the provider returns{ recalled, index }— nolessonskey. Phase-5 callers see{ recalled, index, lessons }. Pinned bymemory-context-provider.test.ts.
Out of scope (deferred to phase 6). Lesson lifecycle bumps from agent-loop terminal verbs (bumpSuccess / bumpFailure on whether a surfaced lesson actually helped the turn), maxEntries FIFO deprecation sweep inside the consolidator tick, deprecationAgeMs enforcement. Phase 7a adds vote_score columns + ExpeL-style vote curation. Phase 7b adds the second stable-prefix change (### procedures) and the combined lesson-and-procedure grammar — invariant 3 ("one LLM call per cluster") survives that addition.
Memory-v2 phase 6 — lesson lifecycle and deprecation
Phase 6 closes the lesson loop: lessons that demonstrably help turns earn success_count bumps, lessons that fail to do so age out, and the total active set stays bounded by a per-store maxEntries cap. Two new code paths land — one hot (agent-loop terminal-verb hook) and one cold (consolidator sweep) — sharing the existing LessonStore API. No new schema change (v8 already carries success_count, failure_count, status, deprecated_at); the work is purely behavioural.
Hot path — lesson lifecycle hook (agent-loop). agent-loop.ts was extended with an optional lessonLifecycle: LessonLifecycleHook dep. Inside runTurn, a surfacedLessonIds: Set<number> accumulates the union of every state.recalledLessons snapshot the turn sees — once at the top of the turn (after the initial refreshMemoryContext) and once after every successful non-terminal step. At terminal time the hook fires exactly once with the deduplicated id set and a typed outcome:
| Terminal reason | Hook fires? | Outcome |
|---|---|---|
reply | yes | success |
finish | yes | success |
failed (thrown / categorised infra failure) | yes | failure |
cancelled (signal aborted, CancelledError, classified cancelled) | no | — |
max_steps (synthetic reply) | no | — |
Cancelled and max-steps turns are deliberately filtered out because neither carries a clean success/failure signal — cancellation is operator-initiated, and max_steps is ambiguous (the lessons may have been useful right up until the budget ran out). Phase 7a may revisit max_steps as a soft-negative signal once vote scores land. The default hook implementation lives at lesson-lifecycle-hook.ts and routes success → LessonStore.bumpSuccess(id) / failure → LessonStore.bumpFailure(id). The hook is fire-safe (try/catch around each bump, errors logged at WARN); a sqlite hiccup on one id never aborts sibling bumps and never derails the turn-completion path.
Cold path — deprecation sweep (consolidator). consolidator-job.ts's runOnce was extended with a deprecation sweep that fires inside the existing finally block, after distillation and regardless of the tick's clustering outcome — including ticks where no clusters formed. This is load-bearing: most production ticks find no new clusters but still need to keep the active set bounded. The sweep runs in two passes:
- Age sweep —
LessonStore.pickAgeDeprecationCandidates({ now, deprecationAgeMs })returns active rows withsuccess_count = 0ANDcreated_at <= now - deprecationAgeMs. Each is demoted viamarkDeprecated(id, "aged_out"). The "still unused" predicate is the load-bearing filter — a lesson that helped at least one turn (success_count >= 1) survives indefinitely on age alone; only the FIFO pass can demote it. - FIFO sweep —
LessonStore.pickOverflowForDeprecation()returns oldest-by-updated_at, idids whencountActive() > maxEntries. Each is demoted viamarkDeprecated(id, "overflow"). Even successful lessons can be evicted here, which is intentional: the cap is a hard ceiling on the rendered surface, not a popularity filter.
Both passes share a maxDeprecationsPerTick cap (default 100, hardcoded in bootstrap) so a misconfigured deprecationAgeMs cannot flatten the entire lesson set in one tick. Per-lesson failures (sqlite errors) are caught, logged, and skipped — sibling demotions keep flowing. The sweep adds two fields to ConsolidatorTickResult: lessonsDeprecatedByAge and lessonsDeprecatedByOverflow.
Trace event. lesson_deprecated { lessonId, reason } lands in the trace union at trace-event.ts. The consolidator fires it via a new optional onLessonDeprecated callback in ConsolidatorJobDeps; bootstrap bridges the callback to TraceBus.emit(...) using a synthetic consolidator sessionId so cold-path events land in <stateDir>/traces/consolidator.ndjson — a dedicated file separate from per-session turn traces. A local seq counter (closure-captured in bootstrap) keeps the file monotonically ordered; per-session counters are recorder-owned and the consolidator does not run through a recorder. reason is intentionally a free-form string ("aged_out" | "overflow" today; phase 7a adds "downvoted") so future demotion paths slot in without a schema bump.
Metrics. Phase 6 reuses the existing agent.memory.lessons.deprecated counter (already wired in phase 5 via LessonStore.markDeprecated); the new contribution is the reason tag which now carries aged_out | overflow instead of phase 5's unspecified. agent.memory.lessons.recalled continues to fire from memory-context-provider once per turn. No new metric names.
Bootstrap wiring. runtime/bootstrap.ts threads lessonLifecycle: createLessonLifecycleHook({ lessonStore, logger }) into loopDeps when memory.lessons.enabled is true, and passes deprecationAgeMs: config.memory.lessons.deprecationAgeMs + maxDeprecationsPerTick: 100 into the existing ConsolidatorJob constructor. The onLessonDeprecated callback is only wired when traceBus is non-null (sidecar default is null) so non-tracing runtimes see byte-identical behaviour.
Configuration. No new keys. Phase 6 consumes the existing memory.lessons.{enabled, maxEntries, deprecationAgeMs} introduced in phase 5 (config v15).
Locked invariants (pinned by lesson-store.test.ts phase-6 cases, lesson-lifecycle-hook.test.ts, agent-loop-lesson-lifecycle.test.ts, consolidator-job.test.ts phase-6 cases):
- Once per turn per id. Even if the same lesson surfaces across many step refreshes,
bumpSuccess/bumpFailurefires exactly once. Pinned byagent-loop-lesson-lifecycle.test.ts("deduplicates surfaced ids across multiple step refreshes"). - No bump on
cancelledormax_steps. Pinned byagent-loop-lesson-lifecycle.test.ts("does NOT fire the hook when the turn is cancelled"). Themax_stepspath is the natural fall-through inrunTurn— the hook block guardsreason === "reply" || reason === "finish". failed→bumpFailure. Thrown / classified failures route through the early-return failed branch that fires the hook beforereturn. Pinned byagent-loop-lesson-lifecycle.test.ts("fires 'failure' for surfaced lessons when the loop fails").- Empty surfaced set → silent no-op. No hook call when no lessons were surfaced. Pinned by
agent-loop-lesson-lifecycle.test.ts("is a no-op when no lessons surfaced this turn"). - Hook is fire-safe. Bump errors are caught per-id; the agent loop's return path is never disturbed. Pinned by
lesson-lifecycle-hook.test.ts("swallows bump errors and keeps processing siblings"). - Age picker requires
success_count = 0. A lesson withsuccess_count >= 1never falls out ofpickAgeDeprecationCandidates, regardless of age. Pinned bylesson-store.test.ts("pickAgeDeprecationCandidates picks active lessons with success_count==0 older than threshold"). - FIFO order is
updated_at ASC, id ASC. Oldest-by-modification-time is demoted first; ties break by id. Pinned bylesson-store.test.ts("pickOverflowForDeprecation respects maxEntries"). - Sweep runs even on a "none" tick. No clusters formed ≠ skip deprecation. Pinned by
consolidator-job.test.ts("deprecation sweep runs on every tick, including when no clusters form"). maxDeprecationsPerTickis a combined cap across both passes. Age + overflow demotions cannot exceed the cap in a single tick. Pinned byconsolidator-job.test.ts("sweep respects maxDeprecationsPerTick across both passes").- Demoted rows stay readable by id.
LessonStore.getById(id)returns the row regardless ofstatus; onlyrecall/listIndexfilter it out. Pinned byconsolidator-job.test.ts("deprecated lessons are excluded from recall but still readable by id"). Cross-phase invariant 9 from MEMORY_FABRIC_V2.md. lesson_deprecatedfires once per demoted lesson with the right reason. Trace bridge is wired only whentraceBusis non-null. Pinned byconsolidator-job.test.ts("emits onLessonDeprecated for every demoted lesson (age + overflow)").- No schema change. Phase 6 is purely behavioural over v8. The next bump is phase 7a (
vote_scorecolumns).
Out of scope (deferred). Failure-count demotion (failure_count > N → deprecate) — today only success_count = 0 + age and FIFO drive demotion; phase 7a adds vote_score < 0. Soft-negative signal from max_steps and stalled outcomes. Per-workingDir scoping of the FIFO cap (today it is global — a noisy project can squeeze a quiet one's lessons out). Re-promotion (undeprecate) of a deprecated lesson when a future recall would surface it — today deprecation is one-way; the consolidator can mint a fresh lesson from the same cluster but the old row stays demoted forever.
Memory-v2 phase 7a — ExpeL-style vote curation
Phase 7a adds an explicit operator-controlled curation signal across memories, lessons, and profile facts. The agent itself emits up- and down-votes through a new reflection sub-call (vote-runner) constrained by a GBNF grammar; the cold-path ConsolidatorJob decays the resulting scores once per tick and demotes targets that crossed into negative territory; the prompt renderer hides profile facts that fell past a configurable threshold. Vote curation is on by default via memory.voting.enabled (default true, config v21); set false to skip the vote-runner and cold-path decay.
Schema (v8 → v9). Idempotent migration in memory-schema.ts: adds vote_score REAL NOT NULL DEFAULT 0.0 columns to memories, lessons, and profile_facts; creates a new vote_events (id PK, kind, target_id, direction, session_id, turn_index, created_at) audit table with idx_vote_events_created (FIFO eviction) and idx_vote_events_target (postmortem lookups). Indexes idx_memories_vote_score and idx_lessons_vote_score accelerate the per-tick deprecation predicate. No data is rewritten — rows migrated from v8 inherit vote_score = 0.
Configuration (memory.voting.*). User config v15 → v16 (transparent migration; see config-schema.ts):
memory.voting.enabled(defaulttrue, config v21) — master switch. When off, theVoteStoreis not constructed, the reflection chain is not decorated, and the consolidator's vote decay + vote-deprecation passes are skipped.memory.voting.maxVotePerItem(default5) — strictly-positive clamp on|vote_score|. Bootstrap fails fast on≤ 0(scenario 7a.C.3).memory.voting.signalDecay(default0.95) — multiplicative decay factor in(0, 1].1.0is identity (audit-only mode);0disables decay and vote-deprecation but is rejected by validation. Applied once per consolidator tick — never per turn (cross-phase invariant 23).memory.voting.scoreBlend(default0.4) — weight in[0, 1]for the lesson rerank:combinedScore = bm25 + scoreBlend × vote_score + (1 - scoreBlend) × (success - failure).memory.voting.eventLogMaxRows(default2000) — FIFO cap onvote_events.memory.voting.profileFilterThreshold(default2) — strictly-positive threshold; profile facts withvote_score ≤ -profileFilterThresholdare hidden from### profileregardless of pinned/keyword status.0disables the filter.
Hot path — vote-runner reflection sub-call. src/memory/voting/ ships a self-contained sub-call:
- Grammar. vote-grammar.ts — bounded to
NONE,UPVOTE <kind>:<id>,DOWNVOTE <kind>:<id>(kind ∈ {memory, lesson, profile}), capped at 8 entries per call. TheEDITmarker from the original ExpeL spec is deferred — phase 7a ships up/down only. - Prompt. vote-prompt.ts — micro-prompt with the same shape as the reflection prelude. Carries the
userMessage, the assistant reply, and aVoteCandidate[]list (kind + id + truncated preview). Aggressive preview truncation keeps the prompt under budget when many items surfaced. - Parser. vote-parser.ts — strictly rejects votes against ids that are not in the per-turn
VoteAllowlist(cross-phase invariant 18). The allowlist is the union of {recalled memory ids, recalled lesson ids, active profile fact ids}; the parser also deduplicates votes and caps atmaxVotes. Out-of-allowlist votes never reach the store — they are taggedout_of_allowlistand recorded on the rejected counter. - Runner. vote-runner.ts — wraps the LLM call, parses, and applies each accepted vote through
VoteStore.applyVote. Timeouts / abort signals / LLM errors are folded intorunner_outcometags (ok | none | skipped | aborted | timeout | failed) so observability never blocks reflection. - Decorator. vote-aware-reflection.ts — composes on top of
createLinkAwareReflectionRunner(or the base reflection runner when links are off). The vote-runner fires after SET/NOTE/link-gen/EVOLVE so all candidates produced this turn are eligible.
Wired in bootstrap.ts when memory.voting.enabled is on; the decorator inherits the reflection slot (cross-phase invariant 2). agent-loop.ts was extended to thread recalledLessonIds, recalledProfileFactIds, and turnIndex into ReflectionInput so the decorator can hydrate previews without re-querying.
Cold path — vote decay + vote-deprecation sweep. consolidator-job.ts's runOnce was extended (still after distillation, inside the finally block):
- Decay pass.
voteStore.decayAllScores(signalDecay)scales everyvote_scorerow by the configured factor in a single SQL statement per table (cross-phase invariant 17). Once per tick, never per turn (cross-phase invariant 23). Metricagent.memory.voting.decayedfires one sample per kind. Decay runs before the deprecation sweep so the latter sees up-to-date scores. - Vote-deprecation sweep.
LessonStore.pickVoteDeprecationCandidates({ limit })returns active rows withsuccess_count = 0 AND vote_score < 0, ordered byvote_score ASC(most-negative first). Each is demoted viamarkDeprecated(id, "downvoted")and emits alesson_deprecatedtrace event. This pass runs before the existing age + FIFO sweeps so the highest-confidence "this is bad" signals retire ahead of merely-aged candidates whenmaxDeprecationsPerTickbinds.
ConsolidatorTickResult gained two fields: lessonsDeprecatedByVote and voteDecayApplied. The vote sweep is gated by voteSignalDecay > 0; when the master switch is off the sweep degrades to a no-op without touching scores.
Memory eviction — vote priority. memory-store.ts's utility-weighted overflow eviction now orders by vote_score ASC, recall_count ASC, COALESCE(last_recalled_at, 0) ASC, updated_at ASC, id ASC. A downvoted memory row evicts before any neutral row regardless of age or recall count (scorecard 7a.A.3). Legacy FIFO mode (eviction.utilityWeighted = false) is unchanged.
Lesson recall — combined-score rerank. lesson-store.ts's recall accepts an optional scoreBlend (and rerankPoolMultiplier) that switches from pure BM25 ordering to computeLessonCombinedScore(bm25, vote_score, success_count, failure_count). The rerank fetches a wider pool (k × rerankPoolMultiplier) so a less-relevant but heavily-upvoted lesson can surface. scoreBlend = 0 collapses to pure success/failure ranking; scoreBlend = 1 collapses to pure vote ranking. The default 0.4 weights operator votes more than auto-feedback.
Profile rendering — vote filter. profile-renderer.ts accepts profileFilterThreshold and drops every fact with voteScore ≤ -threshold before the contextual-keyword gate runs. A downvoted pinned fact disappears too (operators expect votes to override pinning). threshold = 0 is back-compat (filter disabled).
Trace events. trace-event.ts gained vote_applied { kind, targetId, direction, score, clampHit } and vote_rejected { kind, targetId, direction, reason } event types. Emission rides the per-session TraceRecorder: TraceRecorder.recordVoteApplied(...) / recordVoteRejected(...) own the monotonic seq counter so vote events interleave cleanly with the agent-loop stream even though VoteRunner fires after turn_finished. The bridge is wired in bootstrap.ts via the optional emitTrace callback on VoteRunnerDeps, which resolves the recorder by event.sessionId from the same recorders map the loop uses. Sink failures are swallowed (safeEmit in vote-runner.ts) so a recorder hiccup never derails vote application. clamp_hit writes emit vote_applied { clampHit: true } (not vote_rejected) — the operator's curation intent reached the store, the score just saturated.
Metrics. agent-metrics.ts gained agent.memory.voting.{runner,runner_latency_ms,applied,rejected,decayed}. Runner outcomes (ok | none | skipped | aborted | timeout | failed), apply kinds, rejection reasons, and decay-per-kind are tagged so dashboards can split production noise.
Locked invariants (pinned by vote-store.test.ts, vote-parser.test.ts, vote-runner.test.ts, consolidator-vote.test.ts, lesson-store-rerank.test.ts, memory-store-v2.test.ts, profile-renderer.test.ts, scorecard-7a.test.ts):
- One inference per turn for voting. The vote-runner is a single LLM call piggy-backing on the reflection slot — no recursive loops. Pinned by
vote-runner.test.ts. - Allowlist is load-bearing. A vote against an id that was not surfaced this turn is dropped at parse time, regardless of grammar validity. Cross-phase invariant 18. Pinned by
vote-parser.test.ts("rejects vote against id not in allowlist") andvote-runner.test.ts. - Clamp is symmetric.
|vote_score| ≤ maxVotePerItemenforced on every write; the saturated write returnsclamp_hitwithout touching the audit log. Scenario 7a.C.2 inscorecard-7a.test.ts. - Decay is per-tick, not per-turn. Bulk SQL, one statement per table. Two consecutive ticks decay twice; two consecutive turns within the same tick window decay zero times. Scenario 7a.D in
consolidator-vote.test.ts+scorecard-7a.test.ts. - Vote-deprecation requires
success_count = 0. A lesson that helped at least one turn survives a net-negative vote signal; the operator's last resort islessonStore.markDeprecateddirectly. Cross-phase invariant 19. Pinned byconsolidator-vote.test.ts("survives downvote if the lesson has at least one success bump"). - Vote sweep precedes age sweep. When both passes would fire and
maxDeprecationsPerTickbinds, the downvoted candidate retires first. Pinned byconsolidator-vote.test.ts("vote-sweep runs before age-sweep when both fire on the same tick"). - Profile filter overrides pinning. A pinned fact with
vote_score ≤ -thresholdis hidden from### profile(operators expect downvotes to override pinning). Pinned byprofile-renderer.test.ts("hides a pinned fact too when its vote_score is past the threshold"). - Bootstrap fails fast on invalid clamp/decay.
memory.voting.maxVotePerItem ≤ 0orsignalDecay ∉ (0, 1]rejects at config parse time and at bootstrap. Scenario 7a.C.3 inscorecard-7a.test.ts. - Vote curation is opt-out (config v21). With
memory.voting.enabled = false,VoteStoreis not constructed, reflection is not decorated, the consolidator's vote pass degrades to a no-op (voteDecayApplied = false), and the eviction / rerank / renderer paths are byte-identical to phase 6. - Trace
seqis recorder-owned.vote_applied/vote_rejectedevents flow throughTraceRecorder.recordVoteApplied/recordVoteRejected, never directly throughtraceBus.emit(...). Recorder lookup bysessionIdin the closure; a missing recorder is a normal "tracing disabled" outcome, not an error. Pinned bytrace-recorder.test.ts("vote events share the same seq counter as agent events") andvote-runner.test.ts("emits vote_applied via emitTrace for each successful write" + "swallows emitTrace failures without aborting vote application").
Out of scope (deferred to phase 7b). Vote rendering inside the prompt tail (today votes are observability-only; the agent reads vote_score indirectly through rerank + deprecation but never sees the raw number). EDIT marker for memory mutation (deferred — phase 7a is up/down only). Cross-session vote aggregation (today every vote is per-(session × target); a procedural memory consumed by 10 sessions accrues 10 independent vote signals). Per-workingDir filter thresholds. MemP-style procedure curation rides on top of this layer.
Memory-v2 phase 7b — MemP-style advisory procedure templates
Phase 7b extends the consolidator with a second domain object: procedures — read-only "how-to" templates distilled from the same clusters that already produce lessons. A procedure carries an activation cue, an ordered steps[] array (description + optional toolHint), tags, status, lifecycle counters (successCount, failureCount, useCount), vote_score, and parent pointers into the lesson + memory cluster that birthed it. The agent never auto-executes a procedure — it reads the steps as guidance and either follows them or consciously deviates. This is the load-bearing invariant 20 below.
The phase ships the second (and final) stable-prefix change of the v2 rollout: persona text mentions ### procedures and the new memory.procedures.recall tool. KV-cache for every session is invalidated once on first boot; subsequent boots reuse the new prefix.
Schema. memory-schema.ts bumped to MEMORY_SCHEMA_VERSION = 10. V10_MIGRATION adds the procedures table (id, activation, steps JSON, tags JSON, status, success_count, failure_count, use_count, vote_score, parent_lesson_ids JSON, parent_memory_ids JSON, source, working_dir, created_at, updated_at, deprecated_at), three indexes (idx_procedures_status, idx_procedures_updated_at, idx_procedures_vote_score), and a procedures_fts virtual table with the standard INSERT/DELETE/UPDATE triggers (porter unicode61). The migration is idempotent and runs from any of the four connections opened against <stateDir>/memory.sqlite (the first writer wins; subsequent connections see a no-op).
ProcedureStore. procedure-store.ts mirrors LessonStore shape-for-shape so the bootstrap wiring is symmetric. CRUD: create() (validates activation length, steps count + per-step length, tags, parent id arrays), getById(), recall({ query?, id?, k?, scoreBlend? }), listIndex({ limit }), markDeprecated(id, reason), bumpUse() / bumpSuccess() / bumpFailure(), deprecateByParentLesson(lessonId, reason), pickAgeDeprecationCandidates() / pickVoteDeprecationCandidates() / pickOverflowForDeprecation(). recall is vote-aware: when scoreBlend > 0, BM25 hits are reranked by (1 - blend) * normalised_bm25 + blend * normalised_vote_score, identical formula to LessonStore.recall.
Combined distillation (invariant 21). distill-grammar.ts ships DISTILL_WITH_PROCEDURE_GRAMMAR — root ::= lesson "\n" opt-procedure. The procedure half is optional (zero or one occurrence): conceptual clusters emit just the LESSON line, procedural clusters emit both. Steps are encoded as a single quoted string with ; separators and an optional @toolhint suffix per step; distill-parser.ts re-parses that envelope into ProcedureStep[]. Critically, the consolidator still makes exactly one LLM call per cluster — the procedure is the second slot in the same completion. Pinned by consolidator-job.test.ts ("h.llmCalls === 1") and scorecard-7b.test.ts ("7b.A — exactly one LLM call").
Parser failure isolation: if the LESSON line is malformed, the cluster aborts as before; if the PROCEDURE line is malformed (too few/many steps, oversized step, missing semicolons, malformed @toolhint) the parser silently degrades to procedure: null and the lesson half still persists. Pinned by distill-parser-procedure.test.ts.
Prompt tail. build-prompt.ts renders ### procedures between ### lessons and ### memory-index. Each row is ><id> [tags] activation — pointer-only; the full steps[] body never appears in the prompt by design. Token budget: memory.procedures.maxTokens (default 400) with a [truncated] marker on overflow; the bytes are subtracted from the effective conversation cap by token-budget.ts. The renderer procedures-renderer.ts sorts by vote_score DESC so the highest-confidence items survive the clip first.
Recall tool. memory.procedures.recall { id? | query?, k? } — pure read, resource class pure_read. By id: returns the full body (incl. steps[]) even for deprecated rows so postmortems remain possible. By query: BM25 over activation + steps + tags, active rows only, capped at k (default from memory.procedures.recallK). Implementation in procedures-recall.ts; the response renders steps as 1. <description> [@toolhint] text — explicitly text, never a structured tool-call descriptor, because (invariant 20) the runtime is never allowed to feed those hints into a dispatcher.
Vote curation. VoteStore was extended with the procedure kind (vote-store.ts — fourth readScoreByKind / updateScoreByKind / decayByKind row, TABLE_BY_KIND.procedure = "procedures"). The vote grammar's kind rule now includes procedure; the parser's VoteAllowlist interface gained a procedure: ReadonlySet<number> field; the decorator's hydrator pulls the matching ProcedureStore row when recalledProcedureIds is non-empty. The consolidator's runDeprecationSweep runs a vote → age → FIFO pass for procedures after the analogous lesson sweep, sharing the same maxDeprecationsPerTick budget. Decay is uniform across the four kinds (memory, lesson, profile, procedure); the DecayResult interface gained a fourth procedures field, defaulting to 0 when the schema is pre-v10. Metric agent.memory.voting.decayed{kind=procedure} fires once per tick.
Cascade deprecation. When the lesson sweep demotes a lesson (downvote / aged / overflow), the consolidator immediately calls procedureStore.deprecateByParentLesson(lessonId, "parent_deprecated") to demote every active procedure that lists the dying lesson as a parent. Cascade counts roll up into proceduresDeprecatedByCascade on the tick result and into the procedure_deprecated trace event with reason: "parent_deprecated". Failure-isolated: cascade exceptions never roll back the lesson demotion.
Trace events. trace-event.ts gained TraceProcedureCreated ({ procedureId, parentLessonIds[], parentMemoryIds[], source }) and TraceProcedureDeprecated ({ procedureId, reason }); the VoteApplied / VoteRejected kind unions were widened with procedure. Both procedure events are emitted by ConsolidatorJob via the same traceBus bridge that already carries lesson_deprecated, sharing the monotonic consolidatorSeq counter so the <stateDir>/traces/consolidator.ndjson file stays totally ordered across event kinds within a tick.
Configuration (memory.procedures.*). Added in user config v17 with idempotent v16→v17 migration. Defaults: enabled=true, recallK=3, maxTokens=400, indexLimit=12, maxEntries=200, deprecationAgeMs=14*24*3600*1000 (14 days). Disabling the master switch turns off the prompt section, the consolidator's procedure persist, the recall tool, and the vote allowlist contribution in one move — the store handle stays open for clean shutdown.
Locked invariants (pinned by tests).
- Invariant 20 — runtime never auto-executes
Procedure.steps. ThetoolHintfield is plain text guidance; the agent reads it through thememory.procedures.recalltool and decides what to do. Pinned byscorecard-7b.test.ts("7b.D — no file in src/ feeds Procedure.steps into tool dispatch") via a static grep gate. - Invariant 21 — distillation still emits exactly one LLM call per cluster. Procedure is the second slot in the same completion, not a follow-up call. Pinned by
scorecard-7b.test.ts("7b.A") anddistill-parser-procedure.test.ts. - Stable-prefix change is one-shot. Persona text in stable-prefix.ts mentions
### proceduresandmemory.procedures.recallonce. Mutating it again invalidates KV-cache for every session — never edit the persona block without bumping a planned-deprecation note inMEMORY_FABRIC_V2.md. - Pointer-only prompt rendering.
### procedureslists><id> [tags] activationrows; thesteps[]body is never rendered in the prompt. Drill-down requires an explicitmemory.procedures.recall { id }call. Pinned byprocedures-renderer.test.tsandbuild-prompt.test.ts. - Cascade is best-effort, failure-isolated.
deprecateByParentLessonexceptions never roll back the parent lesson demotion. Pinned byconsolidator-job.test.ts("sweep cascades a downvoted lesson onto its child procedure"). - Bounded growth.
maxEntries(default 200) + per-tick deprecation cap + vote-aware FIFO eviction order (vote_score ASC, updated_at ASC, id ASC) keep the table bounded under all input distributions. Pinned byprocedure-store.test.ts("FIFO overflow"). deprecateByParentLesson(lessonId, reason)always takes a reason string. Internal cascade callers pass"parent_deprecated"; manual ops paths pass"manual". The reason flows into the trace event and the metric tag. Pinned byprocedure-store.test.ts("cascade marks the reason on the trace event").- Vote allowlist is the union of all four kinds.
VoteAllowlist.procedureis a real set (possibly empty); the decorator only adds procedure candidates whenrecalledProcedureIdswas non-empty on theReflectionInput. Pinned byvote-parser.test.tsandvote-runner.test.ts. - Graceful parser degradation. A malformed
PROCEDUREline yieldsprocedure: null— theLESSONhalf still persists. The consolidator recordsproceduresCreated += 0for that cluster without aborting the tick. Pinned bydistill-parser-procedure.test.ts("degrades to null when steps quoting is malformed").
Out of scope (deferred to a future phase, if any). Multi-lesson procedures (today every procedure points at exactly one parent lesson — the consolidator never produces a single procedure from a multi-cluster merge). Per-workingDir procedure scoping (today every procedure is globally visible regardless of where it was distilled). EDIT marker for procedure mutation (procedures are append-only; supersession would require a new entity). useCount auto-bumping from observed tool-chain match against steps[] (today bumpUse() is only callable via direct API — agents never trigger it). Operator UI for procedure inspection in TUI (today inspection is via atomic-agent CLI + memory.sqlite queries).
TUI surface — single screen, paired daemons, opt-in onboarding. The "Models" tab in atomic-agent tui renders both the chat catalog and the embedding catalog on the same screen, separated by a Embedding models (N) · paired with chat daemon header. There is no catalog-toggle hotkey (the earlier e switcher was removed in favour of a single combined cursor). Indices [0..rows.length-1] are chat rows; [rows.length..rows.length+embeddingRows.length-1] are embedding rows; the shared helper resolveRowAt(panel, idx) is the single source of truth for "which row is under the cursor" — both the panel component and the key-binding layer go through it.
Hotkeys are row-type-aware (resolved per-keystroke against the cursor's LocalModelsRowRef.kind):
- j / k / ↑ / ↓ — move the shared cursor across the combined list.
- Enter on a chat row — pull (GGUF + mmproj for vision rows), or set active if already downloaded.
- Enter on an embedding row — pull the GGUF (auto-flips
localModels.embeddings.{enabled, modelId}to the row), or set active if downloaded. - g — chat-only GGUF pull (no-op on embedding rows; embedding models are text-only).
- i — chat-only info detail (no-op on embedding rows).
- d on a chat row — chat-scoped red-bordered remove-confirm modal (
removeConfirmId). - d on an embedding row — embedding-scoped remove-confirm modal (
embeddingRemoveConfirmId). The two modals are mutually exclusive at the type level so a strayyin the chat modal can never delete an embedding GGUF, and vice versa. - E (shift+e) — toggles
localModels.embeddings.enabledwithout restarting any daemon. Operator chains an explicitsto apply. - s — drives
startChatAndEmbeddingDaemons/stopChatAndEmbeddingDaemons(paired start and stop — see §"Daemon pairing" below). - B / r / L — backend pull, refresh, jump to LLM Logs.
Embedding-model onboarding modal. When a chat-model pull finishes successfully AND no embedding model is currently configured AND no embedding GGUF exists on disk, the orchestrator defers the daemon start and emits local_models_embedding_onboarding_opened with the default embedding model (DEFAULT_EMBEDDING_MODEL_ID, nomic-embed-text-v1.5 today). The panel renders an accent-bordered yes/no modal:
- y —
orchestrator.resolveEmbeddingOnboarding(true)pulls the default embedding model (flippingembeddings.enabled = true) and then starts the paired daemon. - n / Esc —
orchestrator.resolveEmbeddingOnboarding(false)skips the pull and starts only the chat daemon.
Either branch closes the modal and routes the operator back to chat (ui_mode_set: chat) once the start succeeds. The modal is a one-shot detour — it never re-appears after a chat-model pull if an embedding model is already on disk, even if embeddings.enabled === false (the operator's explicit "later" choice is respected). Pinned by src/tui/local-models/local-models-reducer.test.ts ("opens and dismisses the embedding onboarding modal") and src/tui/local-models/local-models-key-bindings.test.ts (y / n resolution).
Daemon pairing on TUI start/stop. Every TUI code path that touches the daemon flows through LocalModelsOrchestrator.{startDaemon, stopDaemon, stopDaemonSilent} and they are wired to the paired entry points:
startDaemon→startChatAndEmbeddingDaemons({ chat, embedding? })— chat is fatal-on-failure, embedding is fatal-on-buildEmbeddingStartOptions=undefined-only-then-skip (graceful degradation contract). The skip conditions are:embeddings.enabled=false,embeddings.modelId=null, an unknown id, or the model GGUF is not on disk.stopDaemon/stopDaemonSilent(shutdown) →stopChatAndEmbeddingDaemons(dataDir)— always tries to kill both pid files.stopDaemonSilentis invoked fromLocalModelsOrchestrator.shutdown()so closing the TUI tears down both daemons together.autoStartIfReady(called once at TUI launch) reusesstartDaemon, so a fresh TUI boot starts both daemons whenever an embedding model is configured + downloaded.
Pairing reconciliation (ensureEmbeddingPaired). Beyond the symmetric start/stop path, every TUI mutation that touches embedding-side config or model files reconciles the embedding daemon to the pairing invariant — if the chat daemon is alive, the embedding daemon must be alive iff embeddings.enabled && modelId is a known catalog entry && GGUF is on disk. The helper LocalModelsOrchestrator.ensureEmbeddingPaired({ hotSwap? }) is the single seam; it is called from:
pullEmbeddingModelafter a successful GGUF download (hotSwap: true— the new model replaces whatever the daemon was last serving, including the same-name reload edge case).setActiveEmbeddingwhen the chosen model is already on disk (hotSwap: true— model id changed, possibly with a different dim).toggleEmbeddingEnabledfor both directions (nohotSwap—enabled=truestarts when nothing is running,enabled=falsestops when running).autoStartIfReadyafter adopting an externally-started chat daemon (nohotSwap— only fills in a missing embedding side, never preempts a healthy one).
When the chat daemon is not running, ensureEmbeddingPaired is a no-op: the next paired startDaemon call (or autoStartIfReady on a cold TUI) handles both sides atomically via startChatAndEmbeddingDaemons, which is the cheaper path. The helper uses startEmbeddingDaemon / stopEmbeddingDaemon (single-side primitives) rather than the paired orchestrator so chat is never inadvertently bounced mid-conversation. All failures degrade gracefully — the embedding side reports the error to the runtime feed and falls back to FTS5-only recall; chat keeps running. Pinned by src/tui/local-models/local-models-orchestrator-pairing.test.ts (five cases: start-when-paired, no-op-when-chat-down, hot-swap, master-switch-off, adoption-pairs).
The "LLM Logs" tab tails llama-embed.log next to the chat log in the same viewport (separator: ── llama-embed.log ──) when the file exists — a missing embedding log is silently skipped so chat-only operators see no difference. LocalModelsOrchestrator is the only TUI module that touches getEmbeddingDaemonStatus / startChatAndEmbeddingDaemons / pullEmbeddingModel / resolveEmbeddingOnboarding — the orchestrator-as-seam invariant from the chat side extends verbatim.
The three channels share one SQLite file <stateDir>/memory.sqlite (separate from sessions.sqlite):
| Channel | Storage table | Read path (auto) | Write path |
|---|---|---|---|
ProfileStore | profile_facts | ### profile (gated) | memory.profile.set + reflection |
MemoryStore | memories + FTS5 | ### recalled + ### memory-index | memory.notes.store + reflection |
| Reflection | n/a — it writes to both | n/a | end-of-turn fire-and-forget LLM call |
MEMORY_SCHEMA_VERSION = 3; idempotent migrations in src/memory/memory-schema.ts.
ProfileStore (durable facts, in the prompt tail)
- Shape.
profile_facts (key TEXT PK, value TEXT, pinned INTEGER, keywords TEXT, updated_at INTEGER). CRUD in src/memory/profile-store.ts. - Pinned vs contextual.
pinned=true(default) facts are always rendered;pinned=falsefacts are rendered only when at least one of theirkeywordshits the currentuserMessage(case-insensitive substring match). Filter applied by src/memory/profile-renderer.ts, gated bymemory.profile.contextualKeywordGate(defaulttrue). - Prompt placement. Rendered as
### profilein the variable tail (after optional### loaded-skills, before### memory-index/### session-facts/### recalled). Never the stable prefix.build-prompt.test.tspins the invariant by hashing the stable prefix across profile edits. - Budgeting.
truncateToTokens(content, memory.profile.maxTokens)(default512) with[truncated]marker; tokens subtracted from the effective conversation cap in src/prompt/token-budget.ts. - Live snapshot.
AgentLoopreadsprofileStore.list()once per step via the optionalprofileFactsProviderand threads it intoStepContext.profileFacts→buildPrompt. - Tools.
memory.profile.set { key, value, pinned?, keywords? },memory.profile.remove { key },memory.profile.list {}.
MemoryStore (FTS5 freeform notes)
- Shape.
memories (id INTEGER PK, content, tags, source, scope, working_dir, created_at, updated_at)+memories_ftsvirtual table (porter unicode61). CRUD in src/memory/memory-store.ts. Hard capmemory.notes.maxEntries(default1000); FIFO eviction by(updated_at ASC, id ASC)on overflow. - Auto-injection. Two new tail sections (rendered by src/memory/notes-renderer.ts):
### recalled— top-K BM25 hits against the currentuserMessage. Driven bymemory.recallInjection.{enabled, k, previewChars, maxTokens}(defaultsk=3,previewChars=160,maxTokens=400).### memory-index— compact#id [tags] previewpointer rows. Driven bymemory.index.{enabled, limit, previewChars, maxTokens}(defaultslimit=20,previewChars=60,maxTokens=300).- The two sections are deduplicated by id — anything in
### recalledis filtered out of### memory-index.
- Pre-fetch. Done once per turn by src/memory/memory-context-provider.ts, invoked from
agent-loop.runTurnbefore the per-step loop starts. Results land in ephemeralSessionState.recalledNotes/SessionState.memoryIndex.stripEphemeralin src/session/session-store.ts removes them before snapshot persistence — they are recomputed every turn. - Tools.
memory.notes.store { content, tags?, scope?, workingDir? },memory.notes.recall { query? | id?, scope?, workingDir?, k? }({ id }is direct lookup for#42pointers from### memory-index),memory.notes.forget { id }. The bulk corpus is never dumped wholesale into the prompt.
Reflection (async end-of-turn memory formation)
- When. Fired at the end of every
AgentLoop.runTurnafterassistant_replyis emitted. Fire-and-forget, never awaited.abortPending({ sessionId: state.id })runs at the start of the nextrunTurnso at most one reflection is in flight per session; reflections on other sessions are never aborted as a side effect (load-bearing for cross-session parallelism — see §"Concurrency contract"). - What. A micro-prompt with its own small stable prefix asks the model to extract durable facts from the last
USER/ASSISTANTexchange. Output is GBNF-constrained to eitherNONEor a bounded list of two flavours:SET key=value(pinned fact) orSET key=value [pinned=false; keywords=a,b,c](contextual fact). Caps atmemory.reflection.maxFactsPerCall(default3).NOTE freeform observation [tag1, tag2]→ intoMemoryStorewith implicitreflectiontag. Master switchmemory.reflection.autoStoreNotes(defaulttrue); cap atmemory.reflection.maxNotesPerCall(default2, set to0to disable).
- KV-cache invariant. Reflection runs on a dedicated llama-server slot reserved at bootstrap via
slotManager.reserveReflectionSlot(). The main agent slot is never touched. When only one slot is available, reflection falls back toslotId: -1(no cache reuse). - Writes. Parsed entries flow through the same validators as the explicit tools (
ProfileStore.set,MemoryStore.store); invalid entries are logged and skipped without failing the whole call. - Observability.
agent.memory.reflectioncounter tagged byoutcome(ok | none | failed | aborted | timeout) plusagent.memory.reflection.latency_mshistogram. Logs:reflection.fired,reflection.ok,reflection.none,reflection.aborted,reflection.timeout,reflection.failed. - Code. src/memory/reflection/ —
reflection-prompt,reflection-grammar,reflection-parser,reflection-runner.
Configuration
All keys under memory.* in the user config and src/config/config-schema.ts. Full table in MEMORY.md §8. The most relevant for tuning:
memory.profile.{enabled, maxTokens, contextualKeywordGate}memory.reflection.{enabled, timeoutMs, maxFactsPerCall, autoStoreNotes, maxNotesPerCall}memory.notes.{enabled, maxEntries, maxContentChars, recallDefaultK}memory.recallInjection.{enabled, k, previewChars, maxTokens}memory.index.{enabled, limit, previewChars, maxTokens}paths.memoryDbFile— resolved to<stateDir>/memory.sqlite.
Invariants
- Stable prefix is untouched by memory writes. All three memory-aware sections (
### profile,### recalled,### memory-index) live strictly in the variable tail. Pinned bybuild-prompt.test.ts. - Reflection never blocks or crashes the loop.
ReflectionRunner.reflect()is fire-safe — errors are logged and counted; the agent-visible reply is already returned before reflection starts. Pinned byslot-manager.test.ts(slot isolation) andreflection-runner.test.ts(error swallowing). - Notes corpus is never dumped wholesale. Only top-K (
### recalled) and pointer-only (### memory-index) rows go into the prompt; full bodies require an explicitmemory.notes.recall { id }. - Bounded growth. Per-call write caps (
maxFactsPerCall/maxNotesPerCall) + storage cap (maxEntries+ FIFO) + tail caps (maxTokensper section) + contextual gating for profile facts ⇒ both the SQLite file and the rendered tail are bounded under all input distributions. - Single validator path per writer. All
ProfileStorewrites (tool or reflection) go throughProfileStore.setvalidators; allMemoryStorewrites go throughMemoryStore.storevalidators. There is no second back door. - Ephemeral session fields are not persisted.
SessionState.recalledNotes/memoryIndexare stripped bystripEphemeralbeforeSessionStorewrites the snapshot — they are recomputed each turn against the current user message.
Explicit out-of-scope
Episodic summaries, topic/expires_at columns, embeddings / semantic search, importance scoring, content-based deduplication of notes, and secret redaction are deliberately deferred. See MEMORY.md §10 for the known-limitations list.
Memory v2.5 — phase A heuristic-gated query rewriter (opt-in)
A new module src/memory/retrieve/ adds an LLM-based query rewriter that runs before MemoryStore.recallHybridAsync whenever the current user message looks referential (short, pronoun-laden, conjunction-starter). The rewriter expands "did they mention it?" into a self-contained query using the trailing 2-3 conversation turns; non-referential messages bypass the rewriter entirely and use the raw query. The whole layer is wrapped as a decorator around createDefaultMemoryContextProvider so the byte-output is identical to v2 when the flag is off.
Rewriter gate (gateMode). Config v20 adds a pluggable RewriterGate (rewriter-gate.ts) selected by memory.retrieve.rewriter.gateMode:
| Mode | Implementation | When to use |
|---|---|---|
heuristic (product default) | isReferentialMessage word lists | Low latency, no embedding daemon required |
embedding | Cosine vs curated EN exemplars via EmbeddingClient | Multilingual / long self-contained questions (LoCoMo Temporal) |
always | Always fire when history is non-empty | Debug / eval only |
Heuristic gate. isReferentialMessage(msg, historyLen) in referential-detector.ts is a pure function — no I/O, no state. 16 whitespace-tokenized languages: en, ru, es, de, fr, pt, it, nl, pl, tr, ar, he, hi, vi, id, ko. Returns true when any of:
- Length ≤ 5 words AND no leading question word from the multilingual allowlist. A short message asking "what is X?" is not referential because it carries its own anchor.
- Contains any pronoun from the allowlist (
it,they,eso,das,ça,то, …). - Starts with a conjunction (
and,but,y,und,et,и,но, …).
CJK / Thai deferred on heuristic. Languages without inter-word spaces (zh, ja, th) do not tokenize under split(/\s+/) — use gateMode: "embedding" instead (Intl.Segmenter heuristic support is a follow-up).
Embedding gate. embedding-gate.ts lazy-warms unit-normalized vectors for DEFAULT_REWRITER_EXEMPLARS (or embeddingGate.exemplars), embeds the user message, and fires when max(cosine) >= embeddingGate.threshold (default 0.65). Fail-safe B: any warm or per-call EmbeddingUnavailableError → skip rewrite + embedding_gate.unavailable log (same outcome as skipped_not_referential). Requires the phase-1B embedding daemon; bootstrap falls back to heuristic + warns when gateMode=embedding but no embeddingClient.
When the gate returns false, the recall layer is byte-identical to v2: no LLM call, no rewriter prompt, no rewriter slot reservation. When it returns true, the rewriter runner fires (see below).
Runner. query-rewriter-runner.ts orchestrates a single LLM call:
- Prompt (query-rewriter-prompt.ts) — small stable prefix + variable tail with the last K turns and the current ambiguous message. The model is told to emit a single
<rewritten_query>...</rewritten_query>envelope or the literal tokenNONEwhen no rewrite is possible. - Grammar (query-rewriter-grammar.ts) —
root ::= "<rewritten_query>" body "</rewritten_query>"withbody ::= [^<]{1,400}, plus aNONEalternative for explicit abstain. - Parser (query-rewriter-parser.ts) — length-clamps the body, returns
nullon theNONEtoken, fails closed on malformed input (caller falls back to raw query). - Slot.
slotId: -1always — see invariant 1 below. - Timeout. Hard cap
memory.retrieve.rewriter.timeoutMs(default 3000ms). On timeout/abort/parse-failure, the runner returns the raw user message and the recall layer continues.
Decorator rewriter-aware-recall-provider.ts wraps an inner MemoryContextProvider. It intercepts buildMemoryContext({ userMessage, recentTurns, ... }), fires the rewriter when both (a) the gate matches and (b) recentTurns.length > 0, then forwards a (possibly) rewritten userMessage to the inner provider. Everything else (### memory-index, lesson recall, profile rendering) is untouched.
MemoryContextProviderInput.recentTurns. The decorator needs trailing user/assistant context, but MemoryContextProviderInput did not carry it pre-v2.5. The interface was extended with an optional recentTurns: readonly { role: "user" | "assistant"; text: string }[] — populated by agent-loop.refreshMemoryContext via the new helper collectRecentUserAssistantTurns(state, options.userMessage). Older providers that never read the field stay byte-stable; the default provider ignores it.
Locked invariants (pinned by referential-detector.test.ts, query-rewriter-parser.test.ts, query-rewriter-runner.test.ts, rewriter-aware-recall-provider.test.ts):
- Rewriter always uses
slotId: -1. The main agent slot's KV cache is never touched by the rewriter call; the reflection slot is also untouched (the rewriter is a recall-side concern, not a reflection-side concern). Pinned byquery-rewriter-runner.test.ts("uses slotId -1 to keep the main agent and reflection slots untouched"). - Fire-safe. Any rewriter failure (timeout / abort / malformed completion / parser error) folds to "use the raw user message". The recall never blocks and never raises — pinned by
query-rewriter-runner.test.ts(multiple cases) and theoutcometaxonomy onagent.memory.retrieve.rewriter. - Disabled by default. With
memory.retrieve.rewriter.enabled = false, the bootstrap does not construct a rewriter runner; the innerMemoryContextProvideris returned as-is. The recall path is byte-identical to v2. - Heuristic gate is pure. No I/O, no state — easy to assert across a matrix of inputs. Pinned by
referential-detector.test.ts. - Empty history is a hard skip. Even when the gate fires, the rewriter is not called if
recentTurnsis empty (nothing to anchor against) — outcomeskipped_no_history, raw query is used. Pinned byrewriter-aware-recall-provider.test.ts.
Configuration. Added in user config v18; gate modes in v20 — older files transparently migrate with the block disabled / gateMode: heuristic.
memory.retrieve.rewriter.enabled(defaulttrue, config v21).memory.retrieve.rewriter.timeoutMs(default3000).memory.retrieve.rewriter.historyTurns(default3).memory.retrieve.rewriter.gateMode(default"heuristic"). Evalonprofile in eval-memory/harness/memory-profiles.ts defaults to"embedding".memory.retrieve.rewriter.embeddingGate.threshold(default0.65).memory.retrieve.rewriter.embeddingGate.exemplars(defaultnull→ built-in EN list).
Metrics. agent-metrics.ts:
- Counter
agent.memory.retrieve.rewritertagged byoutcome ∈ {ok, skipped_not_referential, skipped_no_history, aborted, timeout, failed}. - Histogram
agent.memory.retrieve.rewriter.duration_ms. - Counter
agent.memory.retrieve.rewriter.gate.fired(taggedgate_mode). - Counter
agent.memory.retrieve.rewriter.gate.skipped(taggedgate_mode+reason ∈ {below_threshold, no_history, unavailable, empty_message}). - Histogram
agent.memory.retrieve.rewriter.gate.cosine_score(embedding mode only).
Memory v2.5 — phase B sliding-window reflection segmentation (opt-in)
Instead of firing reflection after every turn (legacy behaviour), accumulate the last N user/assistant pairs and fire reflection over the whole window every K turns. Both K and W are config; the legacy per-pair micro-reflection comes back when the flag is off.
Trigger logic in src/agent/agent-loop.ts (runTurn reflection block):
- Segmentation disabled (default). Fire on
reason === "reply"only when a user message arrived this turn. Single-pair prompt — byte-identical to v2. - Segmentation enabled.
- On
reply: fire iffstate.turnCount > 0 && state.turnCount % triggerEveryTurns === 0. Skip otherwise. - On
finish: always fire (final flush — see invariant 1 below). Skip silently when no user/assistant pair exists in the session yet. - On every fire: pack the last
windowTurnsuser/assistant pairs intoReflectionInput.transcriptviacollectLastUserAssistantPairs(state, windowTurns). The trailing pair'suser/assistantcontent is mirrored into the existinguserMessage/assistantReplyfields so the runner contract stays satisfied for downstream sub-calls (link-generator, vote-runner) that still read those scalar fields for the per-turn anchor.
- On
Pair projection. collectLastUserAssistantPairs walks state.turns forward, pairing each user row with the next assistant_reply row. Intervening tool calls / results are ignored — the reflection prompt only consumes the human/agent text. Orphan trailing user rows (no reply yet) are dropped so every entry in the window is complete.
Prompt rendering. src/memory/reflection/reflection-prompt.ts buildReflectionPrompt accepts the new optional transcript field. When present and non-empty, the tail renders:
### turn 1
USER: ...
ASSISTANT: ...
### turn 2
USER: ...
ASSISTANT: ...
### output
When transcript is omitted (or empty), the legacy single-pair tail (USER: ...\nASSISTANT: ...) is byte-identical to v2. The stable prefix (REFLECTION_STABLE_PREFIX / REFLECTION_STABLE_PREFIX_TYPED) is unchanged — segmentation lives entirely in the variable tail.
Locked invariants (pinned by agent-loop-segmentation.test.ts, reflection-prompt.test.ts phase-B cases):
- Final flush on
finish. Whenreason === "finish"and segmentation is enabled, reflection fires unconditionally so the trailing partial cadence window is never lost. Pinned byagent-loop-segmentation.test.ts > "'finish' always triggers the final flush even mid-cadence". - Disabled by default. With
memory.reflection.segmentation.enabled = false, the loop fires reflection once perreason: "reply"with a single-pair prompt and notranscriptfield onReflectionInput— byte-stable with v2. Pinned byagent-loop-segmentation.test.ts > "disabled (default): fires reflection on every reply without a transcript". - No new periodic timer. Trigger is a per-turn check inside
agent-loop.runTurn— not asetInterval. Respects the §"Background autonomy" carve-out invariant; theSchedulerand the consolidator's scopedsetIntervalremain the only periodic timers in the runtime. - Cross-session parallelism untouched. The cadence counter is
state.turnCounton the per-sessionSessionState; a fire on session A never influences session B's cadence. Pinned byagent-loop-segmentation.test.ts > "cross-session reflections stay scoped to their own sessionId". - Stable-prefix bytes are not affected. The transcript window lives in the tail; both
REFLECTION_STABLE_PREFIXandREFLECTION_STABLE_PREFIX_TYPEDare byte-identical between a single-pair and a windowed call. Pinned byreflection-prompt.test.ts > "phase B: keeps the stable prefix byte-identical when transcript is present". - Pair completeness.
collectLastUserAssistantPairsonly emits complete{ user, assistant }pairs — orphan trailing user rows are dropped. The transcript window is always chronological (oldest first). finishon an empty session is a silent no-op. No runtime error, no crash, no reflection fired (no pair to extract from). Pinned byagent-loop-segmentation.test.ts > "a 'finish' on an empty session skips reflection".
Configuration. Added in user config v18 — older files transparently migrate with the block disabled.
memory.reflection.segmentation.enabled(defaultfalse).memory.reflection.segmentation.triggerEveryTurns(default3).memory.reflection.segmentation.windowTurns(default5).
Out of scope. No LLM-driven topic/judger segmentation on reflection — the counter-based gate plus final-flush invariant is sufficient for v2.5. No persistence of pending-window state beyond SessionState.turns[] (already there). No cross-session cadence aggregation.
Memory v2.5 — phase C typed NOTE extraction (opt-in)
Force the reflection LLM to tag every NOTE with one of four semantic buckets (event / behavior / knowledge / skill) using a [type=X] marker immediately after NOTE . The marker is parsed into a synthetic type:X tag on the stored MemoryEntry.tags JSON array — no schema change, no migration, no new entity. FTS5 indexes the tag for free; phase 2 link-graph, phase 5 lessons, phase 7a votes all keep working unchanged.
Grammar. src/memory/reflection/reflection-grammar.ts was extended:
note ::= "NOTE " typemark? body "\n"
typemark ::= "[type=" notetype "] "
notetype ::= "event" | "behavior" | "knowledge" | "skill"
typemark is optional so legacy untyped completions emitted by older prompts still validate cleanly — the grammar accepts both shapes; the prompt is what makes the typed shape canonical when the flag is on. The marker requires a trailing space (mirrors the rule body) so a malformed [type=event]body line is treated as untyped — the body is preserved, no synthetic tag emitted.
Parser. src/memory/reflection/reflection-parser.ts recognises [type=X] on NOTE lines and projects X into a synthetic tag type:event / type:behavior / type:knowledge / type:skill (allowlist NOTE_TYPE_ALLOWLIST). Unknown types are dropped silently — the body still parses but no synthetic tag is emitted (fail-open on tag, never lose the observation). The synthetic tag is prepended to the parsed [tags=...] list so consumers can sort/filter by type:* prefix; cap is the shared NOTE_MAX_TAGS = 10.
Prompt. Two stable prefixes coexist:
REFLECTION_STABLE_PREFIX— legacy v2 untyped prompt (untouched).REFLECTION_STABLE_PREFIX_TYPED— typed prompt with per-type guidance and forbidden lists.
buildReflectionPrompt({ typedNotes: true }) picks the typed prefix; otherwise the legacy prefix. The two prefixes are byte-stable module-level constants so each owns its own KV cache slot on the reflection daemon — flipping the flag invalidates the reflection slot's KV cache once on the next call; the main agent slot is unaffected (the agent prompt never carries either reflection prefix). This is the same one-shot pattern phase 4 introduced for the bi-temporal prompt extension.
Per-type contracts:
[type=event]— a specific happening at a particular time, with participants/location when known. Never a recurring routine.[type=behavior]— a recurring pattern, routine, or established solution. Never a single one-off event.[type=knowledge]— static factual content the user knows or works with (concepts, definitions, domain lore). Never events or behaviors.[type=skill]— a replicable how-to with concrete tools, steps, and outcomes. Never trivial actions ("used Docker") or pure opinions.
Locked invariants (pinned by reflection-grammar.test.ts, reflection-parser.test.ts phase-C cases, reflection-prompt.test.ts phase-C cases):
- No schema migration.
typelives as a string tag on the existingmemories.tagsJSON column. FTS5 indexes it for free. - Backward compatible. Legacy untyped NOTEs from older sessions still load and recall correctly —
type:*tag is just absent. Pinned byreflection-parser.test.ts > "phase C: parses a legacy untyped NOTE line without a type tag". - Unknown types fail open. A
[type=foo] bodyline drops the unknown marker silently and stores the body without a synthetic tag — never loses the observation. Pinned byreflection-parser.test.ts > "phase C: drops an unknown type marker". - One-time KV cache invalidation on the reflection slot. Flipping
memory.reflection.typedNotes.enabledswaps the stable prefix between two module-level constants; each is byte-stable across calls. The main agent slot is untouched (the reflection prefix is never rendered into the agent prompt). Pinned byreflection-prompt.test.ts > "phase C: typed prefix is byte-stable across calls (KV-cache hygiene)". - Phase B composes cleanly. When both
typedNotesandtranscriptare passed tobuildReflectionPrompt, the typed prefix is used and the tail renders as numbered### turn Nblocks. Pinned byreflection-prompt.test.ts > "phase B: composes with typedNotes=true". - Existing v2 tests stay green. All phase 1B / 2 / 3 / 5 / 7a / 7b tests continue passing because no entity shape changed —
type:*is just another tag.
Configuration. Added in user config v18 (bundled with phases A and B in a single version bump for atomic migration).
memory.reflection.typedNotes.enabled(defaultfalse).
Out of scope. No content column on MemoryEntry to carry semantic type structure (the marker is in tags only). No automatic re-typing of legacy untyped rows. No agent-facing memory.notes.recall { type: "event" } shorthand (use query: "type:event" against FTS5 instead — same effect).
Concurrency contract
Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and webhook ingress — funnels through one primitive: TurnController in src/runtime/turn-controller.ts. It is the only path into AgentLoop.runTurn. The defunct src/http/turn-hub.ts is gone; its global serialization invariants are now enforced per session.
Invariants (locked, pinned by src/runtime/turn-controller.test.ts)
- Per-session FIFO. At most one
runTurnis in flight persessionId. Two concurrentenqueuecalls on the same session run strictly in submission order. - Cross-session parallelism. Different
sessionIds run concurrently — there is no global queue. This is the property Option 4 (cron / wakeups) was designed to consume. - No preemption, no priorities. Scheduler-origin submissions queue behind in-flight user submissions on the same session, and vice versa. All
TurnOrigins (cli | tui | http | sidecar | scheduler) are equal citizens. - Per-session event hook.
submission.eventHookis installed beforerun()starts and cleared infinally. A hook on session A never sees events from session B. Routing is keyed bysessionIdstored in anAsyncLocalStorageset inbootstrap.tsaround everyexecuteTurncall. - Per-session recorder.
currentRecorderis no longer a global pointer; recorders are lazy-created persessionIdand dispatched via the sameAsyncLocalStorage. - Aborted submission rejects fast.
submission.signalraces the queue wait — a cancelled submission rejects without ever callingrun().
Ownership of shared resources
| Resource | Owner | Safe under cross-session parallelism? |
|---|---|---|
PlaywrightBackend | The active turn on each session | Yes per-session — TurnController guarantees one turn touches the browser at a time within a session. Cross-session sharing is an accepted product constraint: there is one browser profile per process, so concurrent sessions see the same window. Cron-driven sessions must account for this. |
SlotManager | Each runTurn (acquire-per-step) | Yes — acquire for a single session is sequential by TurnController invariant; different sessions have separate slot assignments and the round-robin pointer is integer-mutating. See doc-comment in src/llm/slot-manager.ts. |
ApprovalGate | Per-session pending request | Yes — at most one pending approval per session by design. |
ProfileStore / MemoryStore / SessionStore | Anything holding a handle | Yes — all three use better-sqlite3, which is synchronous: there is no race window between read and write inside a single statement, so concurrent sessions are safe. This is a load-bearing assumption. Replacing the driver with an async one would require a redesign. |
ReflectionRunner.pending | Per-session Map<sessionId, AbortController> | Yes — reflection on session A is never aborted by reflection on session B. agent-loop.runTurn calls reflectionRunner.abortPending({ sessionId: state.id }) at the start of every turn so a stale reflection from the previous same-session turn cannot race the next one. abortPending() with no argument cancels every in-flight reflection (used at runtime shutdown). |
| Trace recorder | Per-session, dispatched via AsyncLocalStorage | Yes — no global pointer to mix traces across sessions. |
SteeringInbox | Per-session Map<sessionId, string[]>, drained only by the turn running on that session | Yes — a steer on session A is invisible to session B, and only one turn per session can drain (TurnController invariant 1). |
What the scheduler / webhook paths may and may not assume
- May enqueue any session via
runtime.turnController.enqueue({ origin: "scheduler", … })orruntime.runTurn(session, msg, { origin: "scheduler" }). - May introspect via
turnController.isBusy(sessionId)/busySessionIds()and decide between "enqueue and wait" or "skip this tick". - May not preempt user turns or claim a separate priority queue — there is none.
- May not assume exclusive browser ownership across sessions; the browser is shared at process scope (see table).
- Must not hold a stale
SessionStatereference betweenenqueueandrun.executeTurnwrites its result tosessionStore; the correct pattern is to re-read the latest session inside the queued callback (see src/sidecar/main.tssend_messagefor the canonical example).
Mid-turn steering
Per-session FIFO is correct for starting turns and wrong for correcting one. An operator who watches the agent head the wrong way should not have to abort the turn or wait it out to say "no, do X instead". SteeringInbox (src/runtime/steering-inbox.ts) is the out-of-band channel for that, and it is deliberately not a second queue:
- It never starts a turn.
runtime.steer(sessionId, text)returnsfalse, and queues nothing, unless a running turn can still pick the message up. Afalsereturn means "not steered" — the caller falls back torunTurnor to its own pending-message queue. There is still exactly one path intoAgentLoop.runTurn. - Acceptance is one fact, not two. The inbox itself owns the window:
AgentLoop.runTurncallsopen(sessionId)on entry andcloseAndDrain(sessionId)on the way out, andpushrefuses whenever the window is shut.steer()does not consultturnController.isBusy—isBusystops being true at a different moment than "a drain is still coming" (the loop's final drain happens insiderunTurn,busy.deletelater in the controller's ownfinally), and a check-then-act across those two facts loses the message in between: accepted, never delivered, and resurfacing at step 0 of some later turn under a "while you were working" notice about a turn that had already ended. Because the same call closes the window and takes what is pending, there is no window at all: a message is either delivered at a step boundary, returned onundelivered, or refused outright. - It lands at a step boundary.
AgentLoop.runTurndrains the inbox at the top of every step, before building that step's prompt. Effect is visible one step later at the earliest — never mid-inference, never mid-tool-call. A turn parked in a longos.shell.runwill not react until that call returns. - It writes to the transcript. Each drained message is recorded as a real
userConversationTurn. The transcript must reflect what the operator actually said;packConversationalready guarantees the lastuserturn stays visible, andfindCurrentMacroTurnStarttreats the steer as part of the macro-turn in progress. Note this does count toward reflection segmentation cadence (state.turnCountis untouched, but the turn list grows) — a steer is a real user message, so that is the intended reading. - The UI sees it land.
steer_applied({ text, stepIndex }) is emitted at the step the message was folded into.reduceAgentEvent(src/tui/agent-event-reducer.ts) renders it inline in the turn already running — a user bubble plus a feed line naming the step — with none of the per-turn resetsuser_messagetriggers. That switch is exhaustiveness-checked (const unhandled: never = event), so the nextAgentLoopEventadded without a case is a compile error rather than a silent no-op; it still returnsstateat runtime, because a UI reducer must not throw on an event it does not know. - It shares
### noticewith the loop detector. Both write the one-shot notice slot;composeSteerNotice(src/agent/steer-notice.ts) appends rather than overwrites, loop-detector text first. The message text is repeated inside### noticeeven though it is already in### conversation: the notice sits immediately before### respond, which is the block small local models reliably act on. Long pastes are clipped inline and point back at the transcript copy. - Nothing is silently lost. A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on
RunTurnResult.undelivered. Callers MUST re-route it:ChatOrchestrator.rerouteUndelivered(src/tui/chat-orchestrator.ts) puts it at the head of its pending-message queue, ahead of anything typed aftersteerstarted refusing. A caller that ignoresundelivereddrops a messagesteeralready answered "yes" to — that is a bug, not a style choice.shutdown()callsclearAll()so a stale steer cannot resurface in a later process. - The caller offers, then falls back.
ChatOrchestrator.sendMessageis the reference shape: while a turn is in flight it callsruntime.steerfirst and only queues on its own when that returnsfalse. (The editor stays live during a turn — a mid-turn submission routes throughhandleEditorSubmitasmessage_queuedand reaches this path; steer first, queue on refusal.) - A refusal is not a demotion. The caller's "a turn is in flight" is strictly WIDER than the window:
ChatOrchestratorsetscurrentControllerbeforeruntime.runTurn, and the loop'sopen()runs only onceturnController.enqueuestops parking inwaitOrAbort— i.e. after any out-of-band turn on the same session finishes settling. A steer aimed into that span is refused, so the fallback must keep the operator's ordering:queueAsSteersplices it in at the front of the pending queue (behind steers already re-routed for the same turn, so their typing order survives) and still emits a "steering the running turn" acknowledgement — worded so it holds whether the window was already shut, not yet open, or full. The window is deliberately NOT opened at the caller's commit point instead: it is a per-session single slot, so opening it before the submission owns the session lock aliases two turns onto one window — the turn still running would drain a message meant for the parked one, and itscloseAndDrainwould carry off the parked turn's pending steers as its ownundelivered. And on the abort-while-parked pathrun()never executes, so nothing would close the window or hand anything back. - The caller reads exactly one fact.
sendMessagedoes not consultsteeringInbox.isOpento tell "window shut" from "inbox full" apart, and no caller should gate onturnController.isBusybefore callingsteer. Both are second facts read at a different moment than the onesteeracts on — the check-then-act this mechanism exists to remove.steer's return value is the whole answer. - Bounded.
MAX_PENDING_STEERS(16) per session;pushrefuses past the cap rather than evicting the oldest, so the caller learns the message did not land.
TUI surface. The editor stays live for the whole turn, so Enter has to mean something while the agent is working. tui.whileBusySubmit ("steer" | "queue", default "steer") decides which, Ctrl+T flips it in-app and persists the flip, and the prompt meta-row shows the live mode (⏎ steer / ⏎ queue) whenever a turn is running. /steer <msg> and /queue <msg> land one message in the other mode without changing the default; bare /steer switches to steer mode, /queue mode to queue mode — bare /queue stays a side-effect-free listing, because the menu node and the /queue N parked chip both invite running it just to look. All three routes to the setting (Ctrl+T, bare /steer, /queue mode) go through the single onWhileBusyModePersistRequested callback into persistUserWhileBusySubmit, so the choice survives a restart and there is one place that can fail.
Host surfaces. The sidecar exposes it as the steer_message NDJSON request ({sessionId, text} -> {steered}) plus the steer_applied / steer_undelivered events; serve exposes POST /api/sessions/{id}/steer with body {text} — 200 {steered:true}, 409 when no running turn will pick the message up (it is refused, not swallowed — retry with POST /v1/chat/completions), 429 when the inbox is full. Neither handler goes through turnController.enqueue: enqueueing would park the message behind the turn it is meant to redirect. Neither pre-checks turnController.isBusy either — runtime.steer is the single authority on accept/refuse, and the HTTP route reads the inbox only after a refusal, to choose between the two status codes. A pre-check would be a second, staler fact that can reject a steer the runtime would have taken.
Every host surface consumes undelivered. Accepting a steer is a promise to say where it ended up, so no surface may drop RunTurnResult.undelivered:
- Sidecar.
send_messageemits onesteer_undeliveredevent per stranded message. The host owns it from there. - HTTP.
POST /api/sessions/{id}/steerand the turn that would have carried the message are different exchanges — the steer was answered long before the turn closed, and the completion response goes to whoever owns the turn, who is not necessarily whoever steered. So the route that ran the turn (src/http/openai-chat-completions.ts) always parks the hand-back inUndeliveredSteerStore(src/http/undelivered-steers.ts), on the success, failed and threw paths alike, and additionally mirrors it onto that response where one can carry it:undelivered_steerson the non-streamchat.completionbody (absent when the turn delivered everything), anevent: steer_undeliveredSSE frame for extensions-opt-in streams (a vanilla OpenAI stream stays strict). The mirrored entries carry the parkedseq, so they are the same message, not a second copy. Hosts read parked messages withGET /api/sessions/{id}/steerand acknowledge withDELETE /api/sessions/{id}/steer?through={seq}&discarded={n}(either parameter alone is fine; at least one is required); reads are non-destructive because a retried or prefetchedGETmust not be able to lose the text, and the ack is by cursor so a steer parked between the two calls survives.DELETE /api/sessions/{id}drops that session's parked messages with the row. The store is per-server and in-memory (bounded byMAX_PARKED_STEERSper session andMAX_PARKED_SESSIONSsessions), matching the inbox it drains from — neither survives a restart. Two properties the cap must not break:discardedis acked separately from the entries — the discarded messages have noseqthe host was ever shown, so the entry cursor cannot stand in for having read the loss count, and a box outlives its entries while a loss is unacknowledged (still reclaimed by the session purge and by session eviction); and a hand-back is returned whole — the cap evicts only entries parked by earlier calls, never the batch it was just handed, because that return value is what becomesundelivered_steers/steer_undeliveredand trimming it would omit messages from the one payload meant to carry them. - Anything else that calls
runTurndirectly (task runner, channels) inherits the same obligation.
Pinned by src/runtime/steering-inbox.test.ts, src/agent/steer-notice.test.ts, src/agent/agent-loop-steering.test.ts (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep), src/tui/chat-orchestrator-steering.test.ts (steer-then-queue fallback, undelivered re-route and its ordering, plus a real-TurnController harness that parks a TUI turn in waitOrAbort behind an out-of-band one and steers into the gap) and the steering cases in src/runtime/bootstrap.test.ts — including the one that stands in the window between the loop's final drain and busy.delete.
Extension points
TurnController.isBusy(sessionId)/busySessionIds()— observability hook for UI and scheduler.TurnController.emit(sessionId, event)— single dispatch path forAgentLoopEventto the per-session hook.runtime.steer(sessionId, text)— fold a message into the turn already running on that session. Returnsfalse(and queues nothing) when no running turn can still pick it up. Deliberately not gated onisBusy— see §"Mid-turn steering".runtime.executeTurn(session, msg, opts)— bypasses the queue. Used by sidecar from inside an already-acquiredenqueuecallback so it does not deadlock against itself. CLI / TUI / HTTP go through the publicruntime.runTurninstead.
Risk (acknowledged)
The three existing callers (CLI / HTTP / sidecar) relied on subtle hook timing through TurnHub / inline onAgentEvent. The hook contract on TurnController matches the defunct TurnHub.runExclusive byte-for-byte (hook installed before run(), cleared in finally), so single-session callers are observably identical. New surface tests cover the cross-session and same-session-FIFO behaviour: src/http/openai-chat-completions.test.ts, src/sidecar/send-message-concurrency.test.ts, src/memory/reflection/reflection-runner.test.ts.
Privacy tab (analytics + approvals)
The atomic-agent tui Manage pane hosts the trust-and-safety switches on one screen: the anonymous-analytics opt-out (a, shared by product analytics and crash reporting) and the five-step approval ladder over the approval gate (1..5 jump, ←/→ step; level 5 is rendered in the error color and states the cost plainly). State slice state.privacyPanel in src/tui/tui-state.ts; modules live under src/tui/privacy/ and follow the standard panel pattern — pure privacy-panel-state + privacy-actions + privacy-panel-reducer, dedicated privacy-key-bindings, and PrivacyOrchestrator as the only TUI module that persists analytics.enabled / agent.approvalLevel and hot-applies them (runtime.setAnalyticsEnabled, runtime.setApprovalLevel).
The ladder (agent.approvalLevel, config v37; the binary agent.approvalRequired migrates: false → 5, true/absent → 1). Every requireApproval call site names an ApprovalCategory (closed union in src/approval/approval-level.ts); the gate auto-approves a request when level >= AUTO_APPROVE_FROM_LEVEL[category] — an O(1) record lookup, cumulative by construction:
| Level | Name | Stops asking for |
|---|---|---|
| 1 | paranoid (default) | nothing — every gated action asks |
| 2 | workspace | fs_write_workspace: os.fs.{write,edit,patch} strictly inside the session cwd (realpath containment via src/tools/os/fs-approval-scope.ts; symlinks pointing outside are classified by their target) |
| 3 | home | + fs_write_home (writes anywhere under the home directory, plus os.fs.archive.extract even into the workspace), fs_trash, http (SSRF guard is not part of the gate and stays on) |
| 4 | operator | + shell (guard verdict approval_required only), script (skill.run_script), proc_kill |
| 5 | full trust | everything, including browser_nonweb (file://, javascript:), trust_config, and other |
Categorisation lives at the call sites (fs tools resolve workspace/home/outside from the target path + ctx.workingDir); a write outside both the workspace and home maps to other, which asks on every level except 5 — the conservative default for anything a call site cannot place. Hardline shell-guard rules fire before the gate and block at every level. MCP tools from a server at the default approval_gated trust go through the gate as category other (tools advertising annotations.readOnlyHint === true at discovery are exempt); pure_read servers bypass it — see §"MCP client".
One funnel for fs mutations. Every fs-mutating tool (os.fs.{write,edit,patch,trash,archive.extract}) routes its prompt through requireFsApproval (src/tools/os/fs-require-approval.ts), which categorises (categorizeFsMutation) and calls the shared requireApproval in one step. A new mutate-tool cannot half-wire the ladder — classify against the wrong scope inputs or forget the trust_config guard — because the scope inputs (workingDir, trustConfigPaths) travel in the same request object as the prompt copy. The kind discriminator (write / trash / extract) selects the categorisation branch; extract's directory-target exemption from the trust-config guard is encapsulated there (it passes destDir and the guard never fires).
The tools layer does not know where the trust surface lives. trustConfigPaths is injected exactly like workingDir comes from ctx: the bootstrap resolves config.json + .env once via getTrustConfigPaths(config.paths) (src/config/config-file.ts) and threads the list through registerOsTools into each fs tool. fs-approval-scope.ts no longer reads getConfig(); an omitted / empty trustConfigPaths simply disables the guard. This keeps the escalation-guard surface a bootstrap-owned decision, not a tools-layer lookup.
Escalation guard — trust_config (asks until level 5). The state dir sits under $HOME, so <stateDir>/config.json (which holds agent.approvalLevel) and <stateDir>/.env (API keys / bot tokens the runtime loads at boot) would otherwise be fs_write_home and go silent at level 3. That is a self-escalation vector: a model at level 3 or 4 could os.fs.write/edit/patch/trash its own config to level 5 (or swap a token) with no prompt, and the next boot comes up on full trust. categorizeFsMutation therefore compares each write/trash target's realpath (and dangling-symlink target) against the injected trustConfigPaths (resolved once in the bootstrap, see above) and returns trust_config on a match — pinned at level 5 in AUTO_APPROVE_FROM_LEVEL, so it always asks below full trust. Match is by canonical path, not string, so a symlink or .. detour is caught; a not-yet-existing .env on a fresh install still matches via the deepest-existing-ancestor comparison. Extraction is exempt (it targets a directory, not the file). .env is protected identically to config.json on purpose: it is the other half of the trust surface (a silent token swap is account takeover, not just a config bump).
Dangling-symlink containment. canonicalizeMutationTarget classifies by where a write actually lands. For an existing path realpath follows links. For a dangling leaf symlink (leak.txt → a not-yet-existing file outside the workspace) realpath throws ENOENT; naively re-gluing the leaf to its parent would call it a workspace write, but writeFile follows the link and creates the file at the target. So on ENOENT we lstat the leaf, and if it is a symlink we resolve its target (recursively, chain-bounded) and classify that instead. Only a genuinely new file (non-symlink ENOENT leaf) uses the deepest-existing-ancestor fallback, whose missing suffix cannot contain symlinks because it does not exist. This is what makes the panel's "symlinks pointing outside still ask" copy true for broken links too.
Level changes are machine-local (Telegram carve-out, by design). /privacy level exists only in the TUI / CLI, not the Telegram inbound-handler. The ladder is a persistent trust posture for this host — it decides what runs unattended — so raising it should require access to the physical machine, not a remote chat. The Telegram operator still approves or denies each gated action per-request through ApprovalBridge; only the durable level is TUI/CLI-local. A compromised bot token therefore cannot silently raise the standing trust level.
Slash commands: /privacy opens the tab; /privacy analytics on|off|status drives analytics (/analytics stays as the top-level alias); /privacy level 1..5 moves the ladder; /privacy approve on|off survives as the alias pair for levels 5 and 1. The approval prompt itself points here — its footer says approving with y grants one call and the ladder lives on the Privacy tab.
Session grants (prompt-side approval, issue #79)
On top of the standing ladder the approval prompt offers two point exceptions, keyed at the prompt and scoped to the current session only. [s] ("allow this kind this session") approves the call and grants the whole ApprovalCategory; [a] ("allow all <binary> this session") approves and grants one shell command shape. [y] still approves the single call with no grant, [n]/esc deny. The grant is offered on the TUI ApprovalModal (keys routed in app-key-bindings.ts) and the CLI run stdin prompt; both surfaces have physical machine access, matching the level carve-out. Telegram/ApprovalBridge never grants: a remote channel approves per-request only, so a compromised bot token cannot raise session trust any more than it can raise the standing level.
Grants live in-memory on the ApprovalGate, keyed by the id of the session that made them (grantsBySession: Map<string, {categories, shapes}>); they never persist to config.json (session-scoped is a safer default than a durable global toggle, which is what the issue asks for). Because grants are keyed by session id and autoApproval matches on request.sessionId, a request from a different session — a background-task turn on the scheduler, a second live session on the same runtime — shares the gate but never rides another session's grant: "session-scoped" is a structural fact, not a promise every caller remembers to keep. clearSessionGrants() additionally fires on newSession() / switchSession() as a belt-and-braces reset of the leaving session. The standing level is untouched: a grant is a point exception layered over the posture, not a move of the posture. The gate checks grants in request() after the standing level and before emitting a prompt (autoApproval), returning a distinct reason (session grant / session grant: <shape>).
The shape is the guard's own normalised binary (basename, lowercased) that the shell tool passes as ApprovalRequest.commandShape, so [a] covers exactly the argv[0] that would run (git, not a path or a different case). A grant records the category/shape from the gate's own pending request, never from the caller's decision, so a host cannot widen a grant beyond what the prompt was about. The shell tool withholds commandShape for opaque interpreters (bash, sh, zsh, dash, ksh — the dangerous.shell_dash_c family) whose danger lives in their arguments, so [a] is never offered where the binary name hides what runs (bash -c "<anything>"); those prompts still offer [s] (the whole shell category) and [y].
Locked invariants (pinned by src/approval/approval-level.test.ts, src/approval/approval-gate.test.ts, src/tools/os/fs-approval-scope.test.ts, src/runtime/bootstrap.test.ts, src/tui/privacy/privacy-orchestrator.test.ts, src/tui/privacy/components/privacy-panel.test.tsx, src/tui/privacy/privacy-key-bindings.test.ts, src/http/route-capabilities.test.ts, src/cli/serve-command.test.ts, src/config/config-schema.test.ts):
- The
ApprovalGateis the single live switch; tools always registerapprovalRequired: true. Boot flags and the persistedagent.approvalLevelland in the gate's level, never in per-tool registration — a tool-levelfalsewould freeze the boot value forever andruntime.setApprovalLevelcould not flip it back.DangerousToolOptions.approvalRequiredis a test-only seam. - Read the gate, not the config snapshot. UI state (
PrivacyOrchestrator.refresh), the diagnostics line (approval L<n>), andGET /api/capabilities(agent.approvalLevel) all reportruntime.getApprovalLevel(), so--no-approvalboots and live changes are shown honestly.runtime.configis a frozen bootstrap snapshot. The capabilities response also carriesagent.approvalRequired = level < 5, deprecated: it is an approximate back-compat boolean for binary-era clients and is coarse at every mid-ladder level (2/3 silence file writes, 4 silences shell/script/kill), not just one — a boolean cannot say "on for some categories, off for others".approvalLevelis the source of truth; new clients ignoreapprovalRequired. - Every interactive entry point honours the persisted level at boot.
run,tui, andservecallresolveBootApprovalLevel(noApproval, config.agent.approvalLevel);--no-approvalcan only force level 5 for one process, never a stricter level. This is what makes the panel's "applies to future runs too" copy true. - The ladder table above is the product decision and the category union is closed.
approval-level.test.tspins the full category-by-level matrix; adding a category means extending the union (the compiler then walks every construction site). The user-facing coverage copy may not undersell the internal table — the panel test pins the cumulative list per level, and the "hardline guards still block" line is present at every level. - Level 2 workspace containment is realpath-based, dangling symlinks included. A path is "inside the workspace" only when the canonicalised target (deepest existing ancestor for not-yet-created files; the resolved link target for a dangling leaf symlink) stays under the canonicalised session cwd; symlinks that lead outside — live or broken — are classified by their target. Unresolvable paths fall to
other. Pinned byfs-approval-scope.test.ts(including the dangling-out / dangling-in / chain cases) and the level-2 end-to-end test inbootstrap.test.ts(workspace write silent, home write prompts,rm -rf /hardline-blocked). - A write to the agent's own trust config is never silent below level 5.
config.jsonand.envmap totrust_config(pinned at level 5) by realpath match, so the model cannot raise its ownapprovalLevelor swap a token without a prompt. Pinned byfs-approval-scope.test.ts(symlink /../ fresh-install / batch cases) and the level-4bootstrap.test.tscase (os.fs.writetoconfig.jsonprompts with categorytrust_configeven wherefs_write_homeis silent). - Persist first, then hot-apply, and say when they diverge.
PrivacyOrchestrator.setApprovalLevelwritesconfig.jsonbefore touching the gate; if the hot-apply throws after a successful persist, the sticky error names the already-rewrittenconfig.jsonso the operator knows the next boot picks the new value up. Levels clamp to [1, 5] at every runtime surface; the config parser rejects non-integers outright. - The prompt carries its category to every host UX.
ApprovalRequest.categoryis forwarded so a host shows why the prompt fired, not just the tool name: the TUIApprovalModal, the CLIrunstdin prompt, and the TelegramApprovalBridgerender a human label (formatApprovalCategory, e.g.file write · home); the sidecar protocol adds an optionalcategorytoApprovalRequestPayload(back-compat: pre-ladder hosts ignore it) and the HTTP/api/eventsSSE already streams the full request. The Tauri host UI is a separate TS consumer of the protocol type — surfacing the new field there is its own follow-up, but nothing is silently dropped on the wire. Pinned byapproval-modal.test.tsx,approval-bridge.test.ts, and the label matrix inapproval-level.test.ts. - Session grants never bypass hardline or
trust_config, and never persist. A grant is a session-scoped, in-memory point exception layered over the standing level; it silences its category/shape by returning early inApprovalGate.requestbefore a prompt is emitted. It cannot bypass the hardline shell guard: hardline returnsblockinshell.tsbeforerequireApprovalreaches the gate, so a catastrophic command is stopped whether or notshell(or thermshape) is granted. It cannot silencetrust_config:isGrantableCategoryexcludes it on both the record path (resolve) and the auto-approve path (request), so a config/.envwrite always prompts even after a broad grant. Honest boundary: thetrust_configguard covers the fs tools (categorizeFsMutation), not the shell. A write to config via a shell redirect (echo >> config.json) stays under theshellcategory and is silenced by a shell grant, the same as at standing level 4 (operator). Grants do not widen this pre-existing class; closing it means intercepting shell redirects, a separate piece of work. Grants are TUI/CLI-local (never from Telegram) and keyed by session id, so a request from another session — a background-task turn, a second live session on the same runtime — never rides a grant it did not make;clearSessionGrants()also drops the leaving session's grants on every session change. Pinned byapproval-gate.test.ts(grant category / grant shape / trust_config refused / per-session isolation for category and shape / targeted-and-full clear / union snapshot / no-shape shell /canGrant*offer logic),shell.test.ts(interpreter shape suppression), the Privacy panel + reducer + orchestrator tests (the read-only grants view), and the twobootstrap.test.tsend-to-end cases (a category grant silences later shell commands whilerm -rf /stays hardline-blocked; a shape grant silences one binary while another still prompts).
Durable tasks
A minimal durable queue of deferred runTurn submissions lives in src/tasks/. It is the persistence layer for any future scheduler / cron / agent-driven self-scheduling — but it ships without a background ticker on purpose: drains are always triggered explicitly (CLI atomic-agent task run, HTTP POST /api/tasks/drain) or implicitly right after create() when tasks.runOnCreate=true (the default).
A task is exactly one record:
TaskRecord = {
id, sessionId, userMessage, maxSteps,
status: "pending" | "running" | "completed" | "failed" | "blocked" | "cancelled",
origin: "cli" | "tui" | "http" | "sidecar" | "scheduler" | "agent",
attempts, maxAttempts, lastError, lastErrorCategory,
createdAt, updatedAt, startedAt, completedAt,
}
Stored in a separate SQLite file <stateDir>/tasks.sqlite (no cross-file FKs to sessions.sqlite — sessionId validity is checked at runtime by TaskRunner and a missing session marks the task blocked with session_not_found). Schema version 3, idempotent migrations in src/tasks/task-schema.ts (v2 added the scheduling columns, v3 the notify opt-in — both documented in §"Background autonomy").
Lifecycle
pending --(markRunning)--> running
running --(success)--> completed
running --(retryable, attempts < maxAttempts)--> pending [retry loop]
running --(retryable, attempts == maxAttempts)--> failed
running --(grammar | tool failure)--> blocked [permanent — same input, same wall]
running --(cancelled signal)--> cancelled
pending --(cancel())--> cancelled
Failure classification is delegated to classifyFailure from src/llm/reliability/ (the LLM reliability policy below) so retry semantics never drift from the rest of the runtime.
Drain semantics
TaskRunner.drainPending(opts?) is the one-shot drain primitive:
- Pull every
pendingtask (optionally?session=). - Group by
sessionId. - For each group: drain sequentially. Each call into
runtime.runTurn(..., { origin: "scheduler" })entersTurnControllerper-session FIFO and serialises against any user turn that lands mid-drain. - Across groups:
Promise.all— different sessions drain in parallel, inheriting cross-session parallelism from the Concurrency contract above for free.
Inter-attempt sleep on retry uses nextDelayMs(attempts, { initialMs, maxMs }) from src/tasks/task-backoff.ts (min(initialMs * 2^attempt, maxMs)). Sleep happens between attempts, never blocking the per-session lock for longer than necessary.
Locked invariants (pinned by tests)
- Tasks always run via
runtime.runTurn(..., { origin: "scheduler" }). Never viaexecuteTurn(which bypasses the controller). Per-session FIFO + cross-session parallelism are inherited from §"Concurrency contract". - Retries are turn-level only. The same
userMessageis replayed; partial-tool replay is out of scope. Step-level retries inside a singlerunTurnremain the LLM reliability layer's responsibility. TaskRunnernever holds aSessionStatereference between attempts. It always re-reads viasessionStore.load(sessionId)inside the next attempt — same pattern as the sidecarsend_messagecallback.cancel(id)is idempotent on terminal rows — returns the existing record unchanged, so HTTPDELETEand CLIcancelare safe to retry.- Stale recovery is one-shot.
taskStore.recoverStale(staleAfterMs)runs exactly once on bootstrap; there is no background sweeper. Process crash betweenmarkRunningand the terminal write leaves arunningrow that the next bootstrap flips back topending. tasks.enabled=false≠TaskStoreis absent. The store is always constructed (it owns a SQLite handle that must be closed inshutdown), butdrainPendingis a no-op and HTTP routes return 404. Mirrorsmemory.profile.enabledfrom Memory fabric.
Surfaces
| Surface | Path | Notes |
|---|---|---|
| HTTP | POST/GET /api/tasks, GET/DELETE /api/tasks/:id, POST /api/tasks/:id/run, POST /api/tasks/drain | src/http/route-tasks.ts. Returns 404 for every route when tasks.enabled=false. |
| CLI | atomic-agent task list | show | create | cancel | run | src/cli/task-command.ts. All subcommands except run open TaskStore directly and exit fast; run boots the full createAgentRuntime and tears it down on the way out. |
| Metrics | agent.tasks.{created,started,completed,failed,blocked,cancelled,retried} counters + agent.tasks.{attempts,duration_ms} histograms | Emitted from TaskRunner at status transitions. |
Configuration
All under tasks.* in src/config/config-schema.ts — env-only (operational tuning, not user config file material):
tasks.enabled(defaulttrue) — master switch.tasks.maxAttempts(default3) — retry budget per task.tasks.backoffInitialMs(default1000) /tasks.backoffMaxMs(default60000) — exponential capped backoff.tasks.runOnCreate(defaulttrue) — auto-drain immediately aftercreate(). Detached, fire-and-forget.tasks.staleAfterMs(default300000) —recoverStalethreshold.paths.tasksDbFile— resolved to<stateDir>/tasks.sqlite.
Out of scope (deferred)
Task graphs / dependencies, workflow primitives (kind != "runTurn"), secret redaction in userMessage / lastError, per-origin priorities, distributed scheduler / leader election across multiple processes. Scheduling itself, webhook ingress, and agent-side tasks.* tools shipped in Option 4 — see next section.
Background autonomy
Option 4 ships time-based scheduling, webhook ingress, and agent self-scheduling as a thin layer on top of §"Durable tasks". It does not change the runTurn contract or add any timers outside of src/scheduler/.
Schedules on TaskRecord
TaskRecord carries an optional schedule: TaskSchedule | null where TaskSchedule is a discriminated union:
TaskSchedule =
| { kind: "at"; at: number } // Unix ms
| { kind: "cron"; expression: string; tz?: string } // parsed via cron-parser
| { kind: "interval"; everyMs: number } // lower bound config.tasks.minIntervalMs
resolveScheduledFor(schedule, fromMs) in src/tasks/task-schedule.ts is the only path that turns a schedule into an absolute scheduledFor (Unix ms). It is used both by TaskRunner.create on insert and by recurring requeue on completion. cron-parser is imported only from this file.
Schema bumped TASK_SCHEMA_VERSION 1 → 2, idempotent migration adding columns schedule_kind, schedule_value (JSON), scheduled_for, recurring, last_scheduled_at, trigger_source and a partial index idx_tasks_due(status, scheduled_for) WHERE status='pending' — the only path the scheduler uses to find work.
Scheduler
src/scheduler/scheduler.ts exposes one class with start(), stop(), tickOnce() (for tests). One setInterval per runtime, period = config.tasks.schedulerTickMs (default 5000), batch = config.tasks.schedulerBatch (default 10). On each tick:
- Guard
runningflag (no reentry). await taskRunner.runDue(Date.now(), batch).- Errors are swallowed + logged + counted in
agent.scheduler.tick_errors; interval keeps running.
Wired in src/runtime/bootstrap.ts after taskStore.recoverStale. shutdown() awaits scheduler?.stop() before taskStore.close() to prevent a final tick from touching a closed handle.
Session lifecycle for scheduled tasks
This is the most load-bearing rule in Option 4 — respect it when adding new code paths.
| Task shape | sessionId at create() | sessionId before runTurn |
|---|---|---|
User-provided sessionId (CLI / HTTP / sidecar) | As provided | Unchanged. |
One-shot, no sessionId, no schedule (or schedule.kind="at") | NULL | TaskRunner.runOne lazily creates a fresh ephemeral session, writes it back to the row, then calls runTurn. Once written, stable for the row's lifetime. |
Recurring (cron / interval), no sessionId | A fresh persistent session, created immediately by sessionFactory | Reused across every firing. If the session row is missing (user deleted it), runOne auto-recreates, logs a warning, emits agent.tasks.session_recreated, and continues. |
requeueRecurring atomically resets attempts, last_error, started_at, completed_at and rearms scheduled_for — but never touches session_id. This invariant is pinned by src/tasks/task-store.test.ts.
Wake reason on session metadata
TaskRunner.stampWakeReason writes session.metadata.wakeReason = { source, taskId, webhookName?, at } before every runTurn, then persists the session. source ∈ { "user", "scheduler", "webhook", "agent" } mirrors TaskRecord.triggerSource. The reserved keys under session.metadata are documented in src/session/session-state.ts:
wakeReason— set byTaskRunner, audit-only in this milestone (not rendered into the prompt).recurringTask,scheduleKind— set on persistent sessions owned by recurring tasks.webhookName,webhookPersistent— set on sessions created viaPOST /api/webhooks/:name.ephemeralTask,scheduledBy— set on lazy-created one-shot sessions.
None of these are currently rendered into the stable prefix; if you start rendering them, pin the stable-prefix hash test first.
Webhook ingress
POST /api/webhooks/:name in src/http/route-webhooks.ts resolves config.webhooks[name] and returns 404 when missing or when tasks.enabled=false. On success:
- Optional
x-webhook-secretcheck againstconfig.webhooks[name].secret. userMessage=evaluateWebhookTemplate(userMessageTemplate, body)with{{body.<json.path>}}substitutions (see src/http/webhook-template.ts — minimal substitution, no expression eval, length-capped).sessionIdresolved persessionMode:ephemeral(leave null —TaskRunnercreates fresh onrunOne),persistent(read/create via webhook-session-store.ts, file-backed JSON in<stateDir>/webhook-sessions.json),named(require explicitsessionIdin config).taskRunner.create({ origin: "http", triggerSource: "webhook", sessionId, userMessage, schedule })— the route never callsrunTurndirectly.- HTTP 202 with
{ taskId }.
Webhook config lives in the user config file (per-name declarative — ops shouldn't need a redeploy to add a new webhook). USER_CONFIG_VERSION bumped 2 → 3 with transparent v2 → v3 migration (webhooks: {} default).
Agent tools (tasks.*)
Five tools in src/tools/tasks/, gated by config.tasks.agentToolsEnabled. Registered from src/runtime/bootstrap.ts next to registerMemoryTools. The current session id is read via AsyncLocalStorage (currentSessionId from src/runtime/session-context.ts).
| Tool | Writeable | Session resolution | Schedule |
|---|---|---|---|
tasks.schedule | yes | Inherits current session by default; newSession=true opts into a fresh one | at (absolute) or inSeconds (relative) — validated via parseOneShotSchedule |
tasks.cron | yes | Always a fresh persistent session (recurring ⇒ continuity, never mix with user thread) | { kind: "cron", expression, tz? } |
tasks.list | no | Defaults to current session; filters by status (CSV) and limit (capped at 200) | — |
tasks.cancel | yes | — | — |
tasks.show | no | — | — |
TaskValidationErrors from parseOneShotSchedule / task-schedule are caught inside each tool's run method and surfaced as a structured { status: "error", details } result — they never escape as thrown exceptions.
Descriptors in src/prompt/tool-descriptors.ts; the GBNF grammar grammars/tool-call.gbnf was extended with a tasks-tool branch covering all five names.
Telegram reports for scheduled tasks (notify)
A task can opt into reporting its terminal outcome to the paired Telegram owner: TaskRecord.notify ∈ TASK_NOTIFY_TARGETS (single member "telegram" today) or null (default — silent, the pre-v3 behaviour). Schema TASK_SCHEMA_VERSION 2 → 3 (idempotent ALTER TABLE tasks ADD COLUMN notify TEXT). The opt-in is exposed on the tasks.cron / tasks.schedule agent tools (notify?: "telegram"), CLI task create --notify telegram, and TaskCreateInput; the TUI create form and POST /api/tasks do not surface it yet (deferred — the HTTP create surface cannot express schedules either), and the OpenClaw / Hermes cron importers never set it.
Flow: TaskRunner.runOne installs a turn event hook only for opted-in tasks (every other task passes no hook at all), captures the final result text — the assistant_reply for reply terminals, or the finish tool's summary (tool_call_executed with result.tool === "finish", details.summary with the compressed summary as fallback; same payload the TUI renders as the final feed line) for finish terminals — and on a terminal transition hands a TaskReport to TaskRunnerOptions.reportSink. Bootstrap wires the sink to TelegramChannel.sendTaskReport, which renders via src/channels/telegram/task-report-message.ts and posts to the owner's DM (chatId = ownerUserId — a user-bot private chat is addressed by the user's own id, and the owner id is only ever captured from a private DM). Boot ordering: bootstrap starts the Scheduler only after the Telegram channel object is constructed, so a report can never observe a missing channel; a channel that is not up yet queues the report itself (below).
Locked invariants (pinned by src/tasks/task-runner.test.ts, src/tasks/task-store.test.ts, src/channels/telegram/task-report-message.test.ts, src/channels/telegram/telegram-channel.test.ts):
- Terminal-only, final result only. Reports fire on
completed | failed | blocked. Never on within-attempt retries (running -> pending), never oncancelled(operator-initiated or shutdown-driven), never mid-run — there are no progress updates. Recurring tasks report each firing off the completed row beforerequeueRecurringflips it back topending. - Best-effort, never load-bearing, never silent. Every outcome is a
TaskReportDeliveryvalue; skips that drop a report are warn-logged with the reason, the task's own status is untouched, and a throwing or rejecting sink is isolated the same way. A channel with a configured token that is notupyet does not drop: the report lands in a bounded FIFO queue (TASK_REPORT_QUEUE_LIMIT= 20, oldest evicted with a warning) and flushes on the transition toup— this covers the boot race and a channel enabled later; with no token configured the report is skipped as before. Partial chunk delivery counts asdelivery_failed(warn carries delivered/total chunk counts), never assent— and the report format is pinned to always fit one outbound chunk at the current caps. Losing a report without a log line is forbidden. - Plain text, bounded size. Task reports are channel infrastructure (invariant 8 of §"Telegram remote-control channel") — never
parse_mode: "HTML". The result excerpt caps atTASK_REPORT_RESULT_MAX_CHARS(3000) and errors at 500 chars, both with an explicit… [truncated]marker. notifyis an allow-list at both ends.TaskStore.createrejects unknown targets withTaskValidationError("notify"); unknown persisted values clamp tonullon read, so the runner only ever sees list members.- Egress is explicit and opt-in. A report sends the task prompt preview plus the result / error excerpt to the Telegram Bot API — that content leaves the machine. Only tasks that explicitly set
notifyare affected. - Approval behaviour is unchanged. This feature adds no Telegram approval relay for unattended scheduled runs; approval-requiring steps behave exactly as before.
Configuration (env-only under tasks.*)
Extending §"Durable tasks" config:
tasks.schedulerEnabled(defaulttrue, envATOMIC_AGENT_TASKS_SCHEDULER_ENABLED).tasks.schedulerTickMs(default5000).tasks.schedulerBatch(default10).tasks.agentToolsEnabled(defaulttrue, envATOMIC_AGENT_TASKS_AGENT_TOOLS_ENABLED).tasks.minIntervalMs(default1000) — lower bound for{ kind: "interval" }.
Webhook config lives under webhooks.* in the user config file, not env.
Metrics (tasks)
Added to src/tracing/agent-metrics.ts:
- Counters:
agent.tasks.scheduled,agent.tasks.recurring_requeued,agent.tasks.session_recreated,agent.tasks.session_auto_created,agent.scheduler.ticks,agent.scheduler.tick_errors,agent.webhooks.received. - Histograms:
agent.scheduler.batch_size,agent.scheduler.tick_duration_ms. - Webhook tag:
webhook_name.
CLI
atomic-agent task create now accepts scheduling flags:
--at <unix-ms>— one-shot at absolute time.--cron "<expr>" [--tz <iana>]— recurring cron (allocates a persistent session eagerly, so this path boots the full runtime).--every <seconds>— recurring interval.--session <id>is now optional; omit for one-shot ephemeral or let recurring allocate its own.
atomic-agent task list gained schedule and next-run columns.
atomic-agent task tick — one-shot runDue(now, limit=Infinity) for ops debugging (does not start the long-lived ticker).
TUI surface (Tasks tab)
The atomic-agent tui debug pane exposes the task store as a first-class tab. State slice state.tasksPanel in src/tui/tui-state.ts drives three view modes — list, detail, create — plus an optional cancelConfirm modal.
Module map:
- src/tui/tasks/tasks-panel-state.ts — state types +
createInitialTasksPanelState. - src/tui/tasks/tasks-actions.ts —
TasksActionunion (tasks_*prefix); folded into the rootTuiActionvia a mixin. - src/tui/tasks/tasks-reducer.ts — slice reducer invoked first by the root reducer, returns
nullto fall through. - src/tui/tasks/tasks-filter.ts — pure filter+sort (status bucket, substring search).
- src/tui/tasks/tasks-summary.ts —
TaskRecord → TaskSummaryRowwith time/interval formatters. - src/tui/tasks/cron-preview.ts — thin wrapper over
peekNextFirings(still keepscron-parserbehindtask-schedule.ts). - src/tui/tasks/tasks-form-validator.ts — create-form parser; errors surfaced as
preview.error, never thrown. - src/tui/tasks/tasks-orchestrator.ts — the only module that calls
runtime.taskStore/runtime.taskRunnerfrom TUI code. Owns a 5ssetIntervalrefresher (opt-in on first entry to the tab). - src/tui/tasks/tasks-key-bindings.ts — dedicated hotkey layer; disables the editor when
activeTab === "tasks"so single-char hotkeys (j/k/n/c/R/r/a/f/o) never conflict with typing. - Components in src/tui/components/tasks-*.tsx.
Keyboard contract:
- List mode —
j/k(or arrows) move cursor, Enter opens detail,nopens create form,ccancels (y/n confirm for recurring),Rruns now,rmanual refresh,atoggles auto-refresh,fcycles status filter. - Detail mode —
oopens the task's session,Rruns now,ccancels, Esc returns to list. - Create form — Tab/Shift+Tab cycles focus (
kind → expression → tz? → message → submit), Left/Right cycles kind when focused, Enter submits on the submit field or advances otherwise, Ctrl+Enter submits from any field, Esc closes. - Cancel confirm modal —
yconfirms,n/Esc dismisses.
Slash commands:
/tasks— jump to the Tasks tab./task new— jump + open the create form./task cancel <id>— enqueue a cancellation (skips the modal — intentional, the operator knows the id)./task run <id>— execute one attempt viaTaskRunner.runOne.
Locked invariants (pinned by src/tui/tasks/tasks-reducer.test.ts, src/tui/tasks/tasks-filter.test.ts, src/tui/tasks/tasks-form-validator.test.ts, src/tui/tasks/cron-preview.test.ts, src/tui/tasks/tasks-summary.test.ts, src/tui/commands/slash-command-handler.test.ts):
TasksOrchestratoris the only TUI module that touchesruntime.taskStore/runtime.taskRunner. Components dispatch actions; actions reach the orchestrator viaTuiAppCallbacks.cron-parserstays behindtask-schedule.ts. TUI only importspeekNextFiringsviacron-preview.ts.- The editor is disabled on the Tasks tab. Single-char hotkeys are free to use letter keys; Tab re-enters the debug-tab cycler unless a create form or cancel modal is open.
- Form validation is pure. Neither the reducer nor the keybinding layer throws; all errors surface as
preview.errororruntime_infolines. - Firings feed is best-effort. The orchestrator diffs task snapshots between refresh ticks; it is never the source of truth for billing or auditing — that stays in metrics + traces.
Locked invariants (pinned by tests)
Pinned by src/scheduler/scheduler.test.ts, src/tasks/task-runner.test.ts, src/tasks/task-store.test.ts, src/http/route-webhooks.test.ts, src/runtime/bootstrap.test.ts, and colocated tool tests:
Scheduleris the only new periodic timer. NeversetIntervaloutsidesrc/scheduler/.- Webhooks never call
runTurndirectly. AlwaysTaskRunner.create. - Recurring requeue preserves
session_id. Only auto-recreation onsession_not_foundmay overwrite it. - One-shot
session_idis stable after the first attempt. Lazy-created sessions must be written back to the row beforerunTurn. - Partial-index
idx_tasks_dueis the only scheduler path. No full scans. tasks.enabled=falsedisables everything. Scheduler doesn't start, webhook routes 404, agent tools unregistered.cron-parseris isolated behindtask-schedule.ts. Future replacement touches one file.session.metadata.wakeReasonis audit-only. Survives restart, never rendered into the prompt.- Agent tool validation errors are structured, not thrown. Including nested errors from schedule parsing.
TUI surface (Memory tab)
The atomic-agent tui Manage pane exposes read-only inspection of the memory fabric. State slice state.memoryPanel in src/tui/tui-state.ts drives list / detail views across six channels: profile, notes, lessons, procedures, links, votes.
Module map:
- src/tui/memory/memory-panel-state.ts — slice types +
MEMORY_CHANNEL_ORDER. - src/tui/memory/memory-orchestrator.ts — the only TUI module that reads
runtime.profileStore,notesStore,lessonStore,procedureStore,linkStore, andvoteStore. - src/tui/memory/memory-reducer.ts — pure fold of
memory_*actions. - src/tui/components/memory-panel.tsx — list/detail router.
AgentRuntime exposes readonly linkStore: LinkStore (always constructed; recall expansion and link-generator remain gated on memory.links.enabled). Operator inspection uses LinkStore.listAll({ limit }) (default 500, hard cap 1000).
Slash commands: /memory opens the tab; /memory dump keeps the legacy profile dump into chat (ChatOrchestrator.dumpProfile()).
Locked invariants (pinned by src/tui/memory/memory-reducer.test.ts, src/tui/memory/memory-filter.test.ts, src/memory/links/link-store.test.ts, src/tui/commands/slash-command-handler.test.ts):
MemoryOrchestratoris the only TUI module that touches memory stores. Components dispatch actions; refresh runs viamemory_refresh_requestedon the event bus.- Read-only. No delete / vote / write paths in the tab.
- The editor is disabled on the Memory tab. Single-char hotkeys (
j/k/r/f/[/]/g) do not collide with typing. - Note detail exposes link neighbours when
memory.links.enabled.grunslinkStore.expand; Enter on a neighbour opens that note by id. - Config gates surface hints, not crashes. Disabled channels show an empty list +
channelHintstring.
New terminal window (Ctrl+N)
Ctrl+N in the TUI (and the /window slash command, alias /newwindow) opens a new OS terminal window running a fresh atomic-agent tui in the same working directory. It is a second agent in a second process — not a second view of the current session, which the per-session runtime lock would not allow. /new remains the in-process "fresh session, warm runtime" reset; the two are deliberately different commands.
The resolver is split so the platform logic is unit-reachable without opening windows:
- src/tui/build-terminal-launch.ts — pure.
buildTerminalLaunch({platform, execPath, argv, isSea, cwd, env, hasBinary})→{cmd, args, label}ornull. macOS drivesosascript→Terminal(oriTermwhenTERM_PROGRAM === "iTerm.app"); Linux probes$ATOMIC_AGENT_TERMINAL→$TERMINAL→ gnome-terminal / konsole / xfce4-terminal / kitty / alacritty / wezterm / x-terminal-emulator / xterm through the injectedhasBinary; Windows useswt.exe -w -1 ntwhen present, elsecmd.exe /c start … cmd /k. - src/tui/open-terminal-window.ts — the effectful half:
detached: true, stdio: "ignore"+unref()so the new window outlives this process,spawninjectable, every failure returned as{ok: false, reason}and never thrown into the render loop. Also owns theisOnPathPATH probe (nowhichshell-out).
Two details that are easy to regress:
argv[1]must be dropped for a SEA build and kept under plain node — same reasoning as the self-update relaunch in src/tui/tui-command.ts;tuiis always appended explicitly.ATOMIC_AGENT_STATE_DIRtravels inside the command line. A spawned terminal starts a login shell and inherits nothing from us, so without the inline assignment the second window would silently attach to a different state dir.
The POSIX command line ends with exec "${SHELL:-sh}" on Linux because -e closes the window the instant the agent exits, which would eat a startup error. macOS do script already leaves the shell alive, so it does not need this.
Pinned by src/tui/build-terminal-launch.test.ts (per-platform argv shapes, SEA split, state-dir passthrough, shell + AppleScript escaping, null on a headless box), src/tui/open-terminal-window.test.ts (detach/unref, error-as-value, PATH probe), src/tui/app-key-bindings.test.ts (Ctrl+N fires only outside modals / the slash palette / a pending approval) and src/tui/commands/slash-command-handler.test.ts (/window vs /new).
Vision (multimodal input)
Image recognition is an opt-in feature wired through the active LlmProvider (src/llm/provider/llm-provider.ts) — LlamaServerProvider for local /v1/chat/completions, OpenAiProvider / OpenRouterProvider for cloud. The text agent loop is unchanged — vision lives outside the conversation transcript, exposed only via the vision.describe tool.
Surfaces
| Layer | Module | Responsibility |
|---|---|---|
| Detection | src/llm/model-profile.ts detectVisionSupport | Inspects /props and stamps ModelProfile.vision = { supported, source }. Source priority: modalities.vision (current llama.cpp surface) → has_multimodal → multimodal → mmproj (legacy fallbacks). The first source that reports support wins; the resolved tag is surfaced through ProviderCapabilities.visionSource for diagnostics. |
| Provider | src/llm/provider/llm-provider.ts | LlmProvider interface — name, capabilities, describeImage(request). Future non-llamacpp adapters implement this surface. |
| Adapter | src/llm/provider/llama-server/llama-server-provider.ts | Speaks the OpenAI-compatible /v1/chat/completions endpoint with messages: [{role:"user", content:[{type:"image_url", image_url:{url:"data:<mime>;base64,…"}}, {type:"text", text:prompt}]}]. Sends chat_template_kwargs: {enable_thinking: false} + reasoning_format: "none" so Gemma-4 / other thinking-capable models do not park the answer in a separate thinking channel. Sniffs JPEG/PNG/WebP/GIF magic bytes for the data: MIME. Does not pass slot_id — chat-completions manages its own slots; the main agent slot and the reflection slot are not touched. capabilities is a getter (not a frozen field) that reads the live profile through getProfile(), so vision turns on the moment ModelProfileManager swaps to a multimodal profile (load-bearing for the TUI's deferLlamaHealthCheck=true cold start). |
| Tool | src/tools/vision/describe.ts + load-image.ts | vision.describe { prompt, path? | paths? }. Loads images from disk (png/jpg/jpeg/webp/gif), enforces per-call and per-image caps, calls the provider, returns a CompressedToolResult like any other tool. |
| Wiring | src/runtime/bootstrap.ts | Constructs the LlamaServerProvider only when config.vision.enabled === true, threading a getProfile closure that resolves through ModelProfileManager (with the cold-start profile as fallback). When the provider is present, vision.describe stays in effectiveToolDescriptors for the entire session — capability is checked dynamically at call time, not at bootstrap. The descriptor is filtered out only when config.vision.enabled === false, so the prompt never advertises a tool the runtime cannot actually invoke. |
| Catalog | src/local-llm/models-catalog.ts | LocalModelDef.supportsVision + mmprojUrl / mmprojFilename / mmprojFileSizeGb for downloads. |
| Installer | src/local-llm/model-installer.ts | downloadMmproj / isMmprojDownloaded for projector files alongside GGUF weights. |
| Daemon launch | src/local-llm/daemon-lifecycle.ts buildLlamaServerArgs | Pure builder for the llama-server argv. When mmprojFile is set, the builder emits --mmproj <path> and a fixed image-token / batch budget: --image-min-tokens 560 --image-max-tokens 560 --ubatch-size 1024 --batch-size 2048. The 560-token budget is the lowest tier in Unsloth's published Gemma-4 grid (70 / 140 / 280 / 560 / 1120) that produces stable general-purpose multimodal chat — at the default ~70 image tokens the clip embedding is too noisy and the model hallucinates instead of describing. The ubatch/batch bumps cover Gemma-4's non-causal vision attention which assumes the entire image-token batch fits in a single ubatch. Both managed-mode start paths (CLI atomic-agent models start and TUI LocalModelsOrchestrator.startDaemon) auto-resolve the projector via isMmprojDownloaded + resolveMmprojFilePath when config.vision.enabled && model.supportsVision. When the projector is missing or vision is disabled the server boots text-only and the vision flags are not emitted. |
| TUI | src/tui/local-models/ | Pull modes with-mmproj (default), gguf-only (g hotkey), mmproj-only (Enter on a model whose GGUF is already present but mmproj is missing). |
Locked invariants
- Vision calls never touch the main agent or reflection slots.
LlamaServerProvider.describeImageposts to/v1/chat/completionswithout aslot_id— chat-completions manages its own slots and never reuses the main agent slot or the reflection slot. The legacy/completion+image_data+[img-N]placeholder path is gone; sending a plain prompt without a chat template was load-bearing for the previous Gemma-4 hallucination bug. Pinned by src/llm/provider/llama-server/llama-server-provider.test.ts (asserts URL ends in/v1/chat/completions, body usesimage_urlcontent blocks,chat_template_kwargs.enable_thinking === false). - Vision lives outside the conversation transcript.
vision.describereturns aCompressedToolResult; no changes toConversationTurnor the variable tail. The model receives the description as a normal### latest-resultblock. - Text completion bypasses the provider. Only the vision verb goes through
LlmProvider. The agent loop continues to callLlamaServerClient.complete/completeStreamdirectly so llama.cpp-specific knobs (slot_id,cache_prompt, GBNF) stay first-class. - Vision tool registration is config-only; capability is dynamic.
registerVisionToolsshort-circuits onconfig.vision.enabled === falseor on a missing provider, but does not checkcapabilities.visionat registration time — that check would freeze the wrong answer when the runtime starts before the first/propsprobe lands (the TUI'sdeferLlamaHealthCheck=truecold start).LlamaServerProvider.describeImagere-checksthis.capabilities.visionon every call against the live profile and throwsVisionUnsupportedErrorwhen the active model is text-only. The bootstrap filters the descriptor out ofDEFAULT_TOOL_DESCRIPTORSonly when no provider was constructed — so disabling vision via config still produces a clean prompt. - Grammar always allows
vision.describe. grammars/tool-call.gbnf keepsvision-toolas a sibling alternative regardless of registration. When the descriptor is absent the model never selects this branch in practice; if it ever did, the registry would reject the call cleanly. - Vision daemon flags are tied to
--mmproj.buildLlamaServerArgsemits--image-min-tokens 560 --image-max-tokens 560 --ubatch-size 1024 --batch-size 2048together with--mmproj <path>— never independently. Removing the bundle will silently regress to the ~70-image-token default that the Gemma-4 / Qwen-VL families confabulate on. Pinned by src/local-llm/daemon-lifecycle.test.ts. vision.describeistier: "frequent"in the descriptor catalog. The fullargsSchemaandexamplesare always rendered into the stable prefix, not the variable### loaded-toolstail. Demoting the tier would cause the agent to emit malformed first-shot calls (e.g. missingprompt) until the rare-tool auto-expansion kicks in on error. Pinned by src/prompt/default-tool-descriptors-b.ts.
Configuration (vision.*)
User-config block (config.json v6; ensureUserConfigFileSync actively migrates older files on bootstrap — when the on-disk version is below USER_CONFIG_VERSION, the parsed contents are atomically rewritten with the bumped version and any newly-added blocks filled from USER_CONFIG_DEFAULTS. Existing user values are preserved verbatim and a single migrated config vN → vM line is emitted to stderr for audit. Read-only call sites (readUserConfigFileSync) stay non-mutating. Migration is one-way. A file whose version is above USER_CONFIG_VERSION — an install that was rolled back, or two builds sharing one state dir — is read with the running build's schema rather than rejected, keeps its own version, and is never rewritten at startup; writeUserConfigFileSync refuses to lower the version field, and unknown top-level blocks survive the round trip. Rejecting a newer file bricks every command, because getConfig() runs ahead of all of them; downgrading its version silently reverts settings, because the version gates behavioural defaults (< 41 forces managed.autoUpdate on, < 22 overrides a memory.* opt-out, < 25 rewrites http.approvalMode)):
vision.enabled(defaulttrue) — master switch. Set tofalseto skip provider construction and tool registration entirely.vision.autoDetect(defaulttrue) — whentrue, the provider's capabilities followModelProfile.vision.supported. Whenfalse, the provider trusts the operator and reportsvision: trueregardless of/props; useful when running a custom backend that does not expose multimodal flags.vision.maxImagesPerCall(default4) — per-call ceiling enforced both in the tool and in the provider (describeImagethrows if exceeded).vision.maxImageBytes(default10485760) — per-image byte cap enforced afterloadImageFilereads from disk.
Out of scope (deferred)
Image inputs as first-class ConversationTurn payloads (the user pasting an image directly into the chat instead of going through the vision.describe tool), paste-from-clipboard / drag-and-drop ingestion in TUI, mmproj checksum verification, per-projector tuning of the image-token budget (today the 560-token tier is uniform across vision models), and an OpenAI-API provider adapter are all out of scope for this milestone. The provider abstraction is intentionally narrow — only describeImage — and will grow when a second adapter actually lands.
Telegram remote-control channel
atomic-agent ships an opt-in Telegram bot that acts as a remote control for the same single-user agent runtime — not a separate process, not a multi-user service. When the user starts the TUI / atomic-agent run / atomic-agent serve, an enabled Telegram channel boots automatically and shares the runtime's TurnController, ApprovalGate, SessionStore, MemoryStore, and ProfileStore. Code lives in src/channels/telegram/ (runtime side) and src/tui/telegram/ (TUI panel).
Lifecycle
The channel is always constructed at bootstrap when the telegram config block exists; only start() is gated on config.telegram.enabled. This is load-bearing for live-control: the TUI / slash commands can flip enabled on at runtime without restarting the host. When enabled=true but TELEGRAM_BOT_TOKEN is missing, the channel transitions to down with lastError: "missing TELEGRAM_BOT_TOKEN" instead of crashing the runtime. Errors are reported through runtime.onChannelStatus (a ChannelStatus sink in src/runtime/channel-status.ts) so CLI / TUI / sidecar can surface them without parsing logs.
Single-instance enforcement is a <stateDir>/telegram.lock file (telegram-lockfile.ts); the second runtime to boot fails fast at start() with a lock_held reason. stop() releases the lock; bootstrap shutdown awaits telegramChannel.stop() before closing SQLite handles.
Polling — explicit AGENTS.md carve-out
The Telegram client uses long-polling (grammy.Bot.start() under the hood). Long-polling is normally forbidden by §"Background autonomy" — Scheduler is the only periodic timer in the runtime, and §"Concurrency contract" disallows additional internal queues. Telegram is the single bounded exception:
- The polling loop is owned exclusively by the grammy adapter inside telegram-bot-factory.ts; no other code in
src/channels/telegram/callssetInterval/setTimeoutfor periodic work. - Every Telegram update is processed in a fire-and-forget wrapper (
bot.on("message:text", …) → void handler(update).catch(…)); the polling loop never blocks onrunTurn. This is what makes/cancelwork mid-turn. - Updates always materialise into a normal
runtime.runTurn(..., { origin: "telegram" })call. Telegram never writes toSessionStore,ApprovalGate, orTurnControllerdirectly. Per-session FIFO + cross-session parallelism are inherited from §"Concurrency contract" for free. - The carve-out is bounded to grammy. New channels (Slack, WhatsApp, …) will need a similar one-time review before adopting long-polling, and must not route through this code path; the
src/channels/<name>/folder is the seam.
Sessions
Telegram has its own dedicated session, persisted as a pointer in <stateDir>/telegram-session.json (telegram-session-pointer.ts). The TUI session and the Telegram session never collide. /new from Telegram rotates the pointer; the TUI's /new does not. The pointer file is the only Telegram-specific session metadata; everything else lives in the shared sessions.sqlite.
Approvals
When a runtime.runTurn call originated on Telegram ({ origin: "telegram" }), ApprovalRouter (src/approval/approval-router.ts) routes the ApprovalRequest to ApprovalBridge (approval-bridge.ts) instead of falling through to the host UI. The bridge:
- Sends a 2-button inline keyboard (
✅ Approve/❌ Deny) to the owner's DM as plain text (no MarkdownV2 — escaping rules are easy to get wrong with tool names containing backticks / underscores). - Validates the callback
userIdagainst the liveownerUserIdmirror — a stale callback from a previous owner is rejected. - Auto-denies after 8 minutes (
config.telegram.approvalTimeoutMs) and edits the original message to⏱ timed out — auto-deniedwith the buttons removed. - Folds button-click / timeout / external-cancel into a single
approvals.resolve()call; double-resolution is prevented by apendingmap check.
Known UX gap (deferred): /cancel aborts the turn but the inline-keyboard message lingers because the bridge does not know it was cancelled externally. Documented inline in approval-bridge.ts.
Task reports
TelegramChannel.sendTaskReport(report) posts a scheduled task's terminal outcome to the paired owner's DM — the delivery half of §"Background autonomy" → "Telegram reports for scheduled tasks (notify)". Rendered by task-report-message.ts (truncation is surrogate-safe: excerpts are cut on code-point boundaries, same walk as chunkUtf16; the maximal report is pinned to fit one outbound chunk), sent through the same sendOutbound chunking / 429-retry path as replies, always plain text (a report is channel infrastructure and its error excerpts may carry < / &). Never throws: every outcome is a TaskReportDelivery value (sent | queued | channel_not_up | not_paired | delivery_failed). Not-up with a token configured queues into a bounded FIFO (TASK_REPORT_QUEUE_LIMIT = 20, oldest evicted with a warning) that transition("up") flushes fire-and-forget; the queue survives stop() so a later restart still delivers, and is deliberately not persisted (a report is a courtesy notification — the task row keeps the authoritative outcome). Any dropped chunk resolves delivery_failed with delivered/total counts in the warn, never a silent partial sent. The static TelegramChannel.buildTaskReportSink({ resolveChannel, logger }) is the TaskRunner.reportSink wired in bootstrap; it warn-logs every dropping outcome and stays silent on sent / queued — pinned by telegram-channel.test.ts. Each fully delivered report counts one messages_sent.
Live control
TelegramChannel exposes a small live-control API used by the TUI panel and /telegram slash commands:
setEnabled(enabled)— flipsconfig.telegram.enabledinconfig.jsonand starts/stops the channel.setOwnerUserId(id | null)— updates the live mirror +config.json; restarts when the channel isupso inbound-handler / approval-bridge re-capture the new value.setToken(token | null)— writesTELEGRAM_BOT_TOKENinto<stateDir>/.envvia dotenv-writer.ts (atomic, mode0600, never logged), mirrors intoprocess.env, and restarts whenup.restart()— clean stop + start; useful to reload a token without flippingenabled.startPairing(timeoutMs?)/cancelPairing()— opens a 60s window where the first eligible private DM claims ownership (pairing-mode.ts). Only allowed when the channel isup. Inbound handler callstryClaimForPairingbefore the owner check so an unowned bot can be paired.
Persistence is split: enabled and ownerUserId live in <stateDir>/config.json; the token lives only in <stateDir>/.env. The token is never copied into config, never echoed in TUI, and never logged on error paths (errors are scrubbed via scrubErrorMessage in telegram-channel-types.ts).
TUI panel
The "Telegram" tab in atomic-agent tui mirrors the channel state and exposes the live-control API. Architecture matches the existing Tasks / Skills tab pattern (see §"TUI surface (Tasks tab)"):
- tui-telegram-orchestrator.ts is the only TUI module that imports
TelegramChannelor readsprocess.env.TELEGRAM_BOT_TOKEN. The token never leaves this file:setTokencalls into the channel by value; the UI mirrors its presence as ahasToken: boolean. - The reducer (telegram-panel-reducer.ts) is pure; every side effect (channel calls, persistence, timers) lives in the orchestrator.
- The pairing countdown ticker is owned by the orchestrator and is cleared on shutdown / resolution / dismissal. Pinned by tui-telegram-orchestrator.test.ts.
Slash commands: /telegram enable|disable, /telegram start|stop (alias for the same), /telegram restart, /telegram pair, /telegram token (opens the masked modal), /telegram clear-token, /telegram clear-owner. The e / t / o hotkeys mirror enable-toggle / token-prompt / pairing.
Configuration
config.telegram (user config, v10) — see src/config/config-schema.ts:
telegram.enabled(defaultfalse) — master switch forstart().telegram.ownerUserId(defaultnull) — numeric Telegram user id authorised to send DMs to the bot. Whennull, the bot ignores all messages and approvals are dropped.telegram.parseMode(default"html") — agent-reply rendering mode."html"converts the agent's markdown to Telegram's HTML subset viaconvertMarkdownToTelegramHtmland sends withparse_mode: "HTML"+disable_web_page_preview: true; on HTTP 400 ("can't parse entities") the same chunk is retried once as plain text so a formatter regression never silently swallows a reply."plain"disables formatting entirely (legacy behaviour). Slash-command acks, infra messages (Turn cancelled.,(no reply)), and failure envelopes (Turn failed [...]: ...) always send as plain text regardless — only "agent content" is formatted, "channel infrastructure" stays unformatted so a stray<in an error message can never collide with the HTML grammar. MarkdownV2 is intentionally unsupported (escape surface too wide for typical LLM output). Added in config v10; older files transparently get"html".telegram.approvalTimeoutMs(default480000= 8 min) —ApprovalBridgeauto-deny window.
TELEGRAM_BOT_TOKEN (env / <stateDir>/.env) — bot token. Stored only in .env with mode 0600; never copied into config.json or logged.
Metrics
- Counters:
agent.telegram.up,agent.telegram.down(tagged byoutcome+ shortreason),agent.telegram.messages_received,agent.telegram.messages_sent,agent.telegram.approvals_resolved(tagged byresolver+approved). - The
messages_*counters track agent-visible inbound (post owner-check, post slash-command-shortcut) and one-per-logical-outbound-message (agent replies and task reports), not raw Telegram updates /sendMessagechunks.
Outbound formatting (HTML mode)
Agent replies are rendered as Telegram HTML by default (config v10, telegram.parseMode = "html"). The path is:
- markdown-to-html.ts is a pure converter that maps a deliberately narrow LLM-friendly markdown subset (headings →
<b>, bold/italic/strikethrough, inline + fenced code, links, lists, blockquotes) into Telegram's HTML subset (<b>,<i>,<u>,<s>,<code>,<pre>,<a>,<blockquote>). All non-tag text is HTML-escaped (<,>,&); linkhrefs are validated against an(https?|tg|mailto):allowlist so unsafe schemes degrade to escaped plain text instead of becoming<a>tags. - outbound-sender.ts
sendOutboundacceptsparseMode: "plain" | "html". The chunker always operates on raw input text (line-break aware, identical to plain mode); the conversion runs per chunk so an unbalanced markdown delimiter that straddles a chunk boundary degrades to literal text rather than producing a corrupt HTML tag. HTML chunks are sent withparse_mode: "HTML"anddisable_web_page_preview: true(so a stray bare URL in the agent's reply does not unfurl an unwanted preview card). - On HTTP 400 with a "can't parse entities" / "unsupported start tag" / "can't find end of" description, the same chunk is retried once as plain text and the
parseFallbackscounter onOutboundSendResultincrements. Other 400s drop the chunk like any non-429 error. This is the load-bearing safety net: a formatter regression never silently swallows a reply.
Locked invariants
Pinned by src/runtime/bootstrap.test.ts, src/channels/telegram/telegram-channel.test.ts, src/channels/telegram/inbound-handler.test.ts, src/channels/telegram/approval-bridge.test.ts, src/channels/telegram/pairing-mode.test.ts, src/channels/telegram/markdown-to-html.test.ts, src/channels/telegram/outbound-sender.test.ts, src/config/dotenv-writer.test.ts, src/tui/telegram/telegram-panel-reducer.test.ts, src/tui/telegram/tui-telegram-orchestrator.test.ts:
- Polling carve-out is scoped to grammy.
setInterval/ long-polling outsidetelegram-bot-factory.tsis forbidden insrc/channels/telegram/. Other channels must repeat the carve-out review. - Telegram updates always go through
runtime.runTurn. Never directly intoSessionStore,ApprovalGate, orTurnController. - The token never leaves
src/channels/telegram/or tui-telegram-orchestrator.ts. UI state mirrors only the booleanhasToken; reducer actions never carry the value. Errors are scrubbed. TelegramChannelis always constructed when thetelegramconfig block exists. Bootstrap setsruntime.telegramChannelregardless ofenabled; the live-control API stays callable from the TUI without a host restart.- Pairing bypasses the owner check. Inbound handler calls
tryClaimForPairingbefore filtering byownerUserId— the only path where a non-owner DM is allowed to claim ownership. - Approval routing is per-session.
ApprovalRouter.setForSession(sessionId, handler)binds the Telegram session id toApprovalBridge; everyone else falls through to the host UI handler. A fallback collision between two channels on the same session is intentionally not supported. grammyis imported from one file only. telegram-bot-factory.ts. Future replacement of the Telegram client touches one file.- Only agent replies are formatted. Slash-command acks (
/help,/status,/new,/cancel), infra messages (Turn cancelled.,(no reply)), failure envelopes (Turn failed [<category>]: ...), pairing welcomes, task reports (sendTaskReport), and approval-keyboard text always send as plain text regardless oftelegram.parseMode. The carve-out keeps a stray<in an error message from colliding with the HTML grammar and keeps the operator's mental model clean: formatted text == agent content, plain text == channel infrastructure. - HTML conversion is tag-allowlisted.
convertMarkdownToTelegramHtmlonly emits tags from{b, i, u, s, code, pre, a, blockquote}and only(https?|tg|mailto):schemes for<a href>. New tag emission requires extending both the converter and the allowlist in markdown-to-html.ts. - Plain-text fallback on HTTP 400 'can't parse entities'. When
parseMode === "html", a parse-rejected chunk is retried once withparse_modestripped and the original raw markdown body — not the formatted HTML — so the operator sees the LLM's intent instead of the broken tags.parseFallbacksis surfaced separately fromdroppedfor metrics + regression detection.
Out of scope (deferred)
Multi-user pairing flows, per-chat session isolation, MarkdownV2 rendering (the escape surface is too wide for typical LLM output and parse_mode: "MarkdownV2" rejects the whole message on a single stray reserved char — see §"Outbound formatting"), structured menu / command surfaces beyond plain text, message editing for streaming output, file uploads (image / document ingestion through Telegram), webhook ingress as a Telegram-specific endpoint (the generic /api/webhooks/:name path is the existing surface), mid-run progress updates and an approval relay for unattended scheduled runs (task reports are terminal-only — see §"Background autonomy"), and a generic Channel abstraction (Slack / WhatsApp adapters) are all deferred. The seam is src/channels/<name>/; only extract shared interfaces when a second concrete channel actually lands.
MCP client
atomic-agent is an MCP (Model Context Protocol) client that connects to external MCP servers and surfaces their tools / resources / prompts to the agent through the existing ToolRegistry. The runtime never exposes an MCP server interface itself — atomic-agent is a consumer, not a producer. Code lives in src/mcp/; the cold-path wiring is in src/runtime/bootstrap.ts right after vision + before the descriptor filter / grammar build.
Why MCP
Every MCP server adds a bounded, declarative surface of capabilities (tools / resources / prompts) without forcing the runtime to grow another bespoke integration. The model picks them by qualified name (mcp.<server>.<tool>); per-server trust levels decide how the runtime batches and approves calls; tools are loaded into the prompt at tier frequent so the model sees their full argsSchema in the stable prefix and can call them on the first shot without an intervening tool.view. The historical rare-tier rendering surfaced only one-line stubs under # extras, which in practice meant smaller local models almost never invoked MCP tools at all — visible but practically unusable without a discovery hop the model rarely thought to take. The trade-off is paid as a one-time bump in stable-prefix tokens proportional to the connected MCP catalog; operators wiring up chatty / untrusted servers should weigh that explicitly.
Lifecycle
McpManager is constructed unconditionally at bootstrap (mirrors the Telegram channel pattern) — even when config.mcp.servers[] is empty — so any future live-control surface stays uniform. An empty configuration produces a zero-cost no-op manager: start() returns immediately, no resolver is installed, no aggregate tools are registered.
When at least one server is configured, bootstrap:
- Constructs the manager.
- Awaits
mcpManager.start()— eachMcpClientopens its transport with a hard 15s connect timeout (DEFAULT_CONNECT_TIMEOUT_MSinmcp-client.ts), runs the MCPinitializehandshake, installs the optional sampling handler, and refreshes the catalog. Failures are isolated per-server; one bad config never blocks bootstrap. - Registers the aggregate
mcp.resource.{list,read}andmcp.prompt.{list,get}tools — single per-runtime registration; the tools dispatch by theserverarg rather than producing one tool per server (the prompt would explode). - Builds
McpToolDescriptor[]from every connected catalog and merges them intoeffectiveToolDescriptorsviamergeMcpDescriptors— MCP descriptors land at the end of the prompt's### toolsblock at tierfrequent(fullargsSchemain# common (full)). - Applies the dynamic
mcp-server-toolGBNF rule viaapplyMcpToolNameRule(grammar, buildMcpToolNameRule(metas)). The staticgrammars/tool-call.gbnffile ships a permissive placeholder; the runtime replaces it with an alternation of the actual qualified names so the LLM can only emit tools that resolve to a registeredToolDefinition.
shutdown() closes every McpClient (best-effort, errors swallowed), tears down every registered tool, and clears the dynamic resource-class resolver. The MCP shutdown step runs between Telegram channel shutdown and browser/SQLite teardown so in-flight sampling calls have a chance to drain before the LLM client is torn down.
Module map
| File | Responsibility |
|---|---|
| mcp-types.ts | Neutral type shapes: McpServerConfig, McpToolMeta, McpResourceMeta, McpPromptMeta, McpServerStatus, McpTrustLevel. No SDK objects leak out of mcp-client.ts. |
| mcp-errors.ts | McpError / McpConnectError / McpRequestError hierarchy + scrubErrorMessage. Every user-facing surface (status badge, log, TUI label) goes through the scrubber. |
| mcp-resource-class.ts | qualifyMcpToolName / splitMcpToolName + createMcpResourceClassResolver (the per-server trust → ResourceClass mapper). |
| mcp-client.ts | The only file that imports @modelcontextprotocol/sdk. Wraps Client + the three transports (stdio / streamable_http / sse). Owns connect / refresh / RPC / close. |
| mcp-sampling-handler.ts | Routes sampling/createMessage from MCP servers to LlamaServerClient.complete with slotId: -1 + cachePrompt: false. Also imports the SDK for the CreateMessageRequest / CreateMessageResult shapes; no other module needs them. |
| mcp-tool-adapter.ts | createMcpToolDefinition(meta, client, gate?) — wraps an MCP tool as a ToolDefinition for the registry. Routes gated calls through requireApproval before tools/call (see the trust table below). Projects the heterogenous MCP response into a single output string + structured details; folds errors into status: "error" so siblings inside a batch keep running (approval denials are stamped details.approvalDenied). |
| mcp-manager.ts | One McpClient per server, lifecycle, status sink, tool register/unregister, dynamic resolver install/clear. |
| mcp-descriptor-builder.ts | buildMcpToolDescriptor + buildMcpToolDescriptors + mergeMcpDescriptors. Every MCP tool ships at tier frequent (full schema in the stable prefix — discoverability over prefix size). Server-then-tool alphabetical sort → deterministic stable-prefix bytes. |
| mcp-grammar-builder.ts | buildMcpToolNameRule + applyMcpToolNameRule. Pure functions; deterministic sort + dedup ensure byte-stable output. |
| mcp-resource-tools.ts / mcp-prompt-tools.ts | Aggregate read-only tools (mcp.resource.list, mcp.resource.read, mcp.prompt.list, mcp.prompt.get). Dispatch by server arg. Resource class pure_read. |
Resource classification
Every MCP-namespaced tool resolves to a ResourceClass through the dynamic resolver installed by McpManager.ensureResolver(). The resolver is the only non-static path through resourceClassFor (see setDynamicResourceClassResolver in src/agent/tool-resource-class.ts).
Trust level on McpServerConfig | Resolved class | Behaviour |
|---|---|---|
| (default / omitted) | approval_gated | Every call routes through the approval gate (category other) and cannot appear in multi-call batches. Tools whose discovery-time annotations.readOnlyHint is exactly true skip the prompt; missing / malformed / non-boolean annotations fail closed to gated. Safe for arbitrary third-party servers. |
pure_read | pure_read | Fans out in parallel inside batches alongside os.fs.read / os.git.*. Opt-in only. Use for servers you trust never to mutate any state. |
The aggregate native tools (mcp.resource.*, mcp.prompt.*) are classified as pure_read in the static TOOL_RESOURCE_CLASS table — they never mutate state regardless of which server they dispatch to.
Sampling forwarding
MCP servers can request the client's LLM to generate text on their behalf via sampling/createMessage. atomic-agent forwards those requests to the same LlamaServerClient that drives the agent loop — but always on slotId: -1. This is the load-bearing safety invariant: the main agent slot and the reflection slot are never touched by MCP traffic. A misbehaving MCP server cannot evict the agent's KV cache mid-turn.
The handler is fire-safe — every error is folded into a thrown Error that the SDK converts into a JSON-RPC error sent back to the server. We never crash the client transport. No grammar is attached; MCP sampling is free-form text and the server owns any structure it expects.
Configuration (config.mcp.servers[])
User config v23. Older files transparently migrate by filling mcp: { servers: [] }. Per-server entries:
{
name: "github", // kebab-case, max 32 chars, MCP_SERVER_NAME_RE
description?: "GitHub API", // free-form one-liner for TUI / logs
enabled: true, // disabled servers are constructed but never connected
transport: { // stdio / streamable_http / sse
kind: "stdio",
command: "npx",
args: ["-y", "@github/mcp-server"],
cwd?: "/optional"
},
trust?: "pure_read", // default "approval_gated"
env?: { GITHUB_TOKEN: "..." } // overlays process.env for stdio transports only
}
Server name collisions are dropped at config parse time with a warning. Tool names from servers that fail MCP_TOOL_NAME_RE are silently dropped during catalog refresh — the agent never sees them.
Locked invariants
Pinned by mcp-client.test.ts (when added), mcp-manager.test.ts, mcp-resource-class.test.ts, mcp-grammar-builder.test.ts, mcp-descriptor-builder.test.ts, mcp-tool-adapter.test.ts, mcp-sampling-handler.test.ts, mcp-resource-tools.test.ts, mcp-prompt-tools.test.ts, and the bootstrap descriptor-filter test filter-disabled-tools.test.ts:
@modelcontextprotocol/sdkis imported from exactly two files — mcp-client.ts and mcp-sampling-handler.ts (the latter only forCreateMessageRequest/CreateMessageResulttype shapes). Everything else operates on the neutral types inmcp-types.ts. Future replacement of the SDK touches these two files only.- Sampling always uses
slotId: -1. The main agent slot and the reflection slot are off-limits to MCP traffic.cachePrompt: falseis set unconditionally. Pinned bymcp-sampling-handler.test.ts > "INVARIANT 1 — always uses slotId: -1 and disables cache_prompt". - Tool failure isolation. A failed MCP call lands as
CompressedToolResult { status: "error" }. Never throws out ofToolDefinition.run. Per-call failures inside a batch never abort siblings. - Per-server failure isolation at connect. One bad server config does not block bootstrap; its status flips to
downand the rest keep running. Pinned bymcp-manager.test.ts > "start() isolates per-server failures". - MCP tools are always tier
frequent. The fullargsSchemais rendered into# common (full)of the stable prefix so the model can invoke them on the first shot without atool.viewround-trip. This inverts the historicalrarerendering — see the module-level comment inmcp-descriptor-builder.tsfor the trade-off (one-time stable-prefix token bump in exchange for actual MCP usability on small local models). The summary stays[mcp:<server>] <description>regardless of tier. Pinned bymcp-descriptor-builder.test.ts > "ships every MCP tool as tier 'frequent' so the full schema lands in the stable prefix". - Deterministic descriptor and grammar ordering.
buildMcpToolDescriptorssorts by(server, rawName)andbuildMcpToolNameRulesorts + dedups the qualified names. Same input → byte-identical output → KV-cache stability across runtime restarts with the same config. - Resource-class default is
approval_gated. Unknown servers and missing trust entries both resolve toapproval_gatedso a stale model-emitted MCP name lands in the safe lane and is rejected byregistry.invokerather than escaping into apure_readbatch. Pinned bymcp-resource-class.test.ts > "returns approval_gated for unknown servers (safe default — never null)". - Dynamic resolver is installed by the manager and torn down on
shutdown(). Pinned bymcp-manager.test.ts > "shutdown() closes all clients and clears the resolver". - Aggregate native tools (
mcp.resource.*,mcp.prompt.*) arepure_readand dispatch byserverarg. Single per-runtime registration; never one tool per server. Filter gatemcp: { enabled }infilter-disabled-tools.tsdrops the descriptors from the prompt when no servers are configured. Pinned byfilter-disabled-tools.test.ts > "drops every mcp.* aggregate descriptor when mcp is disabled". - GBNF placeholder fallback. When
applyMcpToolNameRuleis called with anullrule (no MCP tools registered), the grammar is returned unchanged — the static permissive placeholdermcp-server-tool ::= "\"mcp." [a-z0-9-]+ "." [a-zA-Z0-9._-]+ "\""stays in place. The model will not generate such a name in practice (no descriptor advertises it), but the grammar fails open rather than fails closed. McpManageris always constructed. Bootstrap creates it even whenconfig.mcp.servers[]is empty so the live-control surface (TUI MCP panel) can mutate without restarting the host. Empty configuration is a zero-cost no-op.- MCP
shutdown()runs before browser / SQLite teardown. Order: Telegram → MCP → browser → SQLite. In-flight sampling calls have a chance to drain before the LLM client is torn down. - Variant γ — live add / remove without restart.
McpManager.addServerLive(config)connects a new client, registers its tools intoToolRegistry, and returns the freshly-discovered tool metas.McpManager.removeServerLive(name)disconnects, unregisters tools, and drops the entry. Both are idempotent on the server name (duplicate add or absent remove short-circuit without error). The TUI MCP orchestrator pairs these calls withruntime.refreshMcp()so the GBNF grammar and the prompt's### toolscatalog are rebuilt from the live manager state, and the model sees the new / dropped qualified names on the next inference. KV-cache invalidation on the stable prefix is intentional and one-shot — semantically equivalent to a runtime restart, just without the process churn. The four MCP meta-tools (mcp.resources.{list,read}/mcp.prompts.{list,get}) are registered on demand the first time a server lands so a zero-server cold start does not pollute the descriptor catalog. Pinned bymcp-manager.test.ts > "variant γ — live add / remove"(6 cases: brand-new connect + tool registration, duplicate-name short-circuit, connect failure isolation, disconnect + tool unregister, absent-name short-circuit, round-trip idempotency). approval_gatedtrust means the approval gate, not just solo execution. A tool from a server at the default trust callsrequireApproval(categoryother, the sharedDangerousToolOptionsfrom bootstrap) beforetools/call; a denial folds intostatus: "error"withdetails.approvalDeniedand the server is never contacted. Exemption is decided once at registration from the discovery-time annotations — a strict booleanreadOnlyHint === trueand nothing else — so a hostile server can lie only about its own calls on a server the operator already chose to gate, and the descriptor bytes (stable prefix / KV cache) are untouched.pure_readtrust bypasses the gate. Pinned bymcp-tool-adapter.test.ts > "approval gating"andmcp-manager.test.ts > "passes deps.dangerous + resolved trust through to registered tools".
Out of scope (deferred)
notifications/tools/list_changed subscriptions (the SDK supports them; current phase ships catalog snapshots only — refreshMcp() is the manual / TUI-driven equivalent). Per-tool ACL (today trust is server-level; finer-grained allowlisting of specific tools within a server is deferred). MCP roots capability (the agent does not declare a workspace root to servers — every stdio server inherits the agent's CWD via McpStdioTransport.cwd). MCP elicitation (interactive multi-turn prompts from servers — out of scope for this milestone). Live mutation of mcp.servers[i].enabled from the TUI without an addServerLive / removeServerLive round trip (today setServerEnabled is reachable on the manager but the TUI does not surface it — only persist-driven add / remove is wired). Sampling-handler reattachment on the first live-added server (today sampling capability is decided at construction time; a server added live in a fresh-start-with-zero-servers process has no sampling handler — operators who need sampling should configure at least one server up front, or accept a restart). Server-side MCP exposure (atomic-agent is client-only and will stay that way — the runtime's tool surface is too coupled to local file system / browser / approval gating to safely advertise over MCP to external clients).
TUI surface (Manage tab — Mcp panel)
Lives under src/tui/mcp/ and src/tui/components/mcp-*.tsx. Architecture mirrors the existing LocalModels / Tasks / Memory / Skills panel pattern — pure state slice + reducer, an orchestrator that is the only TUI module that touches runtime.mcpManager / runtime.refreshMcp / persistMcpServer / removeMcpServer, dedicated key bindings.
Hotkeys (list mode): j/k move cursor · Enter open detail · n add (JSON paste modal) · d remove (confirm modal) · r refresh · a toggle auto-refresh. Detail mode adds 1/2/3 tab switch, [/] cycle, d remove the open server. The add-server modal accepts both the bare {name, transport: {...}} shape and shortcut envelopes used by Claude Desktop / Cursor / Smithery / DeepWiki ({mcpServers: {<name>: {command, args}}} for stdio, {url} / {serverUrl} for HTTP, {type: "sse"} for SSE). Multi-line paste is auto-flattened by the reducer (\n / \r / \t → spaces) so the editor does not grow vertically. The remove-confirm modal claims y/Enter and n/Esc; the submitting flag guards against double-fire. Slash-command surface: /mcp opens the tab, /mcp add opens the add modal, /mcp remove <name> opens the remove confirm directly.
Locked invariants (pinned by src/tui/mcp/mcp-reducer.test.ts, src/tui/persist-mcp-server.test.ts):
McpOrchestratoris the only TUI module that touchesruntime.mcpManager,runtime.refreshMcp,persistMcpServer,removeMcpServer. Components dispatch actions; the orchestrator owns the polling loop, snapshots manager state, drives variant γ live mutations, and emits typed actions onto the shared bus.- Live config reads via
getConfig()— neverruntime.config.runtime.configis a frozen bootstrap snapshot;persistMcpServer/removeMcpServercallresetConfigCache()so the nextgetConfig()returns the fresh server list. The orchestrator'sbuildRows/buildDetailgo throughreadConfiguredServers()so adds and removes appear in the panel immediately. - Persist before live mutation.
addServerFromJsonwritesconfig.jsonfirst, then callsmcpManager.addServerLive+runtime.refreshMcp. Live-connect failures degrade gracefully: the server stays in config and shows asdown, the operator gets aruntime_infohint, and a restart will retry the connect.removeServerFromJsonmirrors the symmetry — persist first, thenremoveServerLive+refreshMcp. - The editor is disabled on the MCP tab while a modal is open.
mcpTabBusyinapp-key-bindings.tscovers bothaddModal !== null(lets theMultiLineEditorcapture every keystroke) andremoveConfirm !== null(claims they/nconfirmation keys against the global nav cycler). - Variant γ surface is opt-in but on by default. Restarting the runtime is no longer required after add/remove — the prompt's
### toolscatalog and GBNF grammar are rebuilt on the next step. KV-cache for in-flight sessions is invalidated once per add/remove (the persona stays byte-stable; only the rendered tools block changes).
Project path resolution (os.fs.locate_project)
Issue #77: users mention projects by name ("check my raylib project") instead of full paths. The read-only tool os.fs.locate_project { name, limit? } (src/tools/os/fs-locate-project.ts + fs-locate-project-sources.ts, registered with the other OS tools in registerOsTools — bootstrap constructs the SessionStore first and supplies the column-only projection through RegisterOsToolsOptions.listRecentSessionDirs) first takes a direct-path fast path — a pasted absolute path (e:/_raylib, ~/dev/app) that exists as a directory is returned immediately (source direct-path) — and otherwise resolves the mention against three bounded sources, in priority order. The name argument is a short folder-name segment (the descriptor + examples teach the model to pass raylib, never the whole sentence — matching is per-basename, not phrase-tokenized):
cwd— the session working directory and its ancestors (basename match, bounded by path depth).session-history— working dirs of recent sessions viaSessionStore.listRecentWorkingDirs, a column-only projection (working_dir,updated_at) that never deserialises session payloads on this read path; stat-checked before use so deleted dirs never surface.configured-root— the user-declaredprojects.rootsdirs (config v36, default[]) and their direct children: ONE level, directories only, hidden names /node_modules/__pycache__/ symlinks skipped. The listing streams viaopendirand stops as soon as 500 ELIGIBLE dirs were seen, so loose files never consume the cap and a pathological root is never fully materialised.ToolContext.signalaborts between roots, between entries, and between the later stat/realpath batches.
Matching is case-insensitive and NFC-normalized on the basename with three tiers (exact > prefix > substring); a pasted fragment matches by its last segment with \ normalized to / first. Candidate paths are canonicalized through realpath before dedup so symlink aliases of one dir (macOS /tmp vs /private/tmp) never read as a false ambiguity. Exactly one best-tier candidate ⇒ resolved path plus any weaker matches listed. Two or more best-tier candidates ⇒ the result says ambiguous and instructs the model to ask the user, never to guess. Zero ⇒ an honest miss that tells the model to ask for the full path or to suggest adding a parent dir to projects.roots.
Locked invariants (pinned by src/tools/os/fs-locate-project.test.ts, src/session/session-store.test.ts, and src/config/config-schema.test.ts):
- No disk-wide scanning, ever. The search space is the pasted path itself plus the three sources above; roots are scanned one level deep with no recursion. Empty
projects.roots(the default) means nothing outside session history and the cwd chain is touched. - Ambiguity is surfaced, not resolved. Ties at the best match tier produce a candidate list and an ask-the-user instruction;
details.foundstaysfalse. The verdict is computed on the full match list, so a smalllimitcannot fake confidence. - A miss is honest, and honesty lives in the summary. The miss text reports how many roots were actually scanned (never the configured count), and skipped / unreadable / truncated roots are repeated as
note:lines in the output text — the model only ever sees the compressed summary (toolResultTurncarries nodetails), so details-only reporting would be invisible. - Hidden dirs and dependency caches never match, root scanning stops at one level, and the per-root cap applies after directory filtering.
- Resource class
pure_read, descriptor tierfrequent, segment-only teaching. Fans out inside batches; the full args schema plusexamples({"name":"raylib"}) sit in the stable prefix so small local models can call it first-shot with a folder-name segment instead of copying a phrase (same rationale as the MCPfrequentdecision). Pinned by theis pinned pure_read and frequent-tieranddescriptor teaches a segment-only nametests. - The session-store read path is column-only.
listRecentWorkingDirsis a preparedSELECT working_dir, updated_at— no per-rowJSON.parseon a tool that can appear 16 times in one batch.
Configuration: projects.roots: string[] in the user config file (v36, transparent migration; ~ expansion supported). Empty or non-absolute entries are skipped and reported (summary note + details.invalidRoots); unreadable roots land in details.unreadableRoots and the summary.
Deliberately out of scope: an opt-in whole-disk / drive index (the issue sketches it as a possible extra for people who want it — revisit only on real demand), fuzzy-distance matching, and per-root depth knobs.
LLM reliability policy
Two narrow retry layers sit between the agent loop and llama-server. Both are deliberately bounded and never replay already-executed tool calls:
- Parser retry (step-executor). If the first
parseToolCallon a completion throws, the executor calls the unaryllmCompleteexactly once more with the same prompt/slot and re-parses. Aparse_retryevent is emitted for observability. If the second attempt also fails, the original error (with a raw-output preview) is thrown. The streaming path always falls back to unary for the retry so partial SSE deltas are not double-emitted. - Transport retry (LlamaServerClient).
complete()and the initial pre-body fetch ofcompleteStream()are wrapped in a bounded retry governed byllama.completionRetries(default 3) andllama.completionRetryBackoffMs(default 150ms, exponential with ±20% jitter). Retries fire only for network errors (LlamaServerError.status === null) and HTTP 5xx. Grammar/validation 4xx and abort signals short-circuit immediately. Once the SSE body starts streaming, no further retries happen — the conversation state on the server is considered indeterminate.
Failure taxonomy
Every terminal failure the agent loop surfaces is normalised into a canonical LlmFailureCategory before step_error / loop_failed fire. The classes live in src/llm/reliability/ and carry specialised fields for postmortem use.
| Category | Class | When it fires | Invariants |
|---|---|---|---|
transport | TransportError | LlamaServerError with status === null or status >= 500 (after the bounded retry is exhausted). | Carries status and url. Transport retries already fired; runtime does not retry again here. |
grammar | GrammarError | LlamaServerError 4xx, or ToolCallParseError that survives the one-shot parser retry. | Carries rawPreview of the completion body so postmortems can diagnose without replaying SSE. |
model | ModelError | detectModelFailure returns truncated / empty / no_stop on the initial or retry completion. | Never retried in-place — the same prompt would reproduce the same wall. Parser retry skipped. |
tool | ToolExecutionError | Tool missing from the registry, or any unexpected error escaping the tool dispatch path. | Carries tool name. Runtime tool failures from registry.invoke are folded into CompressedToolResult { status: "error" } and do not reach this category. |
cancelled | CancelledError | ctx.signal.aborted is true, or the caught error is an AbortError / message mentions aborted. | The agent loop closes the turn with reason: cancelled and status cancelled, not failed. |
classifyFailure(err) is the single source of truth for mapping raw thrown values into the taxonomy; LlmFailure instances short-circuit the classifier. step-executor wraps every escape with the correct subclass before the step_error event fires, and agent-loop classifies at the outer catch so loop_failed always carries category even for legacy errors.
Observability propagation
category is plumbed through every observability surface:
- Events.
step_error.categoryandloop_failed.categoryare mandatory fields on theAgentLoopEventunion. Theprovider_switchedvariant on the same union carries fallback-chain state changes (see §"Provider fallback chain"). - Traces.
TraceError.categoryon the append-only NDJSON stream (see src/tracing/trace/trace-event.ts). - Metrics.
AgentMetrics.recordLlmFailure({ sessionId, category })incrementsagent.llm.failuretagged by category — fired exactly once per failed turn from the agent-loop outer catch. - TUI.
agent-event-reducerrenders! [${category}] ${message}in the step feed andfailed [${category}]: ${message}in the run-status line. - Sidecar protocol.
session_failed.categoryanderror.code = step_error:<category>for the Tauri host. - OpenAI SSE. Atomic-extension clients receive
{ error, category }; OpenAI-compatible clients receiveerror.type = agent.<category>(thetypefield is a loose string in the OpenAI error envelope).
Provider fallback chain
A cross-provider circuit breaker layered above the single-provider reliability policy. Where the two retry layers above recover a request on the same provider, the fallback chain switches to a different configured provider when the active one is unavailable. It lives in src/llm/fallback/. The llmComplete / llmCompleteStream seams that wrap it are built by src/runtime/llm-fallback-seam.ts (createFallbackCompleter / createFallbackStreamer, injected with { fallbackChain, resolveSlice, recordUnaryUsage, recordStreamUsage }) and wired in src/runtime/bootstrap.ts — strictly after the per-provider retry budget (PR #90 runOpenAiWithRetry, LlamaServerClient.completionRetries) is spent, never inside it.
Chain unit and config
The chain is an ordered list of configured provider ids, primary first. CompletionRequest carries no per-request model field — a provider instance pins its model at construction (OpenAiProvider.defaultChatModel), so a model@provider pair maps to one provider id in the registry; two models on the same upstream service are two provider entries. Config lives under llm.fallback:
"fallback": {
"chain": ["openrouter-gpt", "groq-llama"], // ordered provider ids; every id must be configured
"appendLocal": true, // default true: append the llama-server provider id to the tail
"failureThreshold": 3, // consecutive non-immediate failures before switching
"cooldownMs": [30000, 60000, 300000], // escalating ladder (must be non-decreasing); last entry is the cap
"probeThrottleMs": 300000, // min gap between primary probes
"failureWindowMs": 86400000 // no-error window that resets the counter + ladder step
}
chaindefaults to[activeTextProvider]when absent. Unknown ids are rejected at config parse time (parseLlmFallbackConfig), and defensively dropped again at resolve time.- The active text provider is always the primary —
resolveFallbackChainhoists it to the head, so a TUI hot-swap re-primes the chain and drops any active override without editingfallback.chain. appendLocalauto-appends the configuredllama-server-kind provider id to the tail (unless already present, or none is configured, or the flag isfalse).
Reset policy (circuit breaker, per provider id, per session)
- Which failures advance is decided by
shouldAdvance(err)from the reliability taxonomy:transportandmodelcategories advance (provider unreachable or model dead);grammar/tool/cancellednever advance (same request fails identically everywhere, or it is our bug / a user abort). One centralized predicate — both wrappers route through it. - Immediate signals (429 / 408 / any 5xx / network-null that is not our own request timeout) switch on the first occurrence, bypassing the threshold. All other advance-worthy failures increment a consecutive-failure counter and switch at
failureThreshold(default 3). "Our own request timeout" covers both transports symmetrically —OpenAiHttpError.timedOutandLlamaServerError.timedOut(both surface asstatus === null) are weak evidence (one slow turn, not a down provider) and only count toward the threshold; a local timeout in particular must not switch immediately, since replaying it burns another full timeout of GPU time. - Per-session isolation. One shared
ProviderFallbackChainserves the main loop and every sub-runner (reflection, link-gen, vote, distill) across all concurrent sessions, so its mutable breaker state (overrideId, per-provider failure counters, cooldown ladder, probe throttle) is partitioned by session id (runWithFallback(chain, attempt, sessionId)). One session's success never clears another's armed cooldown, and their failure counters never cross-contaminate. A keyless call shares one default partition (back-compat for tests / non-session callers). - Sticky. After switching, a per-partition
overrideIdkeeps subsequent turns on the working provider — the dead primary is not retried every turn. - Escalating cooldown on the failed provider:
30s → 60s → 300s(cap), stepped each time it fails again while tripped. The counter and ladder step reset afterfailureWindowMs(24h) with no new failure, checked lazily on next access. - Probe. When on an override and the primary's cooldown has elapsed and
probeThrottleMs(5 min) has passed since the last probe, the next turn is routed back to the primary as a throttled probe. On success: clear the override, reset the breaker, emit a one-shot "switched back" notice. On failure: re-arm (escalate) the cooldown and stay on the override. - Notifications ride a new
AgentLoopEvent,{ type: "provider_switched"; direction: "away" | "back"; from; to; reason }, emitted through the sameturnController.emitpath as every other loop event — at most one per state transition (never on sticky turns). Bootstrap wires the chain'snoticeSinktoemitAgentLoopEvent.
No new timer (invariant)
The probe is lazy / turn-boundary driven — pickProvider() reads Date.now() at the start of each completion and decides whether this turn probes. There is no setInterval: this respects the §"Background autonomy" invariant that Scheduler (plus the two documented carve-outs) is the only periodic timer in the runtime. If no turns arrive, no probe happens, which is correct — there is nothing to serve anyway. Same shape as the loop-detector per-turn check.
Streaming
llmCompleteStream primes the first chunk (primeStream) inside the fallback attempt so a failure to open the stream (429/5xx before any output) advances the chain, while a stream that has begun emitting is never restarted (mirrors the openai-http "stream is live" contract). Later failures propagate as-is.
Cross-transport fallover (request AND response)
A fallover can cross transports — the common cloud (native_tools) → local (grammar) default (appendLocal) does exactly that. Both the request shape and the response parse are decoupled from the primary:
- Request. Each attempt re-resolves
{ transport, adapter }for the chosen link viaresolveActiveLlmSlice(providerId), so the wire shape is correct for whoever serves.buildLlmStreamParamskeepsgrammarpopulated even on the native path (it used to blank it) so a grammar-only link handed the request still has its GBNF; native providers ignoregrammarand readtools, so carrying both is safe. On a think-tag profile the prompt shape is per-link too: the main prompt for a native-tools primary is built prefill-suppressed (issue #283 — a literal<think>shipped to a chat endpoint is at best noise, at worst corrupted server-side), andLlmStreamParams.grammarPromptcarries a lazy, memoized prefill-carrying variant that the seams substitute for grammar links (promptFor), so a llama-server link still gets the shape its template + GBNF prelude expect. The one-shot repair retry rebuilds both variants repair-shaped. - Response. The completion is stamped with
servedTransport— the transport of the link that actually answered — by the fallback seams in src/runtime/llm-fallback-seam.ts (createFallbackCompleter/createFallbackStreamer).step-executor.parseDepsFor(completion, deps)prefersservedTransportover the caller's configuredtoolTransportfor every parse decision (tryParseToolCalls, the empty-completion recovery gate). Without this, a native primary that fell over to a grammar link parsed the grammar reply as OpenAItool_callsand silently broke tool-calling. The streamer additionally stampsservedTransporton every chunk — the return-value stamp only exists after the last delta, which is too late for the live stream parser:consumeStreamcreates its parser lazily off the first chunk's stamp so a grammar-served stream (whose GBNF output starts mid-<think>) keeps emitting livereasoning_deltas under a native primary.completionAssumesOpenReasoningkeys purely off the served/parse transport: grammar-served output always continues an open think block; a chat completion never does (so even in the unsupported grammar-primary → native-link ordering a clean chat reply is no longer swallowed whole as reasoning). The stamps are pinned directly on the real seam factories by src/runtime/llm-fallback-seam.test.ts (deleting either stamp turns it red) and end-to-end through the loop by src/llm/fallback/fallback-e2e.integration.test.ts, including think-tag profile cases in both directions of the parse decision.
The remaining asymmetry: tools is populated only when the primary's transport is native_tools. Placing a native-tools provider below a grammar-only primary would reach it without a tools payload — an unusual ordering; order native-tools links at or above the first grammar-only link. Slot affinity is decided pre-request from the primary, so a cloud→local fallover runs the local link without slot-cache reuse for that turn (correctness-neutral). The grammar string itself is always built for the primary model.
Locked invariants (Pinned by tests)
- 429 / 5xx switches on the first failure; non-immediate transport failures switch at the threshold. A self-inflicted request timeout on either transport (
OpenAiHttpError/LlamaServerErrortimedOut) is non-immediate and only counts toward the threshold. Pinned by src/llm/fallback/provider-fallback-chain.test.ts, src/llm/fallback/should-advance.test.ts. - Sticky — the dead primary is not re-picked every turn; only a throttled probe returns to it. Pinned by src/llm/fallback/provider-fallback-chain.test.ts, src/llm/fallback/run-with-fallback.test.ts.
- Cooldown escalates 30/60/300 and caps at 300s. Pinned by src/llm/fallback/provider-fallback-chain.test.ts.
grammar/tool/cancellednever advance. Pinned by src/llm/fallback/should-advance.test.ts, src/llm/fallback/run-with-fallback.test.ts.- Whole-chain exhaustion rethrows the last (already-humanized) error so
loop_failedclassification is unchanged. Pinned by src/llm/fallback/run-with-fallback.test.ts. - Exactly one switch notice per state transition (away / back), none on sticky turns. Pinned by src/llm/fallback/provider-fallback-chain.test.ts.
appendLocalappends the local provider when configured, nothing when not. Pinned by src/llm/fallback/fallback-config.test.ts.- A cross-transport fallover parses the response with the served link's transport, not the primary's, and the turn reaches the fallback's answer instead of
loop_failed. On a think-tag profile the grammar link also receives the prefill-carryinggrammarPromptvariant and its streamed reasoning stays classified live (per-chunkservedTransportstamp). Pinned by src/llm/fallback/fallback-e2e.integration.test.ts (realAgentLoop+step-executor+ the real seam factories; both unary and streaming, plain and think-tag profiles). - Breaker state is partitioned by session — one partition's success does not clear another's armed cooldown, and a keyless call shares one default partition. Pinned by src/llm/fallback/provider-fallback-chain.test.ts ("partition isolation").
- The cooldown ladder must be non-decreasing — a decreasing
cooldownMsis rejected at parse time so "escalating" stays true. Pinned by src/config/llm-config.test.ts.
TUI: the Fallback pane
The LLM tab gains a fourth pane, fallback, reached with ←/→ after Local / Cloud / External (src/tui/llm-panel/llm-panel-state.ts LLM_PANEL_MODES). It is the operator surface for llm.fallback — the same config the engine reads, edited through the same read → mutate → writeUserConfigFileSync → resetConfigCache path as every other provider setting. It lives in src/tui/llm-panel/fallback/ plus the render in src/tui/components/llm-fallback-rows.tsx.
What it shows. The effective chain from resolveFallbackChain, one numbered link per row (1. provider/model [kind]), the active text provider always the head and tagged active (primary), and the auto-appended local last resort tagged local last resort (appendLocal). Below the list: the appendLocal toggle state and a + add link row (present only when a configured provider is not yet in the chain).
Keys (do not collide with the tab's existing letters f/n/c/e/E/s/B/L/r or [/]): j/k move the row cursor; </> move the selected link up/down in priority; a (or Enter) opens the add-link picker; d removes the selected link; l toggles appendLocal. The add-link picker owns the keyboard while open (↑/↓ move, Enter adds, Esc cancels).
Persistence. FallbackOrchestrator (src/tui/llm-panel/fallback/fallback-orchestrator.ts) is the only TUI writer of llm.fallback.chain / llm.fallback.appendLocal, via setFallbackChainInConfig in src/tui/persist-llm-provider.ts. It writes the operator's declared chain (displayed links minus the synthesised local tail) and re-validates with parseLlmFallbackConfig before writing, so an unknown id is rejected here, not on the next read. Timing knobs (failureThreshold, cooldownMs, …) set by hand are preserved across an edit. Nothing else writes this block, so the pane never fights the runtime ProviderFallbackChain.
Live status is honest, not invented. The runtime ProviderFallbackChain instance is bootstrap-local and not on AgentRuntime, so the pane cannot read live cooldownUntil / probe timers. It therefore never renders a countdown. Its one live signal is the last provider_switched AgentLoopEvent (already streamed to the TUI), mirrored into fallbackPanel.lastSwitch: failed over A -> B (reason) on away, recovered primary B on back, else on primary (no fallover this session). If a future change surfaces the breaker state on AgentRuntime, the pane can upgrade to a real countdown — until then it shows config statics plus the last announced transition only.
Locked invariants (Pinned by tests)
- The pane lists the effective chain in order, active provider hoisted to the head and tagged, appended-local tagged. Pinned by src/tui/llm-panel/fallback/fallback-panel-selectors.test.ts, src/tui/components/llm-fallback-rows.test.tsx.
- Reorder is clamped — a move past either end is a no-op, and neither the active head nor the appended-local link can be reordered/removed. Pinned by src/tui/llm-panel/fallback/fallback-chain-edits.test.ts.
- Move / add / remove / appendLocal-toggle persist to
config.jsonand re-mirror; hand-set timing knobs survive; the synthesisedappendLocallocal link is never written into the storedchain(no round-trip doubling); a provider hot-swap (providers_refresh) re-mirrors so the head follows the active provider. Pinned by src/tui/llm-panel/fallback/fallback-orchestrator.test.ts. - Keys route to the right intent and the add-link picker owns the keyboard while open. Pinned by src/tui/llm-panel/fallback/fallback-key-bindings.test.ts.
provider_switchedis mirrored intofallbackPanel.lastSwitch; the pane never invents a live countdown. Pinned by src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts, src/tui/components/llm-fallback-rows.test.tsx.- Empty chain / nothing-addable shows a hint, not a broken list. Pinned by src/tui/components/llm-fallback-rows.test.tsx, src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts.
Traceability and replay
Every run produces an append-only NDJSON trace at <stateDir>/traces/<sessionId>.ndjson — one event per line. Tracing is on by default for atomic-agent run / TUI / atomic-agent serve, and off by default in sidecar mode so the Tauri host decides whether to opt in.
Emitted TraceEvent types (see src/tracing/trace/trace-event.ts):
session_started— carriesworkingDirand optionalmetadata.turn_started/turn_finished— per macro-turn, withreason/stepCount/durationMs.step_started/step_finished— per inference step.prompt_captured—{ stablePrefixHash, tail, tokens: { total, stablePrefix, tail }, slotId, cacheReused }. The stable prefix is stored only as its salted hash (viahashPrefixfrom src/llm/slot-manager.ts) so trace files stay compact across steps; the variable tail is stored verbatim.llm_completion— full completioncontent+reasoningContent+timing, withattempt: 1 | 2(attempt 2 == parse retry).reasoningContentis sourced from two mutually-exclusive channels: Channel A is the dedicatedreasoning_contentSSE field (QwQ / DeepSeek-R1 with--reasoning-format deepseek); Channel B is the inline<think>...</think>/<|channel>thought...<channel|>block that the grammar-aware stream parser splits client-side.consumeStreamaccumulates both separately and prefers Channel A when both fire; the legacy/completionendpoint always falls back to Channel B because it never emitsreasoning_content. Pinned by src/agent/step-executor.test.ts "executeStep streaming reasoning accumulator".tool_invocation— executed tool call with args, status, summary, and optional details.parse_retry,loop_detected,error,trace_truncated— diagnostics.
Invariants:
- Append-only. Sinks never rewrite past lines.
trace_truncatedis a synthetic final marker when the per-session cap (tracing.trace.maxBytesPerSession, default 10 MiB) is hit; further events are dropped silently. - Per-session file. One NDJSON per
sessionId; no cross-session mixing. - Monotonic
seq. Every event carries a monotonic in-session sequence starting at0. - No redaction yet. Secret redaction is an explicit NON-goal of this milestone; treat trace files as sensitive local artefacts.
CLI:
atomic-agent trace list [--limit N]— most recent trace files in<stateDir>/traces/.atomic-agent trace show <sessionId> [--step N] [--raw]— pretty-print the chronology.--rawincludes the full prompt tail and completion content; otherwise they are summarised.atomic-agent trace export <sessionId> [--format ndjson|json]— dump the file as-is (ndjson) or as a JSON array.atomic-agent trace replay <sessionId> [--step N]— rebuild the stable prefix from the current runtime (tools / capabilities / skills / persona) and compare its hash to the recordedstablePrefixHash. Drift means the upper prompt changed since recording — useful for postmortem when cache hits dropped.
Replay lives in src/replay/. It is a prompt-drift postmortem, not a simulator: it does not reproduce LLM non-determinism or external world state (browser, filesystem). replayInference (programmatic, not wired to the CLI yet) can optionally rerun LlamaServerClient with the recorded prompts for regression tests across llama-server upgrades.