ππ Agent
Chapter 2 / 21

AgentHarness & Durable Session

Above the loop: orchestration & durability

Result<T,E> never throws — turn snapshot vs harness config
No simulation scenario available for this chapter

Deep Dive

~17 min read·17051 chars

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

FileLinesResponsibility
packages/agent/src/harness/agent-harness.ts1084Central orchestration: Phase lock, turn snapshots, 3 queues, Hooks, event dispatch
packages/agent/src/harness/types.ts958Result / AgentHarnessError / config types
packages/agent/docs/durable-harness.mdDurability 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

SubsystemResponsibility
Phase state machineMutual exclusion lock guaranteeing atomicity of single operations
Config managementRead/write of model / tools / thinkingLevel / resources / streamOptions
Three-queue systemsteerQueue / followUpQueue / nextTurnQueue, with drain modes controlling message injection timing
Hook systemCallback hooks (8, can influence behavior) + notification events (read-only observation), defined externally
Session managementPersistence and recovery of the entire conversation — records messages, config changes, supports tree branching

Phase State Machine

StateMeaningEntered byExited by
idleFree; structural operations acceptedagent_end / compact / navigateTree completionprompt / compact / navigateTree invocation
turnExecuting a prompt runprompt / skill / promptFromTemplateagent_end
compactionExecuting session compactioncompactCompaction complete
branch_summaryExecuting branch navigation summarynavigateTreeNavigation 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 itemContentPersisted?
modelCurrent LLM model (with provider / api / baseUrl)Yes (session entry)
thinkingLevelReasoning level: off / low / medium / highYes
activeToolNamesCurrently enabled tool name listYes (active_tools_change entry)
resourcesSkills and prompt templatesNo (memory only)
streamOptionsTransport / timeout / retry / auth settingsNo (memory only)
systemPromptStatic string or async factory, regenerated each turnNo

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
QueueMessage sourceConsumption timing
steerQueueUser types mid-run, wants to interrupt the agent's current directionDrained after each turn completes, before the next LLM call
followUpQueueUser types mid-run, but not urgentDrained only when the agent is about to stop (no toolCall + no steering)
nextTurnQueueExternal nextTurn() injectionMerged 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):

HookCan modifyPurpose
before_agent_startmessages, systemPromptInject messages / override system prompt before prompt
contextmessagesTransform message list (prune/inject context)
before_provider_requeststreamOptionsModify transport/timeout/retry options per request
before_provider_payloadpayloadModify the LLM request body
tool_callblock, reasonIntercept tool calls; model receives error result and retries
tool_resultcontent, isError, terminateModify tool results (e.g., hide sensitive content)
session_before_compactcancel, compactionCancel compaction or provide custom summary
session_before_treecancel, summaryCancel tree navigation or provide custom branch summary

Notification events (observed via subscribe(listener), read-only, cannot modify behavior):

EventDescription
after_provider_responseAfter provider response; observe status / headers
session_compactSession compaction complete
session_treeTree navigation complete
model_update / thinking_level_update / resources_update / tools_updateConfig change notifications
queue_updateQueue state change (steer / followUp / nextTurn)
save_pointSave point (turn complete + persisted)
abortAbort complete
settledFully settled (phase back to idle)
retry_scheduled / retry_attempt_start / retry_finishedRetry 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 writtenTriggerWritten to
User/assistant/tool messagesAfter each LLM call or tool execution completesSession message entries
Model switch recordsWhen user switches modelsSession config-change entries
Thinking level changesWhen user adjusts reasoning levelSession config-change entries
Tool toggle recordsWhen user toggles tool sets/active toolsactive_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?

Scenariotry/catch ProblemResult Advantage
User cancel (abort)Stack trace noiseerr(AbortedError) is a normal branch
Token overflowEasy to miss catchType system forces handling
Permission deniedConfused with real bugsNamed code distinguishes business/system errors
Multi-step compositionNested try hellflatMap / 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

  1. 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.

  2. Error classification drives UI: The TUI displays based on error.code — "busy" shows a spinner, "overflow" triggers auto-compaction, "aborted" is handled silently.

  3. 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.

  4. 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

DirectionChapterRelationship
PrerequisiteCh01 Agent LoopThe underlying loop driven by Harness (runAgentLoop)
NextCh03 CompactionHarness triggers compaction on overflow
NextCh04 Session TreeCommitted state written to the JSONL tree
NextCh11 AgentSessionFurther coding-logic wrapping on top of Harness
NextCh16 Interactive TUITerminal consumer of queues + Hooks

Check Your Understanding

  1. If turn snapshots were removed and the user switched models mid-execution, what specific problem would occur?
  2. 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?
  3. After the tool_call Hook returns { block: true, reason: "..." }, what does the model see? Why is this design better than simply skipping the tool call?
  4. 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.