Table of Contents
🌏 中文版
The series overview set the rules: five sections per post, evidence at file#symbol level. The first real topic is the foundation — the agent loop.
Scope disclosure first: this post deep-dives three projects — pi (badlogic/pi-mono), claude-code (community-decompiled v2.1.88; symbol names may differ from the original), and codex (openai/codex Rust workspace). omp is a fork of pi with a same-shaped loop core, so I didn't re-verify it; I did not examine opencode file-by-file this round and am saying so explicitly. Every citation below was grepped in my local clones.
The design problem: what a loop actually has to handle
"Call the LLM, run tools, repeat" is twenty lines of while-loop. The hard parts live next to it:
- Streaming: model output arrives token by token; the UI wants to render as it goes, but tool-call arguments are only complete once the stream ends.
- Tool-call iteration: the model returns a batch of tool_use blocks; you execute them, feed results back, and ask again. When does it end? Is stop_reason trustworthy? Should you execute tool calls from a response truncated by the token limit?
- Cancellation: at the instant the user hits Ctrl-C, half a batch of tool_use may lack matching tool_result blocks — sending such history back to the provider gets rejected.
- Resume: the process dies at step 37; replaying from scratch is too expensive. But resume isn't just reloading a log — you must be able to prove no side effect was mid-flight when it died.
The ReAct paper's interleaved reasoning-and-acting pattern is the academic prototype of this loop, but the paper's loop never has to handle Ctrl-C or a power cut. All the engineering difficulty is in those four items.
How the reference projects do it
pi: the minimal loop as a textbook
The entire core is pi-mono/packages/agent/src/agent-loop.ts#agentLoop, one function returning EventStream<AgentEvent, AgentMessage[]>. Inside, runLoop is a double while-loop: the inner one runs "stream a response → collect toolCalls → execute → append results", the outer handles queued follow-up messages. The event stream is a fixed discriminated union (types.ts#AgentEvent): agent_start, turn_start, message_start/update/end, tool_execution_start/update/end, turn_end, agent_end.
Details worth stealing:
- Finish-reason defense: when
streamAssistantResponseseesstopReason === "length", it executes nothing and routes every tool call throughfailToolCallsFromTruncatedMessageinstead — streamed tool-call arguments go through a best-effort JSON salvage parser, so truncated arguments that "look valid" are the most dangerous case. - Cancellation is not an exception path: an
AbortSignalthreads through every tool; the sequential execution loop checkssignal.abortedbetween calls and breaks, keeping completed results in the transcript. - A dedicated retry entry point:
agentLoopContinueadds no new message and only requires the last context message to be user or toolResult — built for retrying a failed turn.
claude-code: a state machine that distrusts stop_reason
src/query.ts#query is an async generator yielding stream events and Messages, returning a Terminal (with reason: 'aborted_streaming' | 'model_error' | ...). The body, queryLoop, maintains a State object whose transition: Continue | undefined field records why the previous iteration continued — an explicit state-machine transition log.
Two comments in this file are, to me, the most important in the whole codebase:
- Near line 554, it states outright:
stop_reason === 'tool_use'is unreliable and not always set correctly; the real continue signal is whether any tool_use block actually arrived during streaming (thetoolUseBlocksarray). A production-grade lesson: protocol fields and reality diverge. - On abort it calls
yieldMissingToolResultBlocks, synthesizing anis_error: truetool_result for every unmatched tool_use. Without this, interrupted history goes back to the API containing orphaned tool_use blocks and fails outright.
It also caps max_output_tokens recovery attempts (MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3) — even automatic retries need an endpoint.
codex: turns as tasks, recording as infrastructure
The Rust side separates concerns more cleanly. codex-rs/core/src/tasks/regular.rs#RegularTask::run emits a TurnStarted event, then loops calling run_turn until the input_queue has no pending input — text typed mid-turn becomes the next turn's input rather than an interruption. The actual sampling loop lives in codex-rs/core/src/session/turn.rs#run_turn: pre-sampling compact, hooks, capture_step_context, then the request, all carrying a CancellationToken so cancellation takes effect at every await point.
Persistence is its own crate, codex-rs/rollout: recorder.rs#RolloutRecorder records sessions as JSONL, with RolloutRecorder::resume rebuilding directly from a file; reverse_jsonl_scanner.rs#ReverseJsonlScanner is a read-only scanner starting from the tail that can freeze a "prefix up to this byte offset" — resume only needs the tail, never the full history. This is append-only-log-as-source-of-truth taken all the way.
rivumi's choice: write ordering is crash semantics
rivumi's main loop lives in src/rivumi/loop.py#AgentRunner.run, plainer in shape than all three above: a single while-loop that checks the cancel flag and remaining wall-time at each step boundary, executing tool calls one by one. No parallel tool execution, no mid-turn steering — M1's scope demanded provable correctness first.
Persistence is where I diverged from all three. Every _event call (loop.py#_event) does two things in fixed order:
- Write the complete resumable state (messages, usage, step, repeated-action fingerprint, event sequence) into the
session.jsonmanifest; - Only then append the JSONL record to
events.jsonl.
This ordering creates exactly one known crash window — the manifest one sequence ahead of the JSONL — and src/rivumi/session.py#SessionStore.claim_and_validate_resume repairs precisely that (rewinding the manifest by one) while refusing everything else. In particular, if the last durable event is tool.started or verification.started, resume fails closed: you cannot prove whether that side effect completed, and guessing beats refusing only in fiction. The workspace is validated too: the Git root must exist and HEAD must equal the pinned base_sha, otherwise you'd be continuing on the wrong code.
The checkpoint itself is checkpoint.json, written by loop.py#_checkpoint via events.py#atomic_write_json (temp file + rename + directory fsync). loop.py#AgentRunner.resume is strict hydration: provider/model/protocol must match, the event sequence must be contiguous — and it conservatively sets _made_changes back to True, because the workspace may hold unfinished modifications and the final verification gate must stay armed. If an approval was hanging when the process died, _reconcile_interrupted_approval records it as a failed ToolObservation plus an approval.abandoned event so the model can request it again, rather than silently honoring a stale approval.
Where this diverges furthest from an idealized ReAct-style loop is exactly in these "things papers don't write down": Anthropic's engineering report Building effective agents says it plainly — agent reliability comes from harness engineering, not prompts. My verification gate follows the same principle: even if the model already ran the tests itself, the harness reruns every declared check before declaring success, feeding failure output back as untrusted text.
What could still improve
- No streaming. pi's
message_updateevents and codex's per-item streaming UI both rest on streaming; rivumi currently waits for the whole response before persisting, which hurts interactivity and forfeits detect-tool-calls-as-they-stream. - Checkpoint cost is O(entire history). Every event rewrites the whole manifest; long sessions will slow down. Codex's rollout approach — append-only JSONL plus a reverse scanner reading only the tail — is a ready-made upgrade path.
- No parallel tools. pi supports parallel batches in
executeToolCalls(unless a tool declares sequential); read-only tools would benefit immediately. - Coarse cancellation. rivumi's cancel only takes effect at step boundaries; a running tool is bounded only by timeout. AbortSignal-style immediate propagation is worth adding.
Next post in the series: workspace isolation and path policy — the other safety line outside the loop.
References
- badlogic/pi-mono — packages/agent — minimal agent loop and event stream
- anthropics/claude-code — official repo (ships minified bundle; citations here come from community decompiled v2.1.88)
- openai/codex — codex-rs/core and codex-rs/rollout — task-based turns and session recording
- ReAct: Synergizing Reasoning and Acting in Language Models — the prototype interleaved loop
- Building Effective Agents — Anthropic's agent engineering principles
Loading...