This document captures high-leverage ways to improve the atomic-agent core without changing its product identity as a local operator runtime. It complements:
README.mdfor user-facing setup and usageARCHITECTURE.mdfor the current design and invariantsAGENTS.mdfor contributor constraints and the current memory model
Today the runtime is strongest as a reactive, step-by-step operator loop built around:
src/runtime/bootstrap.tssrc/agent/agent-loop.tssrc/agent/step-executor.tssrc/prompt/build-prompt.tssrc/session/session-state.ts
Its current strengths are clear:
- stable prompt prefix and KV-cache reuse
- explicit tool loop with grammar-constrained tool calls
- durable session persistence in SQLite
- browser and OS tool surfaces with approval gating
Its current limits are also clear:
- memory is session-scoped only
- long
conversationandworldtails can grow until the model context limit - there is no scheduler, wakeup layer, or external event ingress
- LLM failure recovery is narrow
- there is no durable background task model
Any core evolution should preserve the current architectural invariants:
- keep the stable prefix byte-stable within a session
- keep one LLM inference equal to one agent step (the inference always emits a JSON array of
1..Nindependent calls — a "solo" step is a length-1 array; the loop still drives the macro-turn, see "Parallel tool calls per step" milestone below) - keep tool calls grammar-constrained
- keep dependencies explicit
- keep session state outside the model whenever possible
What it adds:
- a bounded transcript window
- session summaries for older turns
- explicit token budgets for
worldSnapshotand conversation tail
Why it matters:
- gives the fastest stability win for long sessions
- reduces silent dependence on the remote model's
n_ctx - makes prompt growth predictable and observable
Likely modules:
src/prompt/build-prompt.tssrc/prompt/token-budget.tssrc/session/session-state.tssrc/session/conversation-turn.tssrc/compressor/
Main risk:
- summary quality can hide details if the compression policy is too aggressive
Reframed from the original "workspace memory and retrieval" framing: atomic-agent is a general-purpose local operator, not a coding-scoped assistant, so the first memory milestone targets operator durability (who the user is) rather than workspace / file-index retrieval. Workspace inventories, document summaries, and embedded retrieval are explicitly deferred.
Retracted: the original plan bundled an Action History layer that searched existing tool_invocation events in <stateDir>/traces/*.ndjson. In production tracing is off by default, which left the feature dead on arrival. Action History (memory.history.* config, memory.history.search tool, action-history-reader/action-history-search modules) has been removed. Memory formation now happens through an async end-of-turn reflection runner that distils durable facts out of the last exchange and writes them into the same ProfileStore.
What ships (single-layer profile + async reflection formation):
- User Profile — durable key/value facts in a new SQLite file
<stateDir>/memory.sqlite, rendered as### profilein the variable tail of the prompt between### sessionand### world. Managed by three tools:memory.profile.set,memory.profile.remove,memory.profile.list. Gated bymemory.profile.enabled(defaulttrue), capped bymemory.profile.maxTokens(default512). - Reflection runner — fire-and-forget at the end of every
AgentLoop.runTurn. Uses a dedicated llama-server slot (slotManager.reserveReflectionSlot()) with its own tiny stable prefix so the main agent's KV cache is never invalidated. A GBNF-constrained micro-prompt emits eitherNONEor at mostmemory.reflection.maxFactsPerCallSET key=valuelines, which flow throughProfileStorevalidators. Gated bymemory.reflection.enabled(defaulttrue), timeoutmemory.reflection.timeoutMs(default10000).
Why this order:
- gives the runtime durable cross-session memory of the user (Type 1) with near-zero prompt cost
- replaces the trace-dependent Action History with an autonomous formation mechanism that works regardless of whether tracing is enabled
- defers embeddings, semantic retrieval, summaries, and workspace indexing until after we measure real usage
Shipped modules:
src/memory/:memory-schema.ts,profile-store.ts,profile-renderer.ts, plus thesrc/memory/reflection/feature folder (reflection-prompt.ts,reflection-grammar.ts,reflection-parser.ts,reflection-runner.ts)src/tools/memory/:profile-set.ts,profile-remove.ts,profile-list.tssrc/prompt/build-prompt.ts—### profileinjection in the variable tail, profile tokens subtracted from the effective conversation cap insrc/prompt/token-budget.tssrc/prompt/tool-descriptors.ts+grammars/tool-call.gbnf— three profile toolssrc/config/config-schema.ts+src/config/load-config.ts—memory.profile.*andmemory.reflection.*keys,paths.memoryDbFilesrc/llm/slot-manager.ts—reserveReflectionSlot()for a dedicated reflection slotsrc/agent/agent-loop.ts— optionalreflectionRunnerdependency;abortPending()at turn start, firereflect()at turn endsrc/runtime/bootstrap.ts— instantiatesProfileStore, registers memory tools, wiresprofileFactsProvider+ReflectionRunnerintoAgentLoopsrc/tracing/agent-metrics.ts—agent.memory.reflectioncounter + latency histogramAGENTS.md— revised §"Memory fabric" section (single layer + reflection subsection)
Invariants (locked):
- the
### profilesection lives in the variable tail only — the stable prefix is byte-stable across profile edits (pinned bybuild-prompt.test.ts) - reflection uses its own reserved slot; main agent KV-cache is never touched by reflection (pinned by
slot-manager.test.ts) - reflection is never awaited by the loop; its errors are caught by the runner and surface only via logs + metrics
- no embeddings, no TTL, no tags, no redaction in this milestone
Explicitly out of scope for this milestone (deferred to future options or later iterations of this one):
- episodic memory / session summaries (Type 2)
- action-history search over trace files (retracted — see above)
- long-term fact store with TTL / tags / topics (Type 4)
- embeddings / semantic search
- workspace inventories and document indexing
- secret redaction of profile / reflection content
Main risk (as expected):
- scope creep — kept contained by freezing the design at the single-profile layer with async reflection and resisting the urge to add retrieval / topic / TTL layers before real usage demands them
Additive layer on top of Option 2. Inspired by the ZeroClaw hybrid-memory trait, stripped to a deterministic keyword-only slice:
memoriestable +memories_ftsvirtual table in the same<stateDir>/memory.sqlitefile that ownsprofile_facts. BumpsMEMORY_SCHEMA_VERSIONfrom 1 to 2; migration is idempotent, downgrade-guarded.MemoryStore(new) — freeform content, BM25 ranking via FTS5 (porter unicode61tokenizer), hard-cap eviction byupdated_at. Shares the SQLite connection pattern andbetter-sqlite3discipline withProfileStore.- Three new agent tools:
memory.notes.store,memory.notes.recall,memory.notes.forget. Originally write and read were both explicit LLM actions; the notes corpus was never rendered into the prompt. The hybrid-memory increments below relax this for read-only tail injection while keeping the corpus itself out of the prompt. - Config keys:
memory.notes.{enabled,maxEntries,maxContentChars,recallDefaultK}.USER_CONFIG_VERSIONbumped to 2 with transparent v1→v2 migration (existing configs load with defaults injected).
Option 2c: hybrid memory increments (read-side recall + contextual profile + auto-NOTE) [shipped: 2026-04-23]
Shipped as a three-step refinement on top of Option 2 + 2a. Full description in MEMORY.md. Plan: .cursor/plans/memory_compromise_three_increments_0ea627e7.plan.md.
- Increment 0 — reflection writes freeform notes. Reflection now emits
NOTE body [tags]lines in addition toSET key=value. Capped bymemory.reflection.maxNotesPerCall(default 2) and gated bymemory.reflection.autoStoreNotes. Closes the "memory doesn't fill itself unless prodded" gap by makingMemoryStoreself-populating from natural conversation. - Increment 1 — automatic recall + memory-index in the tail.
agent-loop.runTurnpre-fetches two new tail sections via src/memory/memory-context-provider.ts:### recalled— top-K BM25 hits against the currentuserMessage(memory.recallInjection.{enabled,k,previewChars,maxTokens}).### memory-index— compact#id [tags] previewpointer rows the agent can drill into viamemory.notes.recall { id }(memory.index.{enabled,limit,previewChars,maxTokens}).- Both sections are deduplicated by id and live strictly in the variable tail.
SessionStategains ephemeralrecalledNotes/memoryIndexfields stripped bystripEphemeralbefore persistence. The notes corpus body is still never dumped wholesale — only top-K hits and pointer rows.
- Increment 3 — contextual profile facts.
MEMORY_SCHEMA_VERSIONbumped to 3 with two new columns onprofile_facts:pinned INTEGER NOT NULL DEFAULT 1andkeywords TEXT.pinned=truefacts always render;pinned=falsefacts only render when at least one keyword hits the currentuserMessage(case-insensitive substring match). Reflection learned a new SET flavour:SET key=value [pinned=false; keywords=a,b,c]. Master switchmemory.profile.contextualKeywordGate(defaulttrue).
The end result: a hybrid memory system where (a) hot bits surface automatically, (b) warm bits surface as cheap pointers, (c) cold bits are reachable via explicit tool calls, and (d) writes happen autonomously after each turn — all without invalidating the KV-cached stable prefix.
What M2 (Option 2b) would add on top: embeddings for semantic recall, an importance score / decay curve, content-based deduplication of notes, and a two-stage FTS5+vector ranking pipeline. Parked until real notes usage justifies the extra operational surface (second llama-server with --embedding, sqlite-vec, reciprocal-rank fusion).
What it adds:
- retries with bounded backoff for transient LLM failures
- parser recovery for malformed or truncated tool-call output
- clearer error classification between transport, grammar, and model failures
Why it matters:
- improves reliability without changing product scope
- reduces turn-ending failures caused by temporary llama-server issues
- makes smaller local models more usable in practice
Likely modules:
src/llm/llama-server-client.tssrc/llm/llama-server-health.tssrc/agent/step-executor.tssrc/agent/agent-loop.ts
Main risk:
- retries must never replay already-executed tools; they should stay on the LLM side only
Implementation notes (2026-04-23):
- Transport retry with bounded backoff lives in
LlamaServerClient.completeand the unary pre-body ofcompleteStream(llama.completionRetries,llama.completionRetryBackoffMs), limited to network errors and HTTP 5xx. - One-shot parser recovery sits in
src/agent/step-executor.ts: onToolCallParseErrorthe executor replays through the unary LLM path, emits aparse_retrystep event, and surfaces a typedGrammarErrorif the second attempt still fails. - Error taxonomy lives in a new feature folder
src/llm/reliability/—LlmFailureCategory, theLlmFailureclass hierarchy (TransportError,GrammarError,ModelError,ToolExecutionError,CancelledError),classifyFailure, anddetectModelFailure. The executor wraps any escaping error into anLlmFailurebefore emittingstep_error; the agent loop classifies at the outer catch and attachescategorytoloop_failed. detectModelFailureshort-circuits the parser retry ontruncated/empty/no_stopcompletions so the runtime does not waste an LLM call reproducing the same wall.categoryis propagated end-to-end:step_error/loop_failedevents →trace-recorder→AgentMetrics.recordLlmFailure(agent.llm.failure) → TUI event feed label → sidecar protocol (session_failed.category,error.code = step_error:<category>) → OpenAI SSE (error.categoryfor atomic extensions,error.type = agent.<category>for OpenAI-compatible clients).- Tests:
src/llm/reliability/*.test.ts, newagent-loop.test.tscases (truncated,empty, persistent grammar failure, missing tool),tracing.test.tsrecordLlmFailure,trace-recorder.test.tscategory propagation,agent-event-reducer.test.tsfeed label. SeeAGENTS.md§"LLM reliability policy" for the full invariant table.
What it adds:
- time-based scheduling (
at/cron/interval) on top of the Option 5 task queue - a single in-process
Schedulerthat drains due tasks through the existingTaskRunner.runDue - generic webhook ingress (
POST /api/webhooks/:name) that materialises as a task — never a directrunTurn - agent-side self-scheduling via
tasks.schedule,tasks.cron,tasks.list,tasks.cancel,tasks.show - explicit
session.metadata.wakeReasonstamped just beforerunTurnso audit plumbing can distinguish user / scheduler / webhook / agent turns
Why it matters:
- turns the runtime from purely reactive chat into an assistant that can resume work later
- unlocks reminders, periodic sync, watchdog, and trigger-based workflows on top of the durable queue from Option 5
- keeps the concurrency story intact: every background firing still enters the same per-session FIFO via
TurnController
Shipped modules:
src/tasks/— extendedTaskRecordwithschedule,scheduledFor,recurring,lastScheduledAt,triggerSource; newtask-schedule.ts(pureresolveScheduledFor/isRecurring, backed bycron-parser);TaskStore.listDue/requeueRecurringplusidx_tasks_duepartial index;TaskRunnernow supports schedule-awarecreate()(recurring tasks get a persistent session at create time; one-shot tasks staysession_id = NULLuntil the first attempt), wake-reason stamping, recurring requeue at completion, and a scheduler-facingrunDue(now, limit)drain. Schema migrated to v2 idempotently.src/scheduler/— new feature folder. SingleSchedulerclass (start()/stop()/tickOnce()) holding the only periodic timer in the runtime; re-entry guarded, errors swallowed + logged + metered.src/http/route-webhooks.ts+src/http/webhook-template.ts+src/http/webhook-session-store.ts— generic webhook route keyed offconfig.webhooks[name],{{body.<jsonpath>}}substitution, optionalx-webhook-secretgate, threesessionModes (ephemeral/persistent/named). Persistent mode stores itswebhookName → sessionIdmap in<stateDir>/webhook-sessions.json.src/tools/tasks/— five agent tools, gated byconfig.tasks.agentToolsEnabled.tasks.scheduleinherits the caller session by default (newSession: trueopts out);tasks.cronalways allocates a fresh persistent session so recurring autonomy never contaminates the caller's thread.src/runtime/bootstrap.ts— wiresScheduler(start afterrecoverStale, stop beforetaskStore.close),WebhookSessionStore, and theregisterTaskToolscall.AgentRuntimenow exposesscheduler: Scheduler | nullandwebhookSessionStore.src/config/config-schema.ts+load-config.ts— env-onlytasks.schedulerEnabled,tasks.schedulerTickMs,tasks.schedulerBatch,tasks.agentToolsEnabled,tasks.minIntervalMs;USER_CONFIG_VERSIONbumped 2 → 3 with a transparent migration that defaultswebhooks: {}.src/cli/task-command.ts—task creategained--at/--cron/--every/--tz;task listprintsschedule+next-run; newtask ticksubcommand for one-shot scheduler pumps.src/tracing/agent-metrics.ts— new countersagent.tasks.{scheduled,recurring_requeued,session_recreated,session_auto_created},agent.scheduler.{ticks,tick_errors},agent.webhooks.received, and histogramsagent.scheduler.{batch_size,tick_duration_ms}.grammars/tool-call.gbnf+src/prompt/tool-descriptors.ts— grammar alternative list and descriptors extended withtasks.*.src/session/session-state.ts— documented reservedmetadata.wakeReasonkey.
Locked invariants (pinned by tests):
Scheduleris the only new periodic timer in the runtime;TaskRunnerand every ingress path stay event-driven.- Webhooks never call
runTurndirectly — they always create a task throughTaskRunner.create. - Recurring requeue is atomic: attempts/lastError/startedAt/completedAt reset,
scheduled_forrearms,session_idnever changes (only auto-recreation on missing session may overwrite it). - A recurring task owns exactly one persistent session for its full lifetime; no row-per-firing duplication.
- One-shot tasks may carry
session_id = NULLuntil the firstrunOneattempt; once written it is stable for that row's lifetime. - The
scheduled_forpartial index is the only path the scheduler uses — no full-table scans. tasks.enabled=falsedisables the entire subsystem: scheduler not started,POST /api/webhooks/:namereturns 404, agenttasks.*tools not registered.cron-parseris encapsulated behindtask-schedule.ts— no other module imports it.
Deferred to later milestones:
- distributed scheduler / leader election across processes
- task graphs & dependencies
- per-trigger fairness
- rendering
wakeReasoninto the prompt (today it is audit-only metadata)
What it adds:
- durable
TaskRecordwithpending | running | completed | failed | blocked | cancelledstates - retries with exponential capped backoff for deferred
runTurnsubmissions, classified through the existingLlmFailureCategorytaxonomy from Option 3 - explicit
(sessionId, userMessage)linkage between tasks and sessions, with runtime validation that the session exists (missing session →blockedwithsession_not_found) - CLI (
atomic-agent task list|show|create|cancel|run) and HTTP admin surfaces (POST/GET /api/tasks,GET/DELETE /api/tasks/:id,POST /api/tasks/:id/run,POST /api/tasks/drain) agent.tasks.*counters and histograms for end-to-end observability of the queue
Why it matters:
- gives structure to deferred work without committing to a workflow engine — one task is exactly one deferred
runTurn, nothing more - unblocks Option 4 (background autonomy / cron): the scheduler now has a stable, persistent submission target with at-least-once semantics
- formalises the "operator can re-trigger this turn" affordance that previously existed only as ad-hoc CLI scripting
Shipped modules:
- new feature folder
src/tasks/—task-types.ts,task-schema.ts(TASK_SCHEMA_VERSION = 1, separate<stateDir>/tasks.sqlitefile, no cross-file FKs),task-store.ts(synchronousbetter-sqlite3CRUD + lifecycle transitions +recoverStale),task-backoff.ts(purenextDelayMs),task-runner.ts(create+runOne+drainPending),index.tsnamed exports, plus colocated*.test.tsfor store / backoff / runner. src/runtime/bootstrap.ts— wiresTaskStoreandTaskRunnerintoAgentRuntime, callstaskStore.recoverStale(config.tasks.staleAfterMs)once on boot, closes the SQLite handle inshutdown().src/config/config-schema.tsandsrc/config/load-config.ts— newtasks.*block (enabled,maxAttempts,backoffInitialMs,backoffMaxMs,runOnCreate,staleAfterMs) +paths.tasksDbFile, all env-overridable.src/tracing/agent-metrics.ts—agent.tasks.{created,started,completed,failed,blocked,cancelled,retried}counters andagent.tasks.{attempts,duration_ms}histograms, withTaskOriginTagreused by metric tags.src/http/route-tasks.ts(+route-tasks.test.ts) andsrc/http/route-table.ts— admin routes; all return 404 whentasks.enabled=false, mirroring thememory.profile.enabledgate from Option 2.src/cli/task-command.ts(+task-command.test.ts) andsrc/cli/index.ts— CLI subcommands;list/show/create/cancelopen theTaskStoredirectly for fast one-shot access,runboots the fullcreateAgentRuntimeand tears it down on the way out.- Documentation:
ARCHITECTURE.md§4.16 + §10 extension table,AGENTS.md"Durable tasks" subsection,README.mdCLI paragraph.
Locked invariants (pinned by tests):
- task
kindis implicit — every record is a deferredrunTurn. The schema does not carry a discriminator; new kinds are not part of this milestone (and would require an additive migration). - task always executes through
runtime.runTurn(..., { origin: "scheduler" })— never throughexecuteTurn(which bypasses the controller). Per-session FIFO + cross-session parallelism are inherited from Option 6. - retries are turn-level only:
runTurnis replayed with the sameuserMessage. Step-level retries inside a singlerunTurnremain Option 3's responsibility. No partial-tool replay. TaskRunnernever holds aSessionStatereference between attempts — re-reads viasessionStore.load(sessionId)inside the next attempt (same pattern assrc/sidecar/main.ts).cancel()is idempotent on already-terminal rows;recoverStaleis one-shot at bootstrap (no background sweeper).tasks.enabled=falsekeepsTaskStoreconstructed (it owns the SQLite handle that must be closed inshutdown);drainPendingbecomes a no-op and HTTP routes return 404.
Explicitly out of scope (deferred to Option 4 or later):
- background ticker / cron /
scheduledFortimestamps - agent-side
tasks.*tools (self-scheduling) - task graphs / dependencies between tasks
- workflow primitives (
kind != "runTurn") - secret redaction inside
userMessage/lastError - per-session task priorities / fairness across origins
Option 4 unblocked by this milestone: the scheduler now has a stable durable-queue contract (taskRunner.create + taskRunner.drainPending) to attach to without touching the agent-loop or controller invariants. Adding cron / wakeups becomes a thin module that calls drainPending on a timer.
What it adds:
- a documented and enforced policy for concurrent
runTurncalls - per-session isolation with explicit FIFO queueing per session
- clearer browser ownership, slot ownership, and reflection ownership rules
Why it matters:
- prevents subtle races when the runtime is embedded in richer hosts
- prepares the core for multiple sessions without hidden shared-state bugs
- gives Option 4 (background autonomy / cron) a stable contract to attach to without breaking browser state, slot ownership, or trace recording
Shipped modules:
- new
src/runtime/turn-controller.ts(+turn-controller.test.ts) — per-session FIFO queue, per-session event hook map,isBusy/busySessionIdsintrospection. Single primitive that every entry point funnels through. src/runtime/bootstrap.ts—runtime.runTurnis now a thin wrapper aroundturnController.enqueue(executeTurn);currentRecorderlives in anAsyncLocalStoragekeyed bysessionId;runtime.executeTurnis exposed for callers that already hold the per-session lock (sidecar).src/http/turn-hub.ts— deleted.src/http/openai-chat-completions.tsenqueues directly viaruntime.runTurn({ origin: "http", eventHook }).src/sidecar/main.ts—send_messageenqueues throughruntime.turnController.enqueueand re-readsactive.sessioninside the queued callback so two rapid NDJSON messages serialise FIFO without crossing state.src/cli/run-agent.ts,src/tui/tui-command.ts— passoriginand (CLI)eventHookthrough the newruntime.runTurnAPI.src/llm/slot-manager.ts— doc-comment pinning the single-active-turn-per-session invariant toTurnController(no internal locking added).src/memory/reflection/reflection-runner.ts—pendingis now aMap<sessionId, AbortController>;abortPending({ sessionId })cancels only the matching session.src/agent/agent-loop.tspassesstate.idso a sibling session's reflection is never aborted by an unrelated turn.
Invariants (locked):
- per-session FIFO, no cross-session serialisation
- no preemption, no priorities — scheduler enqueues like everyone else
- event hook is per-session, set on enqueue and cleared in
finally - recorder is per-session, lives behind
AsyncLocalStorage, never leaks across turns SlotManager/ApprovalGate/ recorder safety relies on at most onerunTurnper session at a time, enforced byTurnController- shared SQLite stores (
ProfileStore,MemoryStore,SessionStore) are safe under cross-session parallel access becausebetter-sqlite3is synchronous (no race window between read and write inside a single statement) ReflectionRunnerhas per-session pending state; reflection on session A is never aborted by reflection on session B
Explicitly out of scope:
- per-session
PlaywrightBackend/ per-session browser context (cross-session browser sharing remains an accepted product constraint) - preemption, priorities, or fairness between origins
- durable queue (shipped as Option 5 [done: 2026-04-24])
- secret redaction in per-session recorder output
Tests:
src/runtime/turn-controller.test.ts— same-session FIFO, cross-session parallelism, hook isolation, error recovery, signal cancellation, scheduler-behind-user orderingsrc/http/openai-chat-completions.test.ts— cross-session HTTP requests do not block each other; same-session HTTP serialises FIFOsrc/sidecar/send-message-concurrency.test.ts— two rapidsend_messagecalls on the same session serialise without crossing statesrc/memory/reflection/reflection-runner.test.ts— same-session re-entry aborts the prior reflection; cross-session reflections never trample each other;abortPending({ sessionId })is scope-correct
Documentation:
ARCHITECTURE.md§"Concurrency contract"; mirrored inAGENTS.md
Shipped as src/tracing/trace/ (append-only NDJSON per session at <stateDir>/traces/<sessionId>.ndjson) + src/replay/ (prompt-drift replay) + atomic-agent trace list|show|export|replay. Secret redaction is intentionally deferred — traces are currently sensitive local artefacts.
What it adds:
- append-only execution traces
- replayable step records
- prompt and tool timing diagnostics for postmortems
Why it matters:
- makes regressions easier to debug
- gives a clean foundation for future evaluation and benchmarking
- helps distinguish model failures from runtime and tool failures
Likely modules:
src/tracing/src/session/src/runtime/bootstrap.ts
Main risk:
- traces must be redacted carefully because tool outputs may include secrets or personal data
If the goal is maximum leverage with minimum architectural risk, the best order is:
managed turn memoryLLM reliability policytraceability and replaymemory fabric (operator-first)runtime isolation and concurrency contractdurable task modelbackground autonomy
Why this order:
- the first three improve the current runtime without changing its product shape
- retrieval becomes much easier once prompt growth and traces are under control
- durable tasks should exist before cron and event-driven autonomy, otherwise background behavior becomes hard to reason about
If only one substantial investment is possible, start with a combined milestone:
- bounded conversation window [done]
- session summary for older turns [done]
- explicit tail truncation markers [done]
- parser retry for malformed tool-call output [done]
- basic LLM retry policy for transient transport failures [done]
This keeps the existing runtime model intact while improving the failure modes users hit first in real work.
Implementation notes (2026-04-23): prompt-time compression lives in packConversation (src/session/conversation-turn.ts); world / conversation safety-net caps (agent.worldSnapshotMaxTokens, agent.conversationMaxTokens) are enforced in buildPrompt and clamped by ModelProfile.contextWindow via computeEffectiveConversationCap. Parser retry lives in src/agent/step-executor.ts (one-shot unary retry, emits parse_retry); transport retry sits in LlamaServerClient (complete + pre-body of completeStream), bounded by llama.completionRetries / llama.completionRetryBackoffMs and limited to network errors and HTTP 5xx. See AGENTS.md §"Current memory model" / §"LLM reliability policy".
Motivation: comparing the agent against Hermes on a "scan four CSVs for PII" task showed atomic-agent taking ~11 minutes vs Hermes' ~5. Trace inspection revealed the bottleneck was not the model or the file IO — it was the architectural constraint that one inference produces exactly one tool call. Hermes had been emitting parallel batches all along; we had been doing four sequential reads.
What it adds:
root ::= tool-call-array(array-only) ingrammars/tool-call.gbnf(and the same array-only root routed through reasoning preludes insrc/llm/grammar/build-grammar.ts). The first iteration shipped withroot ::= tool-call | tool-call-arrayso a solo step could keep the legacy{tool, args}shape — production traces immediately exposed a GBNF first-token bias: small/medium models (Qwen3-30B-A3B-Instruct in particular) almost always picked{over[even when their<think>block explicitly reasoned about parallel reads. Collapsing the root removes the choice entirely; a solo step is now[{...}].parseToolCallsreturning aToolCallBatch { kind: "single" | "batch", calls, reasoning? }insrc/llm/grammar/tool-call-grammar.ts. Under the array-only production grammarkindis always"batch"(a solo step hascalls.length === 1); the"single"branch is preserved for legacy bare-object input from tests / replay traces.parseToolCallis kept as a one-call wrapper that now also accepts a length-1 array.src/agent/tool-resource-class.ts— every registered tool maps to one of nine classes (pure_read,fs_write,browser,memory_write,tasks_write,vision,approval_gated,terminal,unknown). A test pins that every entry ofDEFAULT_TOOL_DESCRIPTORShas an explicit class — adding a new tool requires adding it here.src/agent/batch-executor.ts— per-class planner.pure_readfans out (Promise.allSettled); other batchable classes serialise within their group; distinct groups run concurrently. Failures of one call are folded into a syntheticCompressedToolResult{status:"error"}and never abort siblings. Cancellation marks the in-flight tail ascancelled.src/agent/step-executor.tsrewrite —StepOutcomenow carriestoolCalls[]+toolResults[]aligned by index.validateBatchrejects multi-call batches that include terminal verbs, approval-gated tools, unknown classes, or exceedagent.maxParallelToolCalls. Validation failures piggy-back on the existing one-shot LLM retry. Per-failed-rare autoload runs in batch-index order. Conversation turns are appended N call/result pairs in batch-index order; reasoning is attached once on the firstassistant_tool_call.src/agent/loop-detector.ts— composite hash for batched observations (batchCalls[]). Two identical batches in a row count as a repeat; a permuted batch (same calls, different order) does not — the model may legitimately reorder a set after re-thinking.- Tracing & sidecar —
tool_invocationevents carry optionalbatchIndex/batchSizefor batched steps; solo steps omit them for back-compat with older replay code. Sidecartool_call_started/tool_call_resultmirror the same optional fields. - Stable-prefix instructions — rewritten for the array-only contract: "Emit a JSON ARRAY of tool calls now. Always start with
[and end with], even for a single call." Three worked examples (solo[{...}], parallel batch, reply) anchor the shape, and an explicit "keep solo when" list pins terminal verbs / approval-gated tools / data-dependent chains to length-1 batches. The whole block is part of the byte-stable prefix so the cache stays warm. - New env-only config:
agent.maxParallelToolCalls(default4, hard ceiling16),agent.batchToolResultCharCap(default16000).
Why it matters:
- collapses N independent reads from a sequential
N × per-call latencywall to roughlymax(per-call latency), which is the difference between an 11-minute and a 5-minute multi-file scan - preserves the "one inference per step" invariant — the loop is unchanged, the model just gets to express more parallelism per inference
- cross-session parallelism (
TurnControllerper-session FIFO) is untouched — batches are intra-step, not inter-session
Implementation notes (2026-05-04): the integration test at src/agent/parallel-tool-calls.integration.test.ts measures wall time for a 4-call read batch against an instrumented registry; with PER_CALL_LATENCY_MS = 80, sequential would be ≥ 320 ms and the parallel path comes in at ~85 ms (peakInFlight > 1 confirms real concurrency). Approval-gated tools are unconditionally rejected from multi-call batches because their approval gate is per-call and would deadlock under concurrency. The validator's "approval-gated stays solo" rule is enforced even when approvalRequired=false so the runtime can flip the gate on without changing batching semantics. The array-only grammar pivot was the load-bearing fix: the original tool-call | tool-call-array shipped functionally complete, but production traces (e.g. the "read README/AGENTS/PROMPT/EVOLUTION and check for mentions of vision" trigger) showed the model declaring intent to batch in <think> and then sampling { anyway, exhausting the parallelism gain. Collapsing to tool-call-array is a one-time stable-prefix invalidation but leaves the rest of the pipeline (parser, executor, tracing, sidecar) unchanged. See AGENTS.md §"Parallel tool calls per step" for the full contract; PROMPT.md §2 for the new ### instructions block; src/agent/parallel-tool-calls.integration.test.ts for the wall-time pin.