跪拜 Guibai
← Back to the summary

How Pi's Harness Keeps Agent Sessions Alive Across Crashes

When running a long task in OpenClaw, you can just Ctrl+C to exit halfway through. Open it again, and the conversation is still there — just pick up where you left off.

This experience is so routine that no one gives it a second glance. But recently, while reading Pi's source code, I realized that this "taken-for-granted" little thing is backed by a whole set of rigorous engineering.

Earlier, I read through Pi's agent-loop from start to finish and figured out how the loop runs. But the loop only answers "how does a single request complete." After reading it, I was left with a pile of questions:

The answers to these questions lie in another module of Pi — harness.

The word harness literally means the gear you put on a horse. A horse has strength, but you can't use it directly; you need to put a harness and reins on it so that strength can be turned into pulling power. The testing world borrowed this word long ago: a test harness puts the code under test into a controllable execution environment.

In the agent world, harness follows the same idea. An LLM itself is just an abstract model — you give it text, it spits out text. It has no hands, so it can't touch the file system; it has no memory, so it forgets everything once the process closes. To make it actually do work, you need to give it a "body" — tools are the hands, session is the memory, and the event stream is the nervous system. This body is the harness.

This article will dissect Pi's body: how the session lifecycle is designed, how sessions are persisted, how context is compacted, and how these mechanisms fit together.

1. Why You Need an Engineering Layer Beyond the Loop

First, a more fundamental question: the loop can already run tasks, so why build a dedicated harness layer?

Pi's documentation has a very honest statement: a fully persistent harness is unrealistic because some key dependencies are live code provided by the host application at runtime:

These five things share a common trait: they are all functions, all objects, all carrying closures and network connections — they cannot be serialized to disk. You can store a list of "which tools were active at the time," but you cannot store the function bodies of the tools themselves.

And they change. Today the host registers five tools, tomorrow it might be eight; this time it uses an Anthropic key, next time it might go through a proxy gateway. For the model, whether these resources are available or not, and when they change, is critical information. An agent without a harness cannot perceive changes in tool availability. The most obvious symptom: it repeatedly calls a tool that no longer exists, failing over and over.

So Pi set a sober goal for itself, which the docs call a semi-durable harness:

In one sentence, the boundary is drawn: Data belongs to the session, code belongs to the host. Admitting that some things cannot be saved, solidly saving what can be, and clearly defining the handoff protocol for the rest — this is far more reliable than chasing a fantasy project of "everything can be restored."

2. AgentHarness: The Orchestration Layer Above the Loop

The class that plays this role in Pi is called AgentHarness. The docs define it as: the orchestration layer sitting above the underlying agent loop, responsible for session persistence, runtime configuration, resource resolution, operation locking, and change semantics for extensions.

Five responsibilities listed, but together they boil down to one thing: runtime. The loop is responsible for running; the harness is responsible for making "running" manageable — execution, observation, and control, a trinity.

As a side note, Pi has two implementations of this design: AgentHarness in the general-purpose package, and AgentSession plus SessionManager on the coding-agent product side. Storage is JSONL in both, message writing is hooked onto the same events, and the compaction API is isomorphic. From here on, we'll use harness to refer to both without distinction.

3. AgentHarness Lifecycle: Five Phases

The harness manages itself with a phase state machine. The type definition is just one line:

type AgentHarnessPhase = 
  "idle" | "turn" | "compaction" | "branch_summary" | "retry";

idle: The stable state. Only here can you initiate structural operations like prompt, skill, compact, navigateTree.

turn: An agent turn is executing. Model streaming, tool calls, queue consumption, save points — all happen in this phase.

compaction: Context compaction. Prepares a summary based on the current branch, writes a compaction entry, then returns to idle.

branch_summary: Tree navigation. The session in Pi is a tree. When jumping to another branch, summarize the old branch before leaving.

retry: This slot is reserved in the type, and the docs discuss error recovery, but the full retry path is still converging. For now, treat it as a placeholder.

Five states look simple, but the real information is in the three design principles behind them.

First, this state machine governs session consistency — don't treat it as a progress bar for the UI. Phase, save point, pending writes, turn snapshot — together these solve one problem: how to handle constantly changing state during execution without disrupting the current request or losing it to the session. That the UI can incidentally get phase display is just a side effect.

Second, configuration can be changed at runtime, but only affects the future. During a turn, you can switch models, adjust the thinking level, add or remove tools at any time — the harness accepts it all — but not a single byte of the currently running provider request will change. These changes take effect in the next snapshot.

Third, recovery capability is built around the session log, not the in-memory JS objects. Only session entries can be persisted; tool functions, provider instances, hook handlers must all be re-provided by the host upon recovery, and then messages and configuration are rebuilt from the log.

4. Session Persistence: What to Store, When to Write, How to Read

For session persistence, Pi's approach can be compressed into three sentences:

  1. What to store: An append-only JSONL state tree, one entry per line
  2. When to write: Determined by harness phase — idle writes immediately, turn writes at message boundaries, structural operations write dedicated entries
  3. How to read: Trace back from the leaf, apply compaction boundaries, project into model context

Let's unpack them one by one.

What to Store: A State Tree with One Entry Per Line

The first line of a session file is the header; after that, one entry per line. Entry types include message, model_change, compaction, branch_summary, leaf, and a few others. Each entry carries a parent pointer. The entire file, when read in, is a tree, and leaf marks where the current branch has reached.

Note the granularity of storage: what's stored are event-style entries, one by one. There is no operation that "dumps the entire Agent object." The transcript is serializable; tools, providers, and hooks do not enter the session — this is exactly the implementation of "data belongs to the session, code belongs to the host" from Section 1.

When to Write: The Phase Decides

Under different phases, the disk-writing strategy is completely different. Organized into a table:

Phase What is written How it's written
idle Config changes, manual appends, compaction/navigation results Written immediately
turn user / assistant / toolResult messages Appended on message_end event
Config change mid-turn model / thinking / activeTools Goes into pending queue first, does not touch current request
save_point Accumulated pending changes Flushed uniformly after turn ends, guaranteed to be after this round's messages
Return to idle Residual pending Flushed once more when agent finishes
compaction compaction entry Summary + firstKeptEntryId boundary marker written
branch_summary branch_summary + leaf Tree navigation result written to disk

There are two clever designs in this table.

The first is the pending queue. If you write config changes to disk too early mid-turn, there's a subtle ordering problem: the config entry ends up before the messages of this round that haven't been written to disk yet, so the replay order during recovery would be wrong. Pi's solution is to accumulate — changes go into the pendingSessionWrites queue first, and are flushed uniformly at the save point when the turn ends, naturally guaranteeing that config changes are placed after this round's modifications.

The second is interruption handling. In this design, session interruption follows the normal path: it's still considered within the turn, waits for execution to settle, the interrupted message is written to disk as usual, and then it returns to idle. The design didn't even give abort its own phase. The question from the beginning — "If you Ctrl+C mid-turn, will that half-finished message be lost?" — the answer is right here: what has already been fully written to disk won't be lost, and the interrupted message will be finalized and written with an aborted status. Nothing is lost.

Stringing together the disk-writing sequence of a typical turn:

idle
  └─ prompt()
turn
  ├─ createTurnState()        // Read snapshot from session branch (read-only, no write)
  ├─ user message      → message_end → write to disk
  ├─ assistant message → message_end → write to disk (tool calls attached in message content)
  ├─ toolResult        → message_end → write to disk
  ├─ save_point        → flush pending (model switch, tool add/remove...)
  └─ agent_end         → flush residual → return to idle

How to Read: The Log Read Back Must Be Projected Once More

The last step is the easiest to overlook: the persistent log and the context sent to the model next time are two different things.

When recovering a session, the harness calls buildContext() (on the coding-agent side, it's buildSessionContext()), doing three things:

  1. Trace back from the current leaf node along parent pointers to the root, obtaining the complete history of this branch
  2. Apply the most recent compaction boundary — old messages before firstKeptEntryId no longer enter the context, replaced by the summary
  3. Project the remaining entries into a message array and hand it to the agent loop

So the session's accurate positioning is a durable state log; what the agent loop gets each time is the projected context. After context compaction, the session log has not lost anything, while the model's context has been compressed. Where did the compacted old messages go? They're still lying in the log, untouched — they are just replaced by the summary when projecting the context. You can always trace back; the model always travels light.

5. How Is Pi's Context Compaction Implemented?

The end of the last section said that compacted old messages "are still lying in the log, untouched." This sounds a bit counter-intuitive — it's called compaction, so how come nothing was deleted? It's worth a dedicated deep dive.

Let's give the conclusion first: Compaction itself is also a session log append. The session log is append-only; compaction does not modify any old entries, it only writes one more line to the tree:

{ type: "compaction", summary, firstKeptEntryId, tokensBefore, ... }

The key is two fields. summary is the summary of the old history; firstKeptEntryId is a boundary — from this entry onward, messages are kept as-is; those before it are all skipped during projection and replaced by the summary. Thus, the projection in buildContext() from the last section gains one more replacement step:

LLM Context = [summary] + [kept messages from firstKeptEntryId onward] + [new messages after compaction]

The history on disk hasn't lost a single line, but the context sent to the model has slimmed down significantly. So-called "compaction" compresses only the projection, from start to finish; the log only grows longer.

The Four Steps of a Single Compaction

Step 1: Trigger. There are three entry points: threshold — after a turn ends, check token usage; if it crosses the line of "context window minus reserved margin," it triggers; overflow — the model directly reports a context overflow, a passive fallback; manual — the user types /compact.

Step 2: Choose the cut point. The most meticulous of the four steps. On the current branch, starting from after the last compaction boundary, accumulate tokens from the tail backward. Stop once a retention window (keepRecentTokens, defaulting to about 20k tokens) is filled — this recent segment is the model's short-term working memory, kept as-is as much as possible. The stopping position must also be a legal boundary: the start of messages like user, assistant, bash are all fine, but it must never cut in the middle of a toolResult — tool_use and tool_result are strictly paired; breaking them apart would leave the model seeing a tool call with no response, and the request would be rejected by the provider. Once the cut point is set, the world is split in two: everything before is sent for summarization; everything after is kept as-is.

Step 3: Generate the summary. The messages before the cut point are handed to the summarizer model, which writes a checkpoint following a fixed structure: Goal, Constraints, Progress, Decisions, Next Steps, Critical Context, with a list of files read and modified during this history appended at the end — after context recovery, the model's most common first action is to re-open these files. If a previous summary exists, this step does an incremental update: the old summary serves as the draft, the newly folded messages are layered on, and a new summary is produced, without ever re-reading the full history from scratch.

There's an easily missed detail here. Legal boundaries include the start of assistant messages, so the cut point might land mid-turn — the half initiated by the user gets classified into the to-be-summarized zone. Pi will separately summarize this half-turn prefix and splice it into the final summary, to avoid losing the critical information of "how this most recent round of work started."

Step 4: Write back. appendCompaction() writes to disk, and the phase returns to idle. Next time during projection, the latest compaction is taken, entries before the boundary are discarded, and the summary is wrapped into a user message with explanatory prefixes and suffixes, placed at the very front of the context.

A Concrete Example of Context Compaction

The four snapshots below track the same session: m1…m12 are message entries in chronological order, cmp1 / cmp2 are compaction entries; dimmed blocks have been replaced by summaries, and blue-highlighted parts are what gets sent to the model.

Snapshot A, before the first compaction. The log holds m1…m8; the projection is almost the entire branch plus the system prompt. At the end of the turn, usage crosses the line, entering the compaction phase.

Snapshot B, first compaction complete. The log appends cmp1; the cut point lands on m4, which becomes firstKeptEntryId. The projection becomes system + summary1 + m4…m8: m1…m3 disappear from the model's view, but lie untouched in the log, traceable at any time.

Snapshot C, continuing the conversation after compaction. New messages m9…m12 are appended as usual after cmp1; the projection swells accordingly: summary1 + m4…m12, with context once again approaching the threshold.

Snapshot D, second compaction complete. The new cut point lands on m10; this time, m4…m9 — the segment previously kept, now grown old — is to be folded. Generating summary2 follows the incremental route: summary1 as the draft, m4…m9 layered on, folded into a new summary. The projection only recognizes the latest boundary: system + summary2 + m10…m12; even cmp1 is no longer sent to the model.

Each time, only the increment — "previously kept, now grown old" — is summarized, with the old summary passed down as the draft. No matter how long the session accumulates, the amount of messages a single compaction needs to process remains bounded — the log grows linearly, but the compaction cost can stay constant.

6. How Are the Session Lifecycle and Persistence Implemented?

Finally, a summary. The main thread of Pi's harness engineering can be summed up in one sentence:

By maintaining an append-only session tree, full-chain persistence of session messages is achieved.

The tree's properties are mainly used for session forking and backtracking. This article has primarily covered how sessions are saved in the normal chain.

Let's look back at the four questions from the beginning:

Four seemingly unrelated questions, all answered by the same set of mechanisms. This is the hallmark of excellent code architecture design: few mechanisms, broad coverage.


Finally, some personal thoughts.

Before reading this part of the source code, I thought "session saving" was just finding a moment to write the message array as a JSON file — a simple fs.writeFile matter. After reading, I realized that "writing the file" is only the very last small step. What state allows writing, how to guarantee write ordering, which things should be provided by whom upon recovery — these boundary questions are the main body of the engineering.

Pi's answer isn't exactly black magic: a five-state phase machine, a pending queue, an append-only entry tree. But each piece draws the same line — separating "what can definitely be restored" from "what must be re-provided," separating "what can be written now" from "what must be accumulated." Once the lines are clearly drawn, the fact that you can open a session and continue chatting after an interruption becomes a matter of course.

Anthropic's Thariq Shihipar, in his keynote "Field Guide to Fable" at AI Engineer World's Fair 2026, named the first lesson of collaborating with new-generation models as "Unhobbling Claude" — unshackling Claude. He threw out a slightly counter-intuitive judgment:

What truly constrains the model is often not the model itself, but us — the harness we put on it, and the way we prompt it. When more capable new models arrive, if we still use harnesses and prompts designed for old models, we are essentially holding its capabilities down with our own hands. So-called unhobbling is proactively removing these self-imposed constraints, releasing capabilities that were never activated due to excessive restrictions — there has long been such a "capability overhang" between the model's true abilities and the harness.

He was talking about Claude, but the principle holds for all LLMs.

As model capabilities grow stronger, the harness must also grow lighter. With the emergence of new-generation models like Fable 5 and GPT-5.6, skills akin to superpowers have already been internalized by the models. Therefore, I have now unloaded general-purpose skills, though business-domain skills are still retained.

Pi's harness engineering is done very solidly and lightly — worth learning from.

After analyzing Pi's harness engineering, what I want to say in the end is actually the opposite: unshackle. Give LLMs more permissions, more context, more capabilities, while keeping the entire architecture in its simplest form. No need for too many examples and rules; give the Agent enough information, tools, time, and sufficient budget (^∇^), and intelligence will emerge on its own.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

MingLin

Does Pi have any obvious advantages compared to Codex and Claude Code?