SPEC.md:943 says "Compaction is the canonical use of the §8 beforeLLM hook".
SPEC.md:1174 says the route-gate — "an expensive-tier route is gated" — is also a
beforeLLM hook. Both are in this repository's own spec, and Hooks.BeforeLLM is one
field (golang/client.go:182). A host that wants both must fold them into a single
function value by hand, and nothing in any port composes, orders, or checks that fold.agentic-nexus), from file:line
in this repository. The weakest estimate here is the claim that a chain helper is "cheap in
seven ports" — the helper's code is small everywhere, but its semantics are new
observable behavior that has to be pinned identically in seven languages whose hook
signatures already differ (ctx in golang, a bare 1-arity map fn in clojure
(clojure/src/toolnexus/client.cljc:198), promise-capable in js
(js/src/client.ts:130)). That is the real bill, and it is not small.The §8 seam is complete and, since ADR 0008, reachable from the §7D agent runtime at both
levels. Hooks is four optional function fields — BeforeLLM, AfterLLM, BeforeTool,
AfterTool (golang/client.go:179-192) — with clean, already-pinned return semantics that
matter for everything below:
BeforeLLM is not side-effect only. It returns *LLMOverride, where "a nil field
leaves that value unchanged; a non-nil (even empty) slice replaces it"
(golang/client.go:227-232), and the replacement becomes the working transcript for the
rest of the run (SPEC.md:940). It is a messages → messages transform, i.e. a pipeline
stage. Chaining is therefore semantically well-defined, which is the precondition for
this whole ADR.BeforeTool returning a non-nil Result short-circuits the tool — the real tool never
runs (golang/client.go:185-188). That is a deny/veto, and denies are the one thing that
must not be quietly widened by a later stage.golang/client.go:176-177).Resolution across the two levels is def-over-runtime, replace never merge, per field
independently. It is one conditional, spelled the same way in seven ports:
golang/agents/runtime.go:1131-1138, js/src/agents/runtime.ts:768,
python/src/toolnexus/agents/runtime.py:776,
java/.../agents/AgentRuntime.java:549, csharp/src/Toolnexus/Agents/AgentRuntime.cs:504,
elixir/lib/toolnexus/agents/handle.ex:155,
clojure/src/toolnexus/agents/runtime.cljc:759. (ADR 0008 said six; clojure/ joined after,
and it carries the seam correctly. Its elixir deferral is closed too: the wrong-arity hook
that used to vanish now raises, elixir/lib/toolnexus/client.ex:989-996.)
The framing "the def-level hook silently deletes the runtime-level one" is wrong, and the
ADR is better for saying so. That drop is not a bug, not silent-by-accident, and not
undocumented: it is a stated requirement (openspec/specs/agent-runtime/spec.md:296-303), a
spec rule with its reason attached — "composing two transcript rewrites has no defined order"
(SPEC.md:826) — and it is conformance-pinned in every port by fixture scenario H3
(examples/agent-hooks/fixture.json, "Merging is non-conformant"). Every port's field doc
says it in prose (golang/agents/runtime.go:147, js/src/agents/runtime.ts:94).
The real gap is one level down, and the library cannot see it:
There is exactly one slot per hook kind, and SPEC.md names at least two independent
tenants for beforeLLM alone — compaction (SPEC.md:943) and the route-gate
(SPEC.md:1174). A third is implied: add-governed-execution-layer was archived
never-built with the reason "superseded in practice: §7D budgets + hooks + §10
approval suspension cover the need"
(openspec/changes/archive/_never-built/README.md). Policy denial was delegated to
beforeTool — the same single slot a cache or an arg-rewriter would want.
So a host wiring compaction and a route-gate and tracing writes one closure that does
all three. Two independent subsystems in that host each assigning opts.Hooks.BeforeLLM is a
plain struct-field assignment: the second wins, the first never ran, and toolnexus never
sees the first value at all. No warning in this library can reach that failure. The
composition it needs does not exist here, so it gets re-derived, differently, in every host —
which is the drift this repository exists to prevent, one layer out.
The DeepSeek harness is the honest counter-evidence that a single non-composable slot does not
survive contact with real use: its equivalent is a waterfall
('agent/pre-step'(payload, next) → {kind:'reject'} | {kind:'enter', messages},
packages/core/agent/src/runtime-types.ts:231 and :53-55), registration order is
composition order, and — counted in their tree, not taken on report — fourteen independent
packages register that one hook (compaction-basic, agent-instructions, time-context,
tmux-context, tool-skill, plan-mode, goal-round-driver, repeat-tool-reminder,
session-checkpoint-policy, subagent-in-process-driver, tool-cordis, and the claude-code and
codex hook bridges). Its tool layer goes further: extensible listeners may allow/deny/ask and
are reorderable, but the owner's policy is a registered guard where "later waterfall listeners
cannot turn a guard denial back into permission" (packages/core/tools/README.md:25;
ToolRuntime.prepareExecution, packages/core/tools/src/index.ts:1463). That asymmetry
— pipeline for rewrites, first-deny-wins for vetoes — is the part worth taking. The
middleware/next() machinery is not: it makes ordering an implicit property of registration
order, which is exactly the kind of thing a byte-parity spec should refuse.
Ship a spec-defined pure composition helper, one per port, and change nothing about how the runtime resolves or forwards hooks.
chainHooks(h1, h2, …) -> Hooks // golang: tn.ChainHooks(...*Hooks) *Hooks
Rules that make it cheap and safe:
prepend:true. Order is observable behavior, so it is written at the call site where a
reader can see it. This is the same discipline SPEC.md:1212 already applies to the
request pipeline ("base body → BeforeLLM hook → RequestParams merge → BodyTransform
→ marshal → wire").beforeLLM / afterTool are pipelines. Each stage sees the current
messages/tools (resp. result); a non-nil return replaces them for the next stage;
the composite's return is the accumulated replacement, or nothing if no stage returned
anything. Nil-in-nil-out is what preserves byte-identity.beforeTool is first-deny-wins. The first stage returning a non-nil Result
short-circuits: later stages do not run and cannot revoke it. Args rewrites feed
forward. This is the one place the helper is not a plain fold, and it is deliberate — a
composed permission check must not be widened by a hook that happens to come after it.afterLLM runs all stages in order (observers). The first error aborts, matching
golang/client.go:176; remaining stages do not run.chain() of zero stages, or of all-nil hooks, returns the same as no
hooks at all. The conformance suite asserts this first, exactly as ADR 0008 did.Options/AgentDef keep one hooks field, def-over-merge
stays, H3 stays green. A caller that wants both levels writes
chainHooks(runtimeHooks, defHooks) itself — explicit, at the call site, with the order
visible.SPEC.md first. §8 gains the helper and its five rules; §7F gains one sentence saying
a compactor composes with other beforeLLM tenants via chain, and that a hooks value
set at two levels does not compose implicitly.Deferred — an ordered list / registration API on the runtime (option (a)). Replacing the
single field with []*Hooks, or adding addHook(...), makes order a property of the
runtime's own state, forces a def-vs-runtime interleaving rule (does a def's stage run
before or after the runtime's?), and re-opens the §10 rewind interaction ADR 0008's spike
already found (SPEC.md:840-846: a turn that compacts then suspends rewinds and re-compacts —
with N stages, "re-run all of them, in order" needs stating). It also breaks H3. If chain
turns out to be what every consumer writes anyway, promoting it into the options surface is a
cheap follow-up ADR with real usage behind it. Doing it first is guessing.
Deferred — tool-permission tiers and a non-revocable owner guard. The DeepSeek
allow/deny/ask tier plus a guard registration slot is a governance feature, not a composition
feature. toolnexus has no permission hook today — beforeTool's short-circuit is the only
gate, and add-governed-execution-layer was consciously archived rather than built. Rule 3
above gives composed denies the safety property without introducing the concept; a tiered
permission model deserves its own ADR that revisits that archived proposal on its merits.
Deferred — a loud diagnostic when a def-level hook shadows a runtime-level one (option
(c)). Precedent exists and is genuinely one line (golang/client.go:758:
log.Printf("[toolnexus] …"); SPEC.md:288, SPEC.md:367 are spec'd warnings whose text is
not pinned, so parity is cheap). Rejected as the primary answer for a reason that only
shows up when you check the spec: def-over-runtime is the designed path for per-agent
compaction budgets (openspec/specs/agent-runtime/spec.md:302), so a runtime-wide tracer plus
a per-agent compactor — the exact configuration a healthy consumer wants — would warn on every
agent, forever, about behavior the spec calls correct. And it cannot see the drop that
actually bites, which is one host subsystem overwriting another's closure before toolnexus is
ever called. A warning that fires on correct usage and misses the real fault is worse than
silence. It could be revisited as a one-shot note in §7D docs rather than a log line.
chain is additive and opt-in; unset stays byte-identical, which is the property the
conformance suite pins first.examples/hook-chain/fixture.json pinning: order of side effects, that
stage 2 sees stage 1's rewritten messages, that a beforeTool deny stops the chain, and
that an all-nil chain is byte-identical. That fixture is the decision; the code is small.elixir/ and clojure/ are the ports to think about before agreeing. Both take a hook
map of plain function values, so chain returns a map of closures — idiomatic enough. But
elixir now raises on wrong arity (elixir/lib/toolnexus/client.ex:989-996), so a
composed hook must preserve arity exactly or the chain converts a silent no-op into a
crash; and clojure is .cljc dual-host (ADR 0009), so the helper has to be sync-safe on
the JVM and correct under the JS host's async client, where js hooks may return promises
(js/src/client.ts:130) and the fold must await each stage.beforeLLM and
neither mentions the other.SPEC.md §8 should say plainly that each hook kind holds exactly one
callback, that composing multiple concerns onto one hook is entirely the host's
responsibility, and that toolnexus defines no order for it — and §7F and the §11
route-gate paragraph should each cross-reference the other as competing tenants of
beforeLLM. The present state, where two sections independently call the same single slot
"the canonical use", reads as though the library had thought about their coexistence.chain(a, b) doesn't until
hooks are registered by parties who cannot see each other's call sites. Deferred in writing,
not dismissed.Compactor field alongside Hooks, so compaction stops competing for the slot.
Rejected on ADR 0008's own reasoning: §7F defines compaction as a use of beforeLLM, so a
dedicated field duplicates §8 rather than reaching it — and it would fix exactly one of the
slot's tenants while the route-gate keeps colliding with tracing.next()-style middleware onion (the DeepSeek shape). Rejected: next() makes order
implicit in registration and gives each stage the power to skip the rest, which is a control
construct, not a fold. Toolnexus's hooks already return values; a fold over return values is
the smaller thing that fits what is there.Per the prime directive this ADR is the discussion, not the change. If accepted, the work is
an OpenSpec change whose deltas pin the five fold rules in §8, the one-sentence §7F and
§11 cross-references, and a shared examples/hook-chain/fixture.json — then seven ports
against that fixture. If rejected, the work is a much smaller docs change: the three
sentences named in "If rejected", which cost nothing and remove the false impression.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |