Skip to content

Latest commit

 

History

History
59 lines (34 loc) · 11.7 KB

File metadata and controls

59 lines (34 loc) · 11.7 KB

Agent Note: Interception extension points — the typed-Decision surface a hook programs against

Status: implemented

English | 中文

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 API; the CC/Codex bridges (the dsh-hooks-claude-code / dsh-hooks-codex packages) are merely translators that map an external shell-hook protocol onto that same API. 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 Agent Note supplies the three-domain rule and the typed-Decision idiom; this Agent Note applies them to the lifecycle extension points.

Decision

The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small extension-point-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 a SessionStartSource (startup for a fresh/forked create, resume for a reloaded persisted session; clear/compact reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via agent.inject().
  • agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's turn, step, and cancellation signal (the retired PreStepContext fields live in the payload; see the payload-object events decision); messages is empty for a tool continuation with no intervening input. enter returns the complete message batch, including any current-request context a listener contributes; reject opens no step and leaves the claimed messages removed.

agent/turn-stopping is an awaited notification at the natural stop boundary. A listener that needs another step calls agent.steer() with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn.

The tool pipeline gives each phase one kind of authority

Every call follows tools/pre-execute → guards → tools/execute → dispatch → tools/post-execute → definition-owned finalizeContenttools/result. The registry snapshots caller input, materializes and freezes arguments, assigns an opaque token, and snapshots the visible definition's final-content callback before policy begins. 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-execute is the extensible waterfall gate. Its PreToolDecision allows, denies, or asks. Deny skips tools/execute and core dispatch. Ask resolves through the optional approval seam: only allowed-once continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every resolved decision still reaches post-policy; a throwing listener becomes a final normalized failure.
  • 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/execute is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with next(), may replace and restore the required exec.signal before doing so but cannot remove it, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success short-circuits dispatch and is re-normalized through the resolved output declaration.
  • tools/post-execute is the inspect/transform waterfall. Its PostToolDecision accepts, blocks with feedback, replaces either presentation content or canonical value, or attaches additionalContexts. Value replacement revalidates and recomputes presentation; content replacement preserves programmatic value and is not a confidentiality boundary. The returned decision is the supported transform channel.
  • ToolDefinition.finalizeContent is an optional synchronous, total, content-only boundary snapshotted with the visible definition at call creation. It runs exactly once after the registry has normalized and losslessly snapshotted the candidate outcome, including pre-, around-, or post-listener failures that bypass later waterfalls and errors discovered while snapshotting another result field. It may replace content or preserve it with undefined, but cannot rewrite isError, structured error identity, contexts, or presentation metadata. This is where a tool enforces its own last-mile content invariant without converting policy failures into weaker block decisions.
  • tools/result is 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 reject ToolRuntime.execute()'s returned outcome.

Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, 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; definition-owned final content invariants also cover outer pipeline and candidate-materialization failures; and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The canonical tool-output contract owns the value/projection and durability rules.

Three load-bearing loop decisions

  1. Run pre-step policy at every proposed step. The loop opens the turn before the initial claim and decision, so rejection closes a durable blocked turn with no step or model-visible message. A tool continuation with no newly claimed input still submits an empty batch, allowing per-request context producers to add logged messages to that exact request. On enter, the loop opens the step and appends the returned batch as user/message events before request derivation. Each claimed follow-up remains the sole direct prompt in its turn under the one-send-one-turn simplification.

  2. Post-tool additionalContexts and asynchronous injections enter the active-batch FIFO and append when that batch settles. content/feedback shape the result execute() returns, but each context is a separate sourced user/message, and a single step or composite tool can produce many. Appending context immediately would interleave result(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 on ToolExecutionResult, and the loop accepts it into the same FIFO as agent.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.

  3. A stopping listener requests continuation 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.

Pre-tool input rewrite is a separate consistency decision

PreToolDecision cannot rewrite arguments. History and the audit call are logged before execution, and UI 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 Service Definition 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 extension points 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; terminal monotonic stopping is expressed by tool-result data, while agent/turn-stopping is the last chance to steer another step.

Alternatives considered

  • Shipping pre-tool INPUT rewrite as part of this extension-point set — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read tool/call.arguments logged before execution), and the pre-tool input-rewrite proposal owns the design.
  • Declaring the durable hook/* SessionEvents alongside the extension points — 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 extension 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, pre-step claim settlement, post-tool context buffering, and stopping; 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 settles an initial pre-step rejection from its blocked no-step turn as end_turn, while hook-driven snapshots verify the observable bridge behavior end to end.