AgentHarness & Durable Session
Above the loop: orchestration & durability
“Result<T,E> never throws — turn snapshot vs harness config”
Deep Dive
Result<T,E> never throws — turn snapshots isolate config from in-flight requests
Learning Objectives
After this chapter you will be able to:
- Explain how "config / turn snapshot / committed state" three-state separation prevents concurrent pollution
- Articulate why Result<T,E> is superior to try/catch in Agent scenarios
- Describe the three-queue system (steer / followUp / nextTurn): consumption timing and drain modes
- Distinguish callback Hooks from notification events and their responsibility boundaries
- Understand how session management's read side (buildContext) and write side (save-point flush) cooperate
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/agent/src/harness/agent-harness.ts | 1084 | Central orchestration: Phase lock, turn snapshots, 3 queues, Hooks, event dispatch |
packages/agent/src/harness/types.ts | 958 | Result / AgentHarnessError / config types |
packages/agent/docs/durable-harness.md | — | Durability design doc (semi-persistent model) |
Design Motivation: Why a Harness Layer?
Problem: Chapter 1's agentLoop is a pure "loop engine" — it knows nothing about session persistence, token budgets, or crash recovery. Cramming all that into the loop would make it untestable and irreplaceable.
Analogy: agentLoop is the engine; AgentHarness is the car. The engine just turns; the car handles throttle response, dashboard, airbags, and service records.
Delegate, don't cohere — the Harness never makes LLM calls or executes tools itself; all "doing" responsibilities are delegated downward:
- LLM streaming calls → delegated to pi-ai streamFn (with 40+ provider support, retries, stream parsing)
- Turn loop + tool execution → delegated to runAgentLoop (Chapter 1's stateless loop)
Benefit: the orchestration layer stays thin with a single responsibility — only assertions, snapshots, routing, and settlement. Lower layers can evolve independently (e.g., adding providers or changing loop strategies) without affecting orchestration logic.
Harness responsibility boundaries:
- Before the loop: resolve resources (model, tools, system prompt), create turn snapshot
- During the loop: subscribe to events, write session, manage abort
- After the loop: commit state, trigger compaction, release lock
Five Managed Subsystems
| Subsystem | Responsibility |
|---|---|
| Phase state machine | Mutual exclusion lock guaranteeing atomicity of single operations |
| Config management | Read/write of model / tools / thinkingLevel / resources / streamOptions |
| Three-queue system | steerQueue / followUpQueue / nextTurnQueue, with drain modes controlling message injection timing |
| Hook system | Callback hooks (8, can influence behavior) + notification events (read-only observation), defined externally |
| Session management | Persistence and recovery of the entire conversation — records messages, config changes, supports tree branching |
Phase State Machine
| State | Meaning | Entered by | Exited by |
|---|---|---|---|
| idle | Free; structural operations accepted | agent_end / compact / navigateTree completion | prompt / compact / navigateTree invocation |
| turn | Executing a prompt run | prompt / skill / promptFromTemplate | agent_end |
| compaction | Executing session compaction | compact | Compaction complete |
| branch_summary | Executing branch navigation summary | navigateTree | Navigation complete |
idle ──prompt/skill──→ turn ──agent_end/abort/error──→ idle
idle ──compact──→ compaction ──done──→ idle
idle ──navigateTree──→ branch_summary ──done──→ idle
The state machine guarantees atomicity of each operation — the prompt() entry asserts phase === "idle"; if not idle, it throws AgentHarnessError("busy"). Lighter than a mutex, and lets the UI show "thinking" based on phase.
Three-State Separation
┌─────────────────────────────────────────────────────┐
│ Harness Config (persists across turns) │
│ model, tools, systemPrompt, retryPolicy... │
│ ─── user may modify between turns ─── │
├─────────────────────────────────────────────────────┤
│ Turn Snapshot (immutable copy for this run) │
│ frozen at createTurnState() │
│ ─── mid-run config changes don't affect flight ─── │
├─────────────────────────────────────────────────────┤
│ Committed State (only committed on success) │
│ session entries, model-switch records │
│ ─── on failure, roll back to last commit point ─── │
└─────────────────────────────────────────────────────┘
Why not a single state? Imagine the user switches models mid-execution — if the in-flight provider request suddenly sees the new model, it produces inconsistent responses. Snapshot isolation makes each turn a "transaction."
Config snapshot details:
| Config item | Content | Persisted? |
|---|---|---|
| model | Current LLM model (with provider / api / baseUrl) | Yes (session entry) |
| thinkingLevel | Reasoning level: off / low / medium / high | Yes |
| activeToolNames | Currently enabled tool name list | Yes (active_tools_change entry) |
| resources | Skills and prompt templates | No (memory only) |
| streamOptions | Transport / timeout / retry / auth settings | No (memory only) |
| systemPrompt | Static string or async factory, regenerated each turn | No |
These configs are snapshotted at createTurnState(); mid-run setters don't affect the current provider request. Changes to model / thinkingLevel / activeTools are written to session immediately when idle, or queued for the next save point when busy.
The Three-Queue System
Their positions in the run lifecycle:
User input ──→ prompt entry ──→ turn loop
↑ │
nextTurnQueue LLM call → tool execution
(injected at │
next prompt) steerQueue has messages?
├─ yes → inject into next turn (immediate intervention)
└─ no, and no tool execution
followUpQueue has messages?
├─ yes → inject and continue (catch at the end)
└─ no → agent_end
| Queue | Message source | Consumption timing |
|---|---|---|
| steerQueue | User types mid-run, wants to interrupt the agent's current direction | Drained after each turn completes, before the next LLM call |
| followUpQueue | User types mid-run, but not urgent | Drained only when the agent is about to stop (no toolCall + no steering) |
| nextTurnQueue | External nextTurn() injection | Merged before user message at next prompt; system-level injection |
Drain modes control how many messages are taken per consumption:
- one-at-a-time (default): take only the earliest message each time; the rest wait — avoids flooding the model with too many messages at once
- all: take all messages at once — suits batch-instruction scenarios
Connection to Chapter 1: The stateless loop's getSteeringMessages() / getFollowUpMessages() callbacks are actually the Harness queues' drain functions — the loop knows nothing about queues; it just fetches messages through config-injected callbacks.
Hook System
Two categories with different registration mechanisms:
Callback hooks (handlers can return results that influence behavior):
| Hook | Can modify | Purpose |
|---|---|---|
| before_agent_start | messages, systemPrompt | Inject messages / override system prompt before prompt |
| context | messages | Transform message list (prune/inject context) |
| before_provider_request | streamOptions | Modify transport/timeout/retry options per request |
| before_provider_payload | payload | Modify the LLM request body |
| tool_call | block, reason | Intercept tool calls; model receives error result and retries |
| tool_result | content, isError, terminate | Modify tool results (e.g., hide sensitive content) |
| session_before_compact | cancel, compaction | Cancel compaction or provide custom summary |
| session_before_tree | cancel, summary | Cancel tree navigation or provide custom branch summary |
Notification events (observed via subscribe(listener), read-only, cannot modify behavior):
| Event | Description |
|---|---|
| after_provider_response | After provider response; observe status / headers |
| session_compact | Session compaction complete |
| session_tree | Tree navigation complete |
| model_update / thinking_level_update / resources_update / tools_update | Config change notifications |
| queue_update | Queue state change (steer / followUp / nextTurn) |
| save_point | Save point (turn complete + persisted) |
| abort | Abort complete |
| settled | Fully settled (phase back to idle) |
| retry_scheduled / retry_attempt_start / retry_finished | Retry lifecycle |
Design trade-off between callback vs notification: Callback hooks can change behavior (intercept, modify) but introduce execution-order and error-propagation concerns; notifications are read-only and can be safely broadcast in parallel to any number of subscribers. pi strictly separates the two to avoid the classic bug of "an observer accidentally mutating state."
Session Management: Read Side and Write Side
Responsible for persistence and recovery of the entire conversation — records everything that happens, ensuring context isn't lost when resuming.
Read side: Before each turn starts, reads history messages and latest config from disk JSONL, restoring agent-comprehensible context:
session.buildContext()
├── getBranch() → getPathToRootOrCompaction(leafId) ← trace from leaf to latest compaction (or root)
├── deriveSessionContextState() ← extract latest config
├── buildContextEntries() ← compaction-aware: keep summary or skip compressed entries
└── sessionEntryToContextMessages() ← convert to AgentMessage[]
├── message → [AgentMessage]
├── compaction → [CompactionSummaryMessage, ...retainedTail]
├── branch_summary → [BranchSummaryMessage]
└── custom → entryProjectors[customType] ?? []
Write side: Continuously records new content during conversation. But writing to disk while a turn is running could cause conflicts, so write requests are buffered and flushed together at safe points between turns (save points):
| What's written | Trigger | Written to |
|---|---|---|
| User/assistant/tool messages | After each LLM call or tool execution completes | Session message entries |
| Model switch records | When user switches models | Session config-change entries |
| Thinking level changes | When user adjusts reasoning level | Session config-change entries |
| Tool toggle records | When user toggles tool sets/active tools | active_tools_change entries |
Branching: Sessions are organized as a tree structure, supporting forks from any node into independent branches; each read traces from the current leaf back to root (→ Chapter 4).
Data safety: On exceptions, the finally block performs a fallback flushPendingSessionWrites(), guaranteeing queued writes are never lost.
Result<T,E>: Errors as Values
// Core type — the entire harness layer never throws
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
export const ok = <T>(v: T): Result<T, never> => ({ ok: true, value: v })
export const err = <E>(e: E): Result<never, E> => ({ ok: false, error: e })
// Named errors — not strings, structured types
class AgentHarnessError extends Error {
constructor(
public code: "busy" | "aborted" | "overflow" | "unknown",
message: string,
public cause?: unknown
) { super(message) }
}
Why not try/catch?
| Scenario | try/catch Problem | Result Advantage |
|---|---|---|
| User cancel (abort) | Stack trace noise | err(AbortedError) is a normal branch |
| Token overflow | Easy to miss catch | Type system forces handling |
| Permission denied | Confused with real bugs | Named code distinguishes business/system errors |
| Multi-step composition | Nested try hell | flatMap / andThen chaining |
getOrThrow() is only used at the outermost layer of public mutators (like prompt()) — internally everything flows as Result.
The Full Flow
AgentHarness.prompt(text)
│
├── [Phase lock] assert phase === "idle", set to "turn"
│
├── createTurnState() ← config snapshot isolation
│ ├── session.buildContext() ← Session read: JSONL → AgentMessage[]
│ ├── resolve resources (skills/templates)
│ ├── invoke systemPrompt callback
│ └── snapshot model/thinkingLevel/tools/streamOptions
│
├── executeTurn(turnState, text)
│ ├── [Hook] before_agent_start ← can inject messages/override systemPrompt
│ ├── createStreamFn()
│ │ └── [Hook] before_provider_request / before_provider_payload
│ ├── runAgentLoop() ← delegate to stateless loop (Chapter 1)
│ └── handleAgentEvent(events) ← event hub
│ ├── message_end → session.appendMessage + emit
│ ├── turn_end → flushPendingSessionWrites + emit(save_point)
│ └── agent_end → flush + phase="idle" + emit(settled)
│
└── finally
├── flushPendingSessionWrites() ← fallback flush once more
└── finishRunPromise() ← release waitForIdle waiters
Durable Harness: Crash Recovery Semantics
Semi-persistent model:
- Session owns persistent state → written to JSONL (→ Chapter 4)
- Harness owns non-runtime config → in-memory, loss on crash is harmless
Recovery after crash: Rebuild context from the last committed state in SessionTree. Uncommitted turns are naturally lost — this is the desired semantics (like a database WAL: committed survives, uncommitted rolls back).
Engineering Insights
-
1084 lines of "glue": The Harness produces no intelligence itself; it just correctly glues loop, session, compaction, and retry together. But "correct gluing" is precisely the hardest part of production systems.
-
Error classification drives UI: The TUI displays based on
error.code— "busy" shows a spinner, "overflow" triggers auto-compaction, "aborted" is handled silently. -
Relationship to AgentSession: AgentSession (→ Chapter 11) is coding-agent's further wrapping of Harness, adding skills, prompt templates, idle compaction, and other coding-specific logic.
-
Queues are the interface for product capability: steer/followUp aren't just technical details — they enable the TUI's "user can interrupt anytime while the agent works" interaction model. Without queues, an agent is just a batch job.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch01 Agent Loop | The underlying loop driven by Harness (runAgentLoop) |
| Next | Ch03 Compaction | Harness triggers compaction on overflow |
| Next | Ch04 Session Tree | Committed state written to the JSONL tree |
| Next | Ch11 AgentSession | Further coding-logic wrapping on top of Harness |
| Next | Ch16 Interactive TUI | Terminal consumer of queues + Hooks |
Check Your Understanding
- If turn snapshots were removed and the user switched models mid-execution, what specific problem would occur?
- A user types two steering messages while the agent is running, with drain mode set to one-at-a-time — when is each one consumed?
- After the
tool_callHook returns{ block: true, reason: "..." }, what does the model see? Why is this design better than simply skipping the tool call? - During crash recovery, why is "losing uncommitted turns" correct semantics rather than a bug?
Key Takeaways
- Five subsystems: Phase lock, config snapshots, 3 queues, Hooks, session management — the orchestration layer only "glues"
- Config / turn snapshot / committed three-state separation = concurrency safety + rollback
- steer intervenes immediately, followUp catches at the end, nextTurn injects at system level — drain modes control consumption granularity
- Callback hooks can modify behavior; notification events are read-only — strict separation prevents side-effect leakage
- Session read side buildContext + write side save-point flush = no disk I/O during turns
- Result<T,E> replaces exceptions: cancel, overflow, permission denial are all normal control flow
This article is based on source analysis of earendil-works/pi main branch.