Design 0003: lifecycle hooks¶
Status: draft · Scope: the hook plugin SPI, the dispatch and
ordering contracts at each lifecycle point, the scoped context handed to
hooks, hook sidecar artifacts, the debrief reference hook that ships
with the framework, and the abstract StateProbeHook base for continuous
isolation checks. Non-goals: removing or deprecating the reset
canary's state_probe kwarg, per-hook config plumbing beyond environment
variables, and anything that lets a hook's output gate a verdict or
modify steering.
Wind Tunnel runs have a fixed skeleton: provision, reset, inject, score, repeat. This document adds named observation points to that skeleton so that code which needs to watch a run — not change it — can be written as a plugin instead of a fork. A hook observes the run at a declared lifecycle point and emits artifacts beside the trace. It never scores, never gates, and never mutates the world being judged.
Why hooks, and why now¶
This is an abstraction extracted from three concrete consumers, not a plugin system built on spec:
- Debrief — after the score is computed and while the session is still alive, send the verdict back to the agent under test and record its self-report as an artifact. The rationale: the model has privileged observability of the environment. It is the only component that saw every tool result, every error string, every weird response shape from inside the run. Self-reports are most reliable on exactly the failure class that dominates real triage — wiring and harness faults — because there the model is quoting concrete evidence it received, not reconstructing intent.
- State probes —
run_reset_canary(..., state_probe=...)is already a hook trying to be born as a bespoke kwarg on one API. As a lifecycle hook, isolation gets asserted on every run of a real pack instead of only insidewt doctor. - Prompt-surface capture — shipped in 0.6 as purpose-built runner
code (
Trace.surface). It is shaped exactly like a hook: at a lifecycle point, observe the run, freeze what you saw into an artifact. Had hooks existed first, it would have been one.
Three consumers, one shape: at lifecycle point X, observe the run and emit an artifact. That clears the emergence-then-abstraction bar.
The reason this must live in the framework rather than in wrappers is
ordering. A debrief is only sound if it runs after the scorer has
captured fixture and world state (so the extra turn cannot perturb what
is being judged) and before the next state reset (so the session
still has the run in context). Only the runner can guarantee that window;
a wrapper around run_scenario() never sees it.
The plugin SPI¶
A hook is an object — typically a class instance — that implements any subset of the lifecycle methods:
class Hook:
"""Base class for lifecycle hooks. Subclass and override the points
you care about; unimplemented points cost nothing."""
name: str # stable identifier; used in artifact filenames and --hook
def on_provisioned(self, ctx): ...
def on_run_start(self, ctx): ...
def on_run_scored(self, ctx): ...
def on_run_end(self, ctx): ...
def on_scenario_end(self, ctx): ...
def on_pack_end(self, ctx): ...
One entry point registers one hook plugin, not one callable per
lifecycle point. Real hooks span multiple points with shared state (a
state probe wants an on_run_start baseline and an on_run_end
assertion; a debrief may want a per-pack summary), and activation should
be one user-facing name. The framework introspects which methods the
plugin actually defines, pytest-plugin style, and dispatches only those.
name must be a stable, filesystem-safe slug ([a-z0-9_-]+). It is the
artifact discriminator and the activation token; renaming it is a
breaking change for any tooling that reads the hook's artifacts.
Registration and activation¶
Discovery follows the existing plugin pattern exactly. Built-in hooks
live in an internal registry (as built-in runtimes do); external hooks
register under a windtunnel.hooks entry-point group, the idiomatic
third leg beside windtunnel.runtimes and windtunnel.scenario_packs:
[project.entry-points."windtunnel.hooks"]
acme_state_probe = "acme_bench.hooks:AcmeStateProbeHook"
The registered object may be a Hook instance or a class; a class is
instantiated with no arguments (same contract as runtime plugins).
Activation is explicit, per invocation:
wt run --hook debrief [--hook other] ...
Installing a package must never silently change bench behavior. Gating
benches need byte-comparable artifacts unless a hook was deliberately
enabled, so there is no "auto-enable on install" path and no config file
that turns hooks on ambiently. No --hook flag → zero hooks → byte-for-
byte the behavior of a hook-less Wind Tunnel. An unknown hook name is a
hard CLI error listing the available names (built-ins plus entry points).
Hook-specific knobs are environment variables, namespaced
WT_<HOOKNAME>_* (the convention WT_INJECT_URL established). 0.7.0
deliberately ships no per-hook config-file plumbing.
Lifecycle points and ordering contracts¶
The per-run sequence in run_scenario() / _run_once() today, with hook
points marked:
provision handle; check world preconditions
→ on_provisioned
for each run:
reset_state()
mint session_id; reset probes; capture surface
→ on_run_start (post-reset, pre-first-inject)
inject turns; collect replies
capture MCP evidence + fixture/world observations
build Trace (evidence frozen)
compute Score
→ on_run_scored (session alive; next reset has NOT happened)
→ on_run_end (the run's slot is closing)
teardown handle
→ on_scenario_end (AggregateResult available)
...CLI sweep loop completes all scenarios...
→ on_pack_end (all aggregates available)
| Point | Fires | Guarantees |
|---|---|---|
on_provisioned |
once per scenario batch, after provision() and world-precondition checks |
handle exists; no run has started; no trace/score in context |
on_run_start |
per run, after reset_state() and surface capture, before the first inject |
session is fresh; session_id minted; nothing injected yet |
on_run_scored |
per run, after Score is computed |
trace evidence and fixture/world state already captured; the session is still alive and the next reset has not happened; ctx.converse() is valid only here |
on_run_end |
per run, immediately after on_run_scored dispatch completes |
the run's result is final; the next lifecycle event is the next run's reset or the scenario teardown; observation only |
on_scenario_end |
once per scenario, after the run loop, before teardown() returns the result |
ctx.aggregate holds the scenario's AggregateResult |
on_pack_end |
once per wt run invocation, after the sweep loop |
all completed scenario aggregates available |
on_run_scored is the load-bearing contract and the reason hooks are
framework machinery. Note the asymmetry with the original sketch of
"post-score, pre-reset": in the current runner there is no post-run
reset — the next reset_state() happens at the top of the next loop
iteration (run_scenario in windtunnel/api/runner.py).
The guarantee is therefore stated as the next reset has not happened,
which is the property a debrief actually needs, rather than a claim about
where reset code sits.
A note on naming: earlier sketches had a single on_pack_end (aggregate
available). In the shipped runner the aggregate (AggregateResult) and
the handle teardown are per-scenario, while "everything finished" is
a CLI-sweep-level event; conflating them would hand hooks either a fake
aggregate or a dead handle. The design splits them: on_scenario_end
(runner-fired, aggregate in context) and on_pack_end (CLI-fired, all
aggregates). Most hooks implement neither.
Hooks fire in activation order (the order of --hook flags), and
each point's dispatch completes before the runner proceeds. Hooks are
synchronous; the framework owns the deadline (§ Failure containment).
The scoped context¶
Hooks do not receive the raw AgentHandle. They receive a HookContext
that makes ordering violations structurally impossible — there is no
reset_state(), no teardown(), no raw send() to call out of turn.
Read-only members (populated per point; absent members are None):
ctx.scenario— scenario identity/metadatactx.agent— theAgentConfig(label, soul, model metadata)ctx.run_id,ctx.session_id— the current run's identifiersctx.trace— the frozenTrace(fromon_run_scoredonward)ctx.score— the computedScore(fromon_run_scoredonward)ctx.aggregate— theAggregateResult(on_scenario_endonward)
Hooks MUST treat these as immutable. The framework passes its live objects for cheapness; mutating them is undefined behavior and a bug in the hook, not a supported channel. (Python cannot enforce deep immutability without copying every trace; the contract is stated instead of paid for.)
Capabilities:
ctx.converse(text) -> str— send a text turn into the run's session and return the normalized reply. Valid only duringon_run_scored; any other point raises. Mechanically this ishandle.send()with the run's ownsession_id, so the agent answers with the run in context. The turn is never appended totrace.turns— the trace is already frozen — so trajectory scorers, latency stats, and replay are untouched. Hooks may callconverse()more than once duringon_run_scored; the framework records metadata per call, and artifact stamping accumulates timeout/error state across those calls (timed_out: trueif any converse call timed out).
Tools cannot currently be disabled per-turn: the SPI's send() has no
such parameter and SamplingConfig.tool_choice is provision-time
config. A converse turn may therefore trigger tool calls. This is
sound only because the contract places converse after evidence capture
— the world being judged is already frozen — but it must be recorded,
not silently allowed: the framework stamps tools_disabled: false
into any artifact whose hook used converse, so a reader knows the
self-report could have had side effects in the (already-judged)
fixture world.
-
ctx.emit_artifact(payload, label=None)— record a JSON-serializable payload as a hook artifact. Artifacts are buffered, not written: the runner has no knowledge of artifact paths (that is CLI territory), so emissions attach to the run result and the CLI persists them beside the trace and score files it already writes. Hooks never receive filesystem access. -
ctx.warn(message)— append a non-fatal diagnostic to the same hook-warning channel used for contained hook exceptions. Run-scoped warnings land intrace.worker_warningsashook:<name>: <message>; scenario- and pack-scoped warnings are surfaced by the CLI. A warning is evidence, not a gate.
Artifacts¶
A run's whole story lives in one directory. The trace is
<stem>.json where <stem> is <timestamp>_<run_id[:8]>, and the score
sidecar is <stem>.score.json. Hook artifacts instantiate the same
sidecar pattern with the hook name as the discriminator:
<stem>.json # trace
<stem>.score.json # score sidecar
<stem>.debrief.json # debrief hook artifact
<stem>.<hook_name>.json # the general pattern
<stem>.<hook_name>.<label>.json # a hook that emits several per run
Scenario- and pack-scoped emissions (from on_scenario_end /
on_pack_end) have no single run to sit beside; they are written at the
runs-directory root. Scenario-scoped emissions include the scenario id:
<sweep_timestamp>.<hook_name>.<scenario_id>[.<label>].pack.json.
Pack-scoped emissions stay
<sweep_timestamp>.<hook_name>[.<label>].pack.json.
The sidecar rule. A *.json file whose stem contains a dot is a
sidecar, never a trace. Every walker of runs/ — wt report,
wt triage, wt rescore, load_runs() — must apply this rule rather
than special-casing .score.json. (Today wt rescore excludes only
.score.json; that walker generalizes as part of this change, otherwise
a debrief artifact would be parsed as a trace.)
Never counted, never gating: hook artifacts do not participate in
scoring, wt report totals, JUnit output, or exit codes. They exist for
downstream analysis — clustering debriefs across a 0/5 is a wt triage
concern, and a later one.
Failure containment¶
Hook failures are never run failures. The framework wraps every dispatch:
- An exception inside a hook is caught, logged, and appended to the
trace's
worker_warningsashook:<name>: <error>— diagnostics travel with the run's story, and a hung or broken hook can never turn a PASS into an ERROR. ctx.converse()runs under a framework-owned deadline (default 30s,WT_HOOK_CONVERSE_TIMEOUT_Sto override). On expiry the call is abandoned, any later reply for that call is discarded, the artifact recordstimed_out: trueif any converse call timed out, and dispatch returns.- The orphan-turn caveat, stated honestly: an abandoned converse is an in-flight request the framework cannot always cancel (an HTTP inject already sent will still be processed by the endpoint). Because the runner is synchronous, the next reset cannot begin until dispatch returns — but an endpoint may finish processing an abandoned turn after that reset, which is the same exposure class as any client timeout against a stateful endpoint. The reset canary exists to catch exactly this leakage; a bench that cannot tolerate it should set the converse deadline above its endpoint's worst-case latency, not below.
The debrief reference hook¶
debrief ships built in, and exists for two reasons: it is the first
consumer with a real payoff, and it exercises the hardest contract in
the system (post-score, session-alive, converse, artifact emission). A
hook system shipped with zero consumers is an unproven abstraction.
Behavior: at on_run_scored, if any score layer failed by default
(WT_DEBRIEF_ON=all to include fully passing runs), send the agent one
turn carrying the run-level outcome verdict and the scorers' concrete
layer reasons (each layer's LayerResult.detail), and record the reply.
Prompt discipline. The prompt asks in this order:
- Environment first: did any tool call return an error, unexpected shape, or missing capability? Quote it.
- Blockers: was anything you needed absent from the environment?
- Only then: what would you do differently?
The ordering is epistemics: self-reports are reliable when the model quotes error strings it actually received (privileged observability of the environment from inside the run) and confabulatory on steering questions ("why didn't you ask a clarifying question?"). Harvest the reliable class first, so a truncated or lazy reply still contains the part worth having.
Artifact schema (<stem>.debrief.json), versioned so triage can
cluster across runs and schema evolution stays honest:
{
"schema_version": 2,
"run_id": "...",
"scenario_id": "...",
"agent": "...",
"model": "...",
"verdict": "FAIL",
"failed_layers": ["outcome"],
"reasons": {"outcome": "...", "trajectory": "...", "constraint": "...",
"integrity": "..."},
"prompt": "...",
"reply": "...",
"tools_disabled": false,
"timed_out": false,
"duration_ms": 1234,
"error": null
}
Debriefs report; they never gate. No pass/fail influence, no exit- code influence, and nothing in the framework consumes a debrief to modify souls, skills, or scenarios automatically. A self-report auto-editing its own steering is the hacks-becoming-truth failure mode; the loop is propose, not apply — a human (or at minimum a triage process) sits between the model's self-diagnosis and anything that changes steering. Debriefs are leads for the wiring → harness → steering → model triage ladder, not verdicts.
Rollout¶
Ships additive in 0.7.0: no --hook flag means byte-identical
behavior to 0.6, nothing is deprecated, and the new surface is the SPI,
the CLI flag, the built-in debrief, and the abstract StateProbeHook.
StateProbeHook also ships in 0.7.0 as an additive base class for
consumer #2. The canary kwarg is retained and not deprecated — the doctor
seeded-nonce flow still needs run_reset_canary(..., state_probe=...),
so there was no breaking change to defer. A state-probe hook establishes
the first run's post-reset state as the clean baseline and reports later
post-reset mismatches through the ordinary hook warning channel
(trace.worker_warnings entries like hook:<name>: ...) plus a bounded
violation artifact. wt doctor remains the hard gate. If those warnings
prove too quiet in practice, the candidate escalation is an evidence-
quality or taint state on the run; deliberately not built here.