TAI BUI
Back to curriculum
CapstonePython

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.

TIME: ~90 minutesPREREQUISITES: Phase 13 lessons 01-07, Phase 14 lesson 01

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.

Check: The frame0/1
1. What is the loop contract in an agent harness?Define

The states

The loop has six states. Five are active and one is terminal.

Mermaid diagram

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.

Check: The states0/6
1. Which state is the only one run() accepts?Define
2. In which state does the loop hand a tool request to the caller and wait?Define
3. Why model a long-running agent loop as an explicit state machine rather than a while loop with flags?Explain
4. Why can a session be replayed for debugging without calling the model again?Explain
5. Two runs with the same planner and goal produce different event sequences. Which property of the contract is broken?Analyze
6. A forty-turn session failed in production and you must debug it without paying for model calls again. What do you keep?Design

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.

text
before_plan         after_plan
before_tool_call    after_tool_call
before_step         after_step
on_error
on_pause
on_budget_exceeded
on_complete

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

Check: The hook topics0/7
1. What is a hook in this harness?Define
2. A team wants one OpenTelemetry span shipped for each finished step. Which hook topic fits?Apply
3. Your security team wants to block any tool call containing rm -rf before it executes. Where should you register that hook?Apply
4. What happens when a hook raises HookAbort in before_tool_call?Apply
5. In the demo, run('ship the release notes') returns the first tool pull point with all ten topics hooked. Which topic fired most recently?Apply
6. A before_tool_call hook raises HookAbort on every call, and the plan has two tool steps. What does run() return?Apply
7. Your rm -rf blocker is registered on after_step, yet a destructive command still ran. What is the most likely cause?Analyze

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.

Check: The pull points0/5
1. What is a pull point?Define
2. Why is a pull point a return value rather than an exception?Explain
3. The loop is in AWAITING_TOOL and the caller calls resume(None). What happens?Apply
4. The event log ends with tool.call for step 2, and the harness state is AWAITING_TOOL with nothing logged after. What explains it?Analyze
5. You must require human approval before any tool that deletes data runs. Which design fits the contract?Design

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 when run(goal) is called
  • plan.draft: emitted when the planner returns a draft plan
  • plan.commit: emitted after the draft is committed as the active plan
  • step.start: emitted at the start of each executing step
  • step.end: emitted at the end of each executing step
  • tool.call: emitted when a tool-requiring step yields control to the caller
  • tool.result: emitted on resume with a tool result
  • tool.error: emitted on resume with an error, or when a hook aborts the call
  • budget.warn: emitted when a budget limit is reached
  • session.pause: emitted when the loop yields on a pause (budget or hook)
  • session.complete: emitted once when the loop reaches DONE

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.

Check: The event stream0/6
1. Which event is emitted when a tool-requiring step yields control back to the caller?Define
2. What is the event stream?Define
3. Why are hooks and events kept as separate concerns?Explain
4. Why can a UI subscribe to the event stream without inspecting the loop directly?Explain
5. A hook aborts a tool call before it is dispatched. Why does the stream still record a tool.error?Explain
6. In the demo, in what order does the first tool step emit its events, counting tool.result once the caller resumes it?Apply

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.

Check: The budget envelope0/7
1. What does a session's budget envelope contain?Define
2. When the wall-clock budget is exceeded, what is the next state?Explain
3. What does it mean that the budget is a yield, not a kill switch?Explain
4. A budget has max_turns=1 and the plan is three steps with no tools. What does run() return?Apply
5. A budget has max_tool_calls=1 and the plan has two tool steps. The caller resumes the first pull point with a result. What comes back?Apply
6. In the demo code, max_turns is 1 and the plan has three steps with no tools, so run() pauses with budget_exceeded:turns. The caller then calls resume() with no payload twice. What do the two calls return?Apply
7. A session ends with budget.warn then session.pause, and an operator reports that it crashed. The process is alive and the state is IDLE. What is going on?Analyze

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.

Check: What this lesson does not do0/1
1. Why does this lesson stub out the planner instead of calling a model?Explain
Author's Note

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.

Check: How to read the code0/1
1. Why does it matter that _transition is the only function that changes state?Explain

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.

Warm-up0/3
1. In a basic agent loop, what has to happen after the model asks for a tool before the loop can continue?Warm-up
2. Why would an unattended agent run need a cap on turns or elapsed time?Warm-up
3. What happens when a Python generator reaches a yield statement?Warm-up

Code