Map each agent-core hook by what it can still decide
Record the order of the core hooks on 1.0.4 and use the seam matrix to tell a refusal from a rewrite from an observation.
A hook is a real seam only for decisions it can still make when it fires.
beforeToolCall is the one hook that can refuse an effect; afterToolCall rewrites
the story but cannot undo the deed. This page reproduces the observed order on
pi-agent-core 1.0.4, then the seam matrix that says which decisions are still
cheap where.
The order, observed
One tool-using run, a recorder on every hook — the exact assertion from the test:
assert.deepEqual(order, [
// turn 1: request preparation, then the provider call
"prepareRequest",
"transformContext",
"convertToLlm",
// the model asked for a tool; execution is announced before the gate runs
"tool.execute starts",
"beforeToolCall",
"afterToolCall",
"finishTurn",
"turn_end",
// turn 2: the same preparation, then the final answer
"prepareRequest",
"transformContext",
"convertToLlm",
"finishTurn",
"turn_end",
]);Two things are not what you would guess: prepareRequest runs before
transformContext, and tool.execute starts is announced before
beforeToolCall decides — a UI that draws “running…” on that event draws it for
refused calls too. Group the sequence by what has happened:
BEFORE THE REQUEST prepareRequest, transformContext, convertToLlm
nothing has been asked of the model yet
BEFORE THE EFFECT beforeToolCall
the model decided; nothing has run
AFTER THE EFFECT afterToolCall, finishTurn, prepareNextTurn(...)
the tool ran; its consequences existWhat each hook can still decide
The measured rows are observed in seams.test.ts; the “no” / absent-field cells
are readings of the declared result types, not runs.
| Hook | Runs | Can transform | Can refuse | Effect already happened |
|---|---|---|---|---|
prepareRequest |
before each request | context, model, thinking level | no veto field | no |
transformContext |
before conversion | messages (sent, not stored) | no | no |
beforeToolCall |
after validation, before execute() |
no args field | yes | no |
afterToolCall |
after execute() |
the result | no | yes |
finishTurn |
after the turn’s tools | no | end or continue | yes |
The measured cases: a blocked call leaves the tool body’s counter at 0; a rewrite
still shows the counter at 1; a transform sends one user message while the
transcript keeps two; finishTurn can stop at one request or force a second;
prepareRequest swaps the model for one call; and a nested call through
runToolCall() passes the same gate, so a blocked one never executes.
Mechanism and limitations
Documented: the nine AgentOptions hooks and their result types, that
beforeToolCall runs after tool_execution_start and argument validation,
terminate’s batch rule, and runToolCall()’s contract (pi-agent-core README and
types.d.ts, 1.0.4). Observed: the order, the block-prevents-effect and
rewrite-does-not-undo behaviours, the transform-sends-not-stores behaviour, and the
model swap. A mutation of args inside beforeToolCall also lands on 1.0.4 —
it works, nothing declares it, so it is not something to build a policy on.
Proposed: the grouping of the hooks and the advice to put prevention inside the
tool or beneath the process.
Absent fields are not proven impossibilities; they are statements about the type. No hook can undo an effect, because by the time any hook fires the effect is the tool’s own business. This example runs the core under a scripted provider, so it says nothing about a real model’s tool choices.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.