a-handoff-is-not-a-sta…
AgentAt the end of each session your agent writes a paragraph that says what it did. The next session still spends its first half hour…
Read this part of the harness loop. A subscriber on `before_tool_call` raises `HookAbort` on the second step of a three-step plan, because the tool name is on a deny list. Trace what the loop does next: which events it emits, which hooks fire, which state it enters, and what the
Claude Code
$ npx -y skills add rohitg00/ai-engineering-from-scratch --agent claude-code
Cursor
Cursor also loads subagents from .claude/agents, so copy this file there to use it in Cursor.
id: a-hook-aborts-the-tool-call title: Read the harness, a hook aborts a tool call mid-plan card: agents kind: code-reading level: 3 roles: [agentic-ai-engineer, ai-evaluation-reliability-engineer] rounds: [coding, debugging] tags: [harness, hooks, state-machine, event-stream, pull-point] minutes: 12 lessons: [phases/19-capstone-projects/20-agent-harness-loop-contract] manuals: [] practice: [] cheatsheet: [agent-harness, agent-state-graphs] related: [agents/budget-exhausted-looks-like-an-answer] updated: 2026-11-03
Read this part of the harness loop. A subscriber on `before_tool_call` raises `HookAbort` on the second step of a three-step plan, because the tool name is on a deny list. Trace what the loop does next: which events it emits, which hooks fire, which state it enters, and what the caller receives.
step = self._plan[self._cursor]
self.hooks.fire("before_step", {"step": step})
self._emit("step.start", {"step_id": step.id, "desc": step.description})
if step.requires_tool:
try:
self.hooks.fire("before_tool_call", {"step": step})
except HookAbort as exc:
step.error = f"hook_abort:{exc}"
self._emit("tool.error", {"step": step.id, "error": step.error})
self.hooks.fire("on_error", {"step": step, "error": step.error})
self._transition(State.REFLECTING)
return self._step()
self.budget.tool_calls += 1
self._emit("tool.call", {"step": step.id, "tool": step.tool_name, "args": step.tool_args})
self._transition(State.AWAITING_TOOL)
self._emit("step.end", {"step_id": step.id, "outcome": "awaiting_tool"})
self.hooks.fire("after_step", {"step": step, "outcome": "awaiting_tool"})
return PullRequest(reason="tool_call", state=self.state, payload={...})The abort is handled inside the loop, not thrown to the caller. The loop records the error on the step, emits `tool.error`, fires `on_error`, moves to the reflecting state and calls itself again. Reflecting advances the cursor and counts a turn, so the denied step is skipped, not retried, and the loop goes on to the third step. The caller never sees the abort as an exception: it sees the next pull request for the third step's tool call or the completion, or a pause with a budget-exceeded reason if a budget limit ran out first, and it can read the denied step in the event stream.
1. `before_step` fires and `step.start` is emitted for step two. 2. `before_tool_call` fires. The subscriber raises `HookAbort`. 3. The loop writes `hook_abort:<reason>` into `step.error`. 4. It emits `tool.error` with the step id and the error text. 5. It fires `on_error` with the step and the error. 6. It transitions to `REFLECTING`. The transition table allows executing to reflecting. 7. It calls `_step()` again. In the reflecting branch the cursor moves to step three and the turn count goes up by one. 8. Step three requires a tool, so the loop fires its hooks, counts a tool call, emits `tool.call`, transitions to `AWAITING_TOOL`, emits `step.end`, fires `after_step`, and returns a pull request with reason `tool_call`.
Hooks are imperative and can mutate, abort or skip. Events are observational and only record. The abort is a hook decision, and the record of it is an event, so a tracer that subscribes to the stream sees the denial without inspecting the loop. The state machine is deterministic, so a replay of the same event log re-enters the same state without calling a model.
The pull request it receives is for step three. To see the denial it reads the event stream for `tool.error` with the `hook_abort` prefix, or it registers an `on_error` subscriber. A production harness would also put the denied step into the handoff's failed attempts.
The [harness loop contract](../../../phases/19-capstone-projects/20-agent-harness-loop-contract/) defines the states, the hook topics, the event stream and this
At the end of each session your agent writes a paragraph that says what it did. The next session still spends its first half hour…
The task given to the agent was to fix one login bug. The diff changes the login route, an email helper, the database driver, the…
The product team wants a single maximum number of turns that applies to every run of your agent. The agent handles one-line…
Read this run method from the toy agent loop. A caller shows the returned string to the user as the answer. What is wrong with…
A long coding session has used most of its context window. The runtime offers to summarize the older turns and keep going. Your…
A tool is registered with this schema, and the model sends the four argument sets below. For each one, say what the registry…
Reviews the repository's shared Claude Code configuration (.claude/settings.json, CLAUDE.md, .claude/rules/, .mcp.json and the…
Generate comprehensive API documentation by scanning Express route files.
Advisor to a busy principal. Optimizes for the user's time, attention, and decision quality. One of the starter-kit council…