# Conflicts: # docs/cordis-catalog/events.md # docs/core-data-structures/core.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/tool-execution-pipeline.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.md # packages/core/tools/src/index.ts # scripts/gen-doc-graphs.ts
9.8 KiB
RFC: Interception seams — the typed-Decision surface a hook programs against
Status: implemented
Problem
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that "native hooks" are not a package — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a powerful, well-typed canonical event surface; the CC/Codex bridges (the dsh-hooks-claude / dsh-hooks-codex packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full ctx, typed returns).
The surface needs distinct contracts for per-prompt policy (CC's UserPromptSubmit), session-start observation (CC's SessionStart), pre-tool policy, around-dispatch control, post-tool transformation, final-result observation, and continuation with a model-facing reason. Conflating those phases gives plugins mutation channels they do not need and makes finality depend on listener ordering. The event-domain-semantics RFC supplies the three-domain rule and the typed-Decision idiom; this RFC applies them to the lifecycle seams.
Decision
The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific typed Decision unions; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (session-start, prompt-submit, pre-tool, post-tool, stop-via-continuation) while leaving non-hook execution policy independently composable.
Agent events (dsh-agent):
agent/session-start(agent, source)— emit, once before turn 1, carrying aSessionStartSource(startupfor a fresh/forked create,resumefor a reloaded persisted session;clear/compactreserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context viaagent.inject().agent/prompt-submit(agent, content, source, next) → PromptDecision— waterfall, fired per drained queued message inside the open turn, before theuser/messageappend.allow(optionally rewriting the promptcontentor attaching separately sourcedadditionalContexts[]) orblock(dropping the prompt; the loop appends a durableprompt/blockedin its place — see the dispatch note below).
agent/turn-continuation receives and returns a ContinuationDecision. A {action:'continue', reason?} may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the /goal step-end-steer pattern. It is not a context/message, so its type does not offer a context envelope or durable context metadata.
The tool pipeline gives each phase one kind of authority
Every call follows tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → tools/result. The registry snapshots caller input, materializes and freezes arguments, and assigns an opaque token. Nested calls carry only the parent token. Identity remains immutable; only signal may change around dispatch. The log, UI, and tool body therefore agree on what ran.
tools/pre-executeis the extensible waterfall gate. ItsPreToolDecisionallows, denies, or asks. Deny skipstools/executeand core dispatch. Ask resolves through the optional approval seam: onlyallowed-oncecontinues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers.ctx.tools.guard()installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.tools/executeis the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch withnext(), may add, replace, or remove onlyexec.signalbefore doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.tools/post-executeis the inspect/transform waterfall. ItsPostToolDecisionaccepts, blocks with feedback, optionally replaces content, or attachesadditionalContexts. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation.tools/resultis the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or rejectToolRegistry.execute()'s returned outcome.
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe isError results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
TurnEndReason.rejected (dsh-session): a turn whose entire prompt batch was blocked by prompt-submit.
Three load-bearing loop decisions
-
Open the turn before prompt policy. A fully blocked batch becomes a zero-step
rejectedturn, preserving enclosure and giving ACP a durable terminal event. Every veto also recordsprompt/blockedwith the original prompt and reason, so mixed batches retain blocked inputs. Every allowedadditionalContextsentry is injected into the open turn. -
Post-tool
additionalContextsand asynchronous injections enter the active-batch FIFO and append when that batch settles.content/feedbackshape the resultexecute()returns, but each context is a separatecontext/message, and a single step or composite tool can produce many. Appending context immediately would interleaveresult(c1) → context → result(c2)or place nested context before its outer result, breaking tool-call/result adjacency.ToolRunContext.deferContext()therefore collects nested-dispatch context through failures,execute()surfaces the ordered array onToolExecutionResult, and the loop accepts it into the same FIFO asagent.inject()calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. -
A forced
continuereasonis enqueued through the steering channel, so the next step's top-of-loop drain records it as steering for the continued turn — next-step steering within the SAME turn, not a next-turn prompt (matching the existinghasSteeringforce-continue override).
Pre-tool input rewrite is a separate consistency decision
PreToolDecision cannot rewrite arguments. History and the audit call are logged before execution, and ACP presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the input-rewrite proposal.
Boundaries
The seam package does not declare hook/* session events (the durable hook-invocation log); those belong to dsh-hook-protocol, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (packages/core/agent-loop/tests/interception.spec.ts) composes the seams through the real loop with no hook/* protocol. Compaction (PreCompact/PostCompact), Notification, and Codex PermissionRequest remain outside this decision. The approval seam resolves ask decisions through ctx.approval, while terminal monotonic stopping is owned separately by agent/turn-stop.
Alternatives considered
- Shipping pre-tool INPUT rewrite as part of this seam set — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read
tool/call.argumentslogged before execution), and the pre-tool input-rewrite proposal owns the design. - Declaring the durable
hook/*SessionEvents alongside the seams — rejected: a native plugin uses the typed Decisions with no hook log at all (the worked example proves it), so the durable log belongs to the hook-protocol library, not the seam surface.
Consequences
The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; dsh-tools owns identity sealing and the five-phase execution pipeline. Their contracts are documented in architecture.md, package READMEs, core interception decisions, and tool structures. The ACP bridge maps rejected turns to its cancelled codec value, while hook-driven snapshots verify the observable bridge behavior end to end.