Prompt system
How the stable prefix and variable tail are budgeted and cached.
Atomic Agent is a local-first AI agent that does real work on your machine — browsing, editing files, running shell commands, remembering things across sessions. This page explains what happens between the moment you send a message and the moment the agent replies.
The short version: one model inference produces one step. A step is a batch of tool calls. The agent runs them, compresses the results, updates its state, and asks the model again. That repeats until the agent decides it’s done.
Most of the cost of running an agent is the model. Atomic Agent is built around keeping each model call small and cheap so that long, multi-step sessions stay affordable on a quantized local model.
It does this with a few deliberate choices:
The result is a tight, observable loop you can trace, replay, and reason about.
flowchart TD
User["User message<br/>or scheduled task"] --> Prompt["Build prompt<br/>(stable prefix + bounded tail)"]
Prompt --> Infer["One LLM inference<br/>(GBNF grammar-constrained)"]
Infer --> Calls["JSON array of<br/>1..N tool calls"]
Calls --> Exec["Execute batch<br/>(parallel reads, approval gates)"]
Exec --> Compress["Compress results"]
Compress --> State["Update durable state<br/>(session, memory, world)"]
State --> Terminal{"reply or finish?"}
Terminal -->|no| Prompt
Terminal -->|reply| Reply["Turn ends,<br/>session pending"]
Terminal -->|finish| Done["Session completed"]
State -.->|async, never awaited| Reflect["Memory reflection<br/>(learns from the turn)"]
Two terms recur:
When you send a message, the runtime calls AgentLoop.runTurn(). Here is what it does, in order.
ToolLoopTracker is created to watch for the agent getting stuck.The loop runs from step 0 up to agent.maxSteps (default 25). Each step:
/props if a model-profile manager is wired (this is how a mid-turn model swap is detected).executeStep, which builds the prompt, runs one inference, parses the tool calls, and executes them.reply, the turn ends (session left pending — the user can continue).finish, the session is completed.When the loop exits, the session status is set, the turn count is incremented, and a lesson-lifecycle hook records whether the turn succeeded or failed.
A session is always in one of eight states:
| Status | Meaning |
|---|---|
pending | Idle and ready — the last turn replied, or none has run yet |
running | A turn is executing right now |
awaiting_approval | Blocked on you approving an approval-gated call |
awaiting_llm | Blocked waiting on the model’s response |
completed | The model emitted finish |
failed | The turn ended in an unrecoverable error |
cancelled | The turn was cancelled |
stalled | The turn hit agent.maxSteps without emitting reply or finish |
stalled is not an error state: the session is otherwise healthy and your next message can drive a new turn. It exists so operators can tell that condition apart from a clean pending idle.
Finally, memory reflection fires — asynchronously, never awaited. It runs in the background on a separate llama-server slot so it never disturbs the main agent’s KV-cache or blocks your reply.
executeStep is where the model meets the tools. The pipeline:
flowchart TD
Build["buildPrompt()<br/>stable prefix + tail"] --> Slot["acquire KV-cache slot<br/>(prefix hash)"]
Slot --> Complete["LLM completion<br/>(stream or unary)"]
Complete --> Reason["resolveReasoning()<br/>(server channel or inline think)"]
Reason --> Parse["parse tool calls<br/>(grammar or native_tools)"]
Parse --> Validate["validate batch"]
Validate -->|ok| Run["executeBatch()"]
Validate -->|parse failed| Repair["one-shot repair completion"]
Repair --> Run
Run --> Effects["apply state effects<br/>(world snapshot, rare-tool autoload)"]
Effects --> Record["append transcript turns"]
A few details worth knowing:
resolveReasoning() prefers the server’s dedicated reasoning_content channel when it’s available (QwQ, DeepSeek-R1 with --reasoning-format deepseek), and otherwise falls back to extracting inline <think> blocks from the content body for models that stream chain-of-thought inline.Once the calls are parsed and validated, executeBatch runs them in three phases, grouped by resource class. There are nine classes:
| Class | Covers |
|---|---|
pure_read | Side-effect-free reads — the only class that fans out in parallel |
fs_write | Declared, but no tool is assigned it today (see below) |
browser | browser.* — one Playwright backend per process |
memory_write | Mutations to the memory store |
tasks_write | Mutations to the task queue |
vision | vision.* |
approval_gated | Anything that needs your approval, including os.shell.run and os.fs.write |
terminal | reply and finish |
unknown | Default for unlisted tools; the validator rejects these from any batch (fail-closed) |
The three phases:
max(group durations). Within a group, pure_read tools fan out in parallel, while the other classes serialize in batch order to keep their writes deterministic.reply or finish, that call runs solo, last, after every other call settles — even if an earlier tool failed. This preserves the model’s intent: “do the work, then close the turn.”Results are collected back in batch-index order, then each one is compressed.
Every tool result is passed through compressToolResult() before it re-enters the conversation. The compressor:
error:, failed:, traceback, exception:) and surfaces the first match.[truncated] if it overflows.The full output is never sent to the model — only this compact summary. This is what keeps each step under budget even when a shell command spews thousands of lines.
Local models can get stuck repeating the same call. ToolLoopTracker watches for this within the turn (it has no memory of prior turns):
web.search, web.fetch, browser.*) are watched for a spreading set of distinct args; crossing the escalation threshold jumps straight to the breaker.Thresholds are tunable via config / env:
| Knob | Env var |
|---|---|
| Warning threshold | ATOMIC_AGENT_LOOP_WARNING_THRESHOLD |
| Critical threshold | ATOMIC_AGENT_LOOP_CRITICAL_THRESHOLD |
| Breaker veto streak | ATOMIC_AGENT_LOOP_BREAKER_VETO_STREAK |
| History window size | ATOMIC_AGENT_LOOP_HISTORY_SIZE |
| Max parallel tool calls | ATOMIC_AGENT_MAX_PARALLEL_TOOL_CALLS |
The prompt is split into two zones:
AGENT_FIXED_PROMPT_TOKENS) as headroom for drift — that constant is only a startup sanity check against the model’s context window, and the real per-build figure comes from checkBudget.Everything durable lives outside the prompt entirely:
sessions.sqlite.memory.sqlite.tasks.sqlite.The prompt only ever sees compact pointers into memory — never the whole store.
Every entry point — atomic-agent run, atomic-agent serve, atomic-agent tui, and the Tauri sidecar — constructs the runtime exactly once through createAgentRuntime(). That factory probes llama-server, builds the LLM provider registry and tool registry, opens the memory stores, wires the AgentLoop, and returns an AgentRuntime handle.
Turn serialization is the runtime’s job, via the TurnController:
graph TD
Entry["run / serve / tui / sidecar"] --> Factory["createAgentRuntime()"]
Factory --> Llama["probe llama-server"]
Factory --> Tools["ToolRegistry"]
Factory --> Memory["Memory stores (SQLite)"]
Factory --> Loop["AgentLoop"]
Factory --> Turn["TurnController"]
Turn -->|per-session FIFO| Loop
Loop -->|runTurn| Step["executeStep → executeBatch"]
Step --> Tools
Step --> Memory
Step -.->|events| Trace["TraceRecorder (NDJSON)"]
The loop is easiest to watch with tracing on. Run a turn, then replay or inspect it:
# Run an interactive sessionatomic-agent run --cwd . --max-steps 25
# List recorded traces, then inspect one step-by-stepatomic-agent trace listatomic-agent trace show <sessionId>
# Re-run the prompts against llama-server to check for driftatomic-agent trace replay <sessionId>Prompt system
How the stable prefix and variable tail are budgeted and cached.
Memory
Profile facts, notes, lessons, and procedures — and how recall avoids prompt bloat.
Tools
The tool namespaces (os, memory, browser, tasks, mcp, skill, vision, tool, plus the bare reply and finish verbs), resource classes, and the approval gate.
Configuration
Loop thresholds, parallel limits, and the agent.* config block.