Agent Harness Loop Contract
The harness is the agent. The model is a coprocessor. This lesson freezes the loop contract you can wire any model into.
Learning Objectives
- Specify an agent harness loop as a deterministic state machine with explicit transitions.
- Implement ten lifecycle hook topics that operators wire policy, telemetry, and guardrails into.
- Define two pull points where the loop yields control back to the caller and resumes on a fresh input.
- Enforce per-session budgets (turns, tool calls, wall-clock) without leaking partial state on exceeding.
- Emit a typed stream of eleven event types so downstream UIs and tracers can subscribe without inspecting the loop directly.
The frame
The loop contract is the written-down shape of an agent harness: six states, ten hook topics, two pull points, eleven event types, and a budget envelope. Everything else in the harness (tool registry, JSON-RPC transport, dispatcher, planner) plugs into it.
A coding agent that runs unattended for forty turns is not a chat loop. It is a state machine whose nodes the operator can intercept and whose edges the operator can audit. Once the contract is on paper, swapping models, tools, or policies stops being a refactor and becomes a registration call. This lesson builds that contract.
The states
The loop has six states. Five are active and one is terminal.
IDLE is the only state run() accepts, and the state the loop returns to when it pauses. DONE is the only legal exit. AWAITING_TOOL is the state that yields a tool pull point, budget pauses yield from IDLE, and every other transition is internal.
The state machine is deterministic: given the same event log, the harness re-enters the same state. That property lets you replay a session for debugging without calling the model again.
The hook topics
A hook is a callback the operator registers on a named point in the loop. The harness fires ten topics, and each topic accepts any number of subscribers. Subscribers fire in registration order. A subscriber can mutate the payload, raise to abort the turn, or return a sentinel to skip the next step.
before_plan after_plan
before_tool_call after_tool_call
before_step after_step
on_error
on_pause
on_budget_exceeded
on_completeClaude Code, Cursor, and OpenCode all converged on a similar shape by mid-2025. The names here are functional, not branded. A hook that blocks rm -rf lives in before_tool_call. A hook that ships an OpenTelemetry span lives in after_step. A hook that resumes a paused session lives in on_pause.
The pull points
A pull point is a place where the loop returns control to the caller and waits to be resumed with fresh input. The loop has two. The first is AWAITING_TOOL, when it cannot make progress without a tool result. The second is on_pause, when the budget is exhausted or a hook explicitly requests human review.
A pull point is a return, not an exception. The caller inspects the harness state, fetches whatever the harness asked for, and calls resume(payload). The harness picks up where it stopped, much like a Python generator. You choose the transport over the pull point: a keypress in a TUI, tools/call over MCP, a job poll over a queue.
The event stream
The event stream is an append-only, typed log the loop writes to at specific points in the contract. Subscribers can replay it from any offset. The eleven implemented event types are:
session.start: emitted once whenrun(goal)is calledplan.draft: emitted when the planner returns a draft planplan.commit: emitted after the draft is committed as the active planstep.start: emitted at the start of each executing stepstep.end: emitted at the end of each executing steptool.call: emitted when a tool-requiring step yields control to the callertool.result: emitted on resume with a tool resulttool.error: emitted on resume with an error, or when a hook aborts the callbudget.warn: emitted when a budget limit is reachedsession.pause: emitted when the loop yields on a pause (budget or hook)session.complete: emitted once when the loop reachesDONE
Events do not duplicate hook payloads. Hooks are imperative: they mutate and abort. Events are observational: they record and ship. Treat the two as orthogonal.
The budget envelope
The budget envelope is the set of limits a session carries: turn count, tool call count, and wall-clock seconds. Each turn increments turns by one, and each tool call increments tool calls by one. Wall-clock is checked on every state transition. When any limit is reached, the loop fires on_budget_exceeded, emits budget.warn, and transitions to IDLE with a budget-exceeded reason on the next pull point.
The budget is a yield, not a kill switch. The caller decides whether to extend the budget and resume or to close the session.
What this lesson does not do
This lesson does not call a model, register real tools, or implement a transport. Those belong to the next four lessons. Here the contract gets nailed down so the next four can plug into it without rewriting it.
The deterministic planner in main.py is a stand-in. It returns a hardcoded plan of three steps, two of which require a tool result. The loop is the point, not the plan.
I'd keep hooks and events strictly separate, even when it looks convenient to let a hook emit an event or an event handler mutate state. The lesson treats hooks as imperative and events as observational, and I'd hold that line so a tracer can subscribe to the stream without affecting the loop.
My bet is that replay from an event log deserves its own test early on, beyond the same-inputs check already in the suite. If the same event log re-enters the same state, you can debug a session without calling the model again, and I'd want to know that holds before the next four lessons build on this loop.
How to read the code
HarnessLoop is the main class: it holds state, fires hooks, and emits events. Budget tracks limits. Event is the typed envelope on the stream. HookRegistry is the dispatch table. _transition is the only function that changes state, so the state machine invariants live in one place.
Read main.py top to bottom, then code/tests/test_loop.py. The tests pin every transition and every hook firing order.
Going further
The hard part of building a harness in production is making the contract enforceable, not drawing the state machine. The contract has to survive a hot reload of the planner. It has to survive a tool that returns malformed JSON. It has to survive a hook that raises in before_tool_call two-thirds of the way through a forty-turn session. The tests in this lesson exercise those failure modes. Run them, break them, add cases.
The next lesson adds the tool registry, then the JSON-RPC transport, then the dispatcher. By lesson twenty-four, the loop in this file will be running a real plan against real tools with real budgets enforced.
Code
- main.py (python, 344 lines)
- tests/test_loop.py (python, 209 lines)