Merge pull request #121 from deepseek-harness/worktree-hooks-c-interception

feat(events): interception seams — typed-Decision surface for hooks (hooks stack PR-C)
This commit is contained in:
Tianyi Cui
2026-07-04 10:54:01 +08:00
committed by GitHub
49 files changed
+1476 -195

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/.
core/ product API spine
session/ event-sourced session log + in-memory store
system-prompt/ prompt-section + tool-schema assembly registry
tools/ tool registry + tools/execute waterfall
tools/ tool registry + tools/pre-execute/post-execute pipeline
agent/ Agent interface, registry, agent/* event vocabulary
agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver
agent-core/ bundle plugin: the providerless/executor-less/UI-less spine
+21 -13
View File
@@ -89,7 +89,7 @@ The filesystem capability follows the bash topology with a fourth layer, but the
The web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, `dsh-web-search-deepseek`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it reads only the aggregated `ctx.web.searchStatus()`/`fetchStatus()` and executes through `ctx.web.search()`/`fetch()`, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.md).
> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations.
> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/pre-execute` deny/ask gate), NOT a mechanism for swapping implementations.
## The vocabulary (dsh-llm)
@@ -122,7 +122,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its schemas into the system-prompt assembly automatically.
`execute()` runs through the **`tools/execute` waterfall** — the single seam where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call. This collapses Claude Code's validate → PreToolUse → permission → execute → PostToolUse pipeline into ordered waterfall listeners.
`execute()` runs through a **two-waterfall pipeline**`tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hooks, and plan-mode plugins gate or transform a call. This maps Claude Code's validate → PreToolUse → permission → execute → PostToolUse pipeline onto two ordered waterfalls: `pre-execute` returns a `PreToolDecision` (allow/deny/ask), `post-execute` a `PostToolDecision` (accept/block, optionally replacing content or attaching `additionalContext`). Core dispatch sits between them as plain code, inside `execute`'s outer try/catch, with the tool body's own try/catch preserved so a thrown tool still reaches `post-execute` as an `isError`.
**TODO**: tool shapes get revisited now that real tools exist (the bash suite landed; the `TODO(review)` in dsh-tools is still open) — e.g. a concurrency-safety hint for parallel execution; phase 1 executes tool calls sequentially.
@@ -146,11 +146,15 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told
- **Step**: one model request + its tool executions.
```
create agent → emit agent/session-start(source) ⟵ once, before turn 1 (startup|resume)
forever:
wait for queued messages (idle)
emit agent/status(running)
TURN (error-contained — a throwing plugin ends the turn, never the loop):
drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror)
'turn/start' ⟵ durable turn boundary (no agent/* mirror)
each queued msg: waterfall agent/prompt-submit ⟵ allow (rewrite/+context) | block
allow → session('user/message'…); inject additionalContext
every prompt blocked → 'turn/end'(rejected), 0 steps ⟵ zero-step turn, model never called
STEP loop:
drain steering (late steering from previous step's listeners)
assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
@@ -166,15 +170,19 @@ forever:
msg = waterfall agent/step-result ⟵ runs BEFORE the log append, so the
session('assistant/message' {content, usage?}) log records what tool dispatch uses
each tool-call (sequential, abort-checked between calls):
session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/pre-execute (allow/
deny/ask gate) → dispatch → tools/post-execute (accept/block, replace, +context)
tool execution may append tool-owned session events, e.g. `todo/write`
session('tool/result')
append buffered post-execute additionalContext → session('context/message')(s)
⟵ after ALL tool/results (adjacency)
drain steering → session('steering/message'); emit agent/steering
session('step/end') ⟵ durable step boundary (no agent/* mirror)
cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
steering pending forces cont = true (from continuation listeners OR from
step/end session-event listeners — the /goal pattern; hasSteering override)
if !cont: break
cont = waterfall agent/turn-continuation(default = {action: hadToolCalls||steered
? 'continue' : 'stop'}) → ContinuationDecision
a continue's reason is recorded as next-step steering (same turn); steering pending
also forces continue (continuation OR step/end listeners — the /goal pattern)
if action==stop: break
session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure
reported via agent/error, not fatal)
@@ -184,7 +192,7 @@ forever:
Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with `turn/end { reason: { kind: 'error', step, message, code? } }` — the failure's step number rides on the durable turn reason (there is no separate session `error` event); live diagnostics fire via `agent/error`. Never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`.
Turn-end reasons: a turn ends with one `TurnEndReason``completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them.
Turn-end reasons: a turn ends with one `TurnEndReason``completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). `rejected` is a zero-step turn whose entire prompt batch was blocked by an `agent/prompt-submit` hook (the turn still opens and closes balanced; the ACP bridge maps it to `cancelled`). `interrupted` is synthesized by a persistence backend closing a crash-orphaned turn on reload. This lets a consumer distinguish a clean stop from a truncated/blocked one (the ACP bridge maps `max-tokens` to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them.
A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) is reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush.
@@ -210,7 +218,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| MVP feature | Plugin mechanism |
|---|---|
| Hook system (user + project level) | listeners on `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`; a hooks plugin bridges config files to shell commands |
| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` (each interception waterfall returns a typed Decision); a hooks bridge plugin maps config files / shell commands onto those seams, a native hook plugin uses them directly |
| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders |
| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue |
| Dynamic workflow | orchestrator plugin on the `turn/end` (or `step/end`) session event driving `send`/`steer` (+ sub-agents later) |
@@ -221,9 +229,9 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
| Built-in tools (Read/Write/Edit/Bash/…) | `ctx.tools.register()`; schemas flow into the assembly automatically. **Bash: implemented**`dsh-bash` (seam) + `dsh-bash-local` (subprocesses) + `dsh-tool-bash` (`bash`/`bash_output`/`bash_kill`, incl. background tasks). **`todo_write`: implemented** — `dsh-tool-todo` writes the whole task list to the session log (`todo/write`), rendered as a stdio checklist / ACP `plan` |
| ToolSearch / progressive disclosure | wrap `agent/request`, filter `req.tools` |
| Tool sandbox (landlock / sandbox-exec) | wrap `tools/execute`, or implement a sandboxing `BashExecutor` (the dsh-bash seam) |
| Permission system / AskUserQuestion | wrap `tools/execute` (veto or ask); register an ask tool |
| Plan mode | wrap `tools/execute` (deny writes) + `agent/request` (inject mode prompt) |
| Tool sandbox (landlock / sandbox-exec) | `tools/pre-execute` (deny), or implement a sandboxing `BashExecutor` (the dsh-bash seam) |
| Permission system / AskUserQuestion | `tools/pre-execute` (deny/ask); register an ask tool |
| Plan mode | `tools/pre-execute` (deny writes) + `agent/request` (inject mode prompt) |
| Sub-agent delegation | Implemented as the `ctx.subagents` provider-registry seam: `dsh-subagent-spawn` starts a fresh in-process child, `dsh-subagent-fork` seeds a child from the parent's completed-turn prefix, `dsh-subagent-acp` drives an out-of-process child over ACP, and `dsh-tool-subagent` exposes one configured provider to the model |
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |
+1 -1
View File
@@ -47,7 +47,7 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task
## Permissions / sandboxing
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)), or a sandboxing implementation behind the tool's executor seam.
Prefer not to build policy into the tool. The seam is the `tools/pre-execute` gate (deny/ask — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)) and the `tools/post-execute` inspect/transform seam, or a sandboxing implementation behind the tool's executor seam.
## How your tool renders in an editor (ACP presentation)
+4 -8
View File
@@ -8,24 +8,20 @@ A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `exec
## A hook plugin (permission gate)
A hook wraps the `tools/execute` waterfall to veto or rewrite a call — the seam where sandbox, permission, and plan-mode plugins live.
A hook returns a typed decision from the `tools/pre-execute` gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.)
```ts
import type { Context } from 'cordis'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
declare function isAllowed(exec: ToolExecution): Promise<boolean>
export const name = 'permission-gate'
export function apply(ctx: Context) {
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return {
callId: exec.callId,
content: [{ type: 'text', text: 'Denied by policy.' }],
isError: true,
}
return { kind: 'deny', reason: 'Denied by policy.' }
}
return next()
})
+56 -20
View File
@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts)
#### `agent/disposed` — emit
@@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
#### `agent/error` — emit
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts)
#### `agent/pre-step` — serial
@@ -63,7 +63,19 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts)
#### `agent/prompt-submit` — waterfall
Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit.
```ts cordis-catalog
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
```
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts)
#### `agent/queued` — emit
@@ -75,7 +87,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts)
#### `agent/request` — waterfall
@@ -87,7 +99,19 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts)
#### `agent/session-start` — emit
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup).
```ts cordis-catalog
'agent/session-start'(agent: Agent, source: SessionStartSource): void
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts)
#### `agent/status` — emit
@@ -99,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
#### `agent/steering` — emit
@@ -111,7 +135,7 @@ Steering content was injected into a running turn.
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts)
#### `agent/step-result` — waterfall
@@ -123,7 +147,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts)
#### `agent/stream-chunk` — emit
@@ -135,19 +159,19 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/core/agent/src/types.ts:253`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
#### `agent/turn-continuation` — waterfall
Waterfall: override the turn-continuation decision. The default (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners can force-continue (/goal, /loop) or force-stop (budget guards).
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.
```ts cordis-catalog
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean>
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts)
### `fs/*`
@@ -289,19 +313,31 @@ A tool was registered or unregistered (the available tool set changed).
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:84`](../../packages/core/tools/src/index.ts)
#### `tools/execute` — waterfall
#### `tools/post-execute` — waterfall
Waterfall around every tool execution — the single seam where sandbox, permission, hook, and plan-mode plugins wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed (possibly around your own logic), or return a ToolExecutionResult without calling `next()` to short-circuit (veto).
Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. The core tool dispatch sits between the two waterfalls as plain code, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result).
```ts cordis-catalog
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
```
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:61`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:79`](../../packages/core/tools/src/index.ts)
#### `tools/pre-execute` — waterfall
Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` degrades to deny until the permission system lands (`FIXME(permissions)`).
```ts cordis-catalog
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
```
Types: [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:65`](../../packages/core/tools/src/index.ts)
### `web/*`
@@ -502,7 +538,7 @@ Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/syst
### `ctx.tools` — `ToolRegistry`
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/execute` waterfall. The registry contributes its schemas into the system-prompt assembly.
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly.
```ts cordis-catalog
register(definition: ToolDefinition): () => void
@@ -513,7 +549,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:199`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:265`](../../packages/core/tools/src/index.ts)
### `ctx.web` — `WebService`
+38 -3
View File
@@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
@@ -216,7 +216,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
## The agent handle
@@ -308,7 +308,42 @@ interface Agent {
}
```
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle emits, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
## Interception decisions
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt).
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
```ts type-equiv
interface HookContext {
content: ContentBlock[]
source: MessageSource
}
```
`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`):
```ts type-equiv
type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
```
`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern):
```ts type-equiv
type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
```
`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
```ts type-equiv
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
```
## `ToolDefinition`
+22 -1
View File
@@ -16,6 +16,17 @@ interface SessionEventMap {
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
* record of a blocked prompt and why. Appended in place of the `user/message`
* the prompt would have become, so the block survives replay even in a MIXED
* batch where another queued prompt is allowed (there the turn does not end
* `rejected`, so the boundary reason alone would not preserve it). `content`
* is the original prompt the listener rejected; `reason` is the veto text
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
@@ -181,6 +192,16 @@ interface TurnEndReasonMap {
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
'max-tokens': { kind: 'max-tokens' }
/**
* The turn's entire prompt batch was BLOCKED before any step ran — every
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
* hook). The turn still opened (so the boundary stays balanced and the block
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
* message from the vetoing decision. Distinct from `aborted` (a user-driven
* cancel) and `error` (a failure): the prompt was rejected by policy, not
* interrupted or broken. A UI renders it as "prompt blocked by hook".
*/
rejected: { kind: 'rejected'; reason: string }
/**
* The turn never ended on its own: the process crashed mid-turn and a
* persistence backend later closed the orphaned (open) turn on reload so the
@@ -195,7 +216,7 @@ interface TurnEndReasonMap {
}
```
`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible.
`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. `rejected` is a zero-step turn whose whole prompt batch an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible.
## The turn-enclosure invariant
+29 -3
View File
@@ -73,9 +73,9 @@ type InferArgs<S extends SchemaSpec> = Simplify<
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface.
## Execution: the `tools/execute` waterfall shapes
## Execution: the `tools/pre-execute` / `tools/post-execute` pipeline shapes
`ctx.tools.execute()` runs each call through the `tools/execute` waterfall — the single seam where sandbox, permission, hook, and plan-mode plugins wrap or veto. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`.
`ctx.tools.execute()` runs each call through a two-waterfall pipeline — `tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins gate or transform a call. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`.
```ts type-equiv
interface ToolExecution {
@@ -100,6 +100,17 @@ interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
* `additionalContext` is a SEPARATE `context/message`. A step can carry
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
* and appends them only AFTER all `tool/result`s for the step, keeping
* tool-call/result adjacency intact. Carried on the result purely to ferry it
* from `execute()` up to the loop's per-step buffer.
*/
additionalContext?: HookContext
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
@@ -110,7 +121,22 @@ interface ToolExecutionResult {
}
```
A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly around your own logic), or return a `ToolExecutionResult` without calling `next()` to veto. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
```ts type-equiv
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
```
```ts type-equiv
type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
```
Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
## Tool-presentation UI vocabulary
+2
View File
@@ -44,6 +44,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
### Simplification
@@ -86,6 +87,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
### Simplification
@@ -17,7 +17,7 @@ Reject the pervasive `DeepReadonly<T>` type flip. Instead:
1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call.
2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`).
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown `tools/execute` waterfall ends the step), and both `idle→disposed` and `running→disposed` are legal.
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown tool-execution pipeline step ends the turn), and both `idle→disposed` and `running→disposed` are legal.
`DeepReadonly` was rejected because it is compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise.
@@ -12,7 +12,7 @@ The product principle (see the 微内核Harness实现思路 design doc) is "ever
Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
- **waterfall** (around-middleware) where plugins mutate or veto: `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/execute`, `llm/stream`, `system-prompt/assemble`.
- **waterfall** (around-middleware) where plugins mutate or veto: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
- **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors.
- **parallel** (awaited) for the one durability checkpoint: `session/flush`.
@@ -20,7 +20,7 @@ A swappable capability is **three packages**:
Implementation and consumer then evolve independently: a sandboxed executor replaces `dsh-bash-local` without touching a tool schema.
Alternatives considered: **one combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point). **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/execute` veto seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names.
Alternatives considered: **one combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point). **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/pre-execute` deny/ask seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names.
The split is not mandatory when the parts are genuinely one concern: the LLM seam folds interface + consumer into `dsh-llm` (the consumer is the loop itself, not a swappable schema surface) with adapters as the implementation packages. Don't split preemptively — a capability with one conceivable implementation and one consumer stays one package until a second appears.
@@ -66,7 +66,7 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin
## Risks and deferrals
- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name.
- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/pre-execute` deny in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name.
- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign.
- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process.
@@ -0,0 +1,45 @@
# RFC: Interception seams — the typed-Decision surface a hook programs against
Status: implemented (accepted 2026-06-30)
<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
## Context
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).
Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's `UserPromptSubmit`), no session-start signal (CC's `SessionStart`), the single `tools/execute` waterfall conflated the pre-gate and post-inspect phases (CC splits `PreToolUse`/`PostToolUse`), and `agent/turn-continuation` returned a bare `boolean` with no room for a force-continue *reason*. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it.
## Decision
Add/reshape the interception seams so every one returns a small, seam-specific **typed Decision union**, and the set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation).
**New `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/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below).
**Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern.
**Split** the single `tools/execute` waterfall into `tools/pre-execute` (→ `PreToolDecision` allow/deny/ask gate) and `tools/post-execute` (→ `PostToolDecision` accept/block, optionally replacing content or attaching `additionalContext`). Core dispatch sits between them as plain code inside `ToolRegistry.execute`'s outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an `isError` result that `post-execute` listeners can inspect.
**New `TurnEndReason` variant** `rejected` (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`.
### Three load-bearing loop decisions
1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected``cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn.
2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended.
3. **A forced `continue` `reason` is 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 existing `hasSteering` force-continue override).
### Pre-tool INPUT rewrite is DEFERRED (the over-reach signal)
`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement (`PostToolDecision.accept.content`) is safe because `tool/result` is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: `assistant/message` (the model-history source) and `tool/call` (the audit record) are both logged BEFORE execution, and live consumers READ `tool/call.arguments` for presentation (the ACP bridge remembers them for `presentResult`; `dsh-tool-bash` derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own [proposed RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md), and `TODO(pre-tool-input-rewrite)` anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production `tools/execute` listener mutated `exec.arguments`). The low-level capability to mutate `exec` in a `pre-execute` listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract.
### What this PR does NOT do
It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) — those belong to the `dsh-hook-protocol` library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (`packages/core/agent-loop/tests/interception.spec.ts`) proves all the seams compose end-to-end through the REAL loop with NO `hook/*` involved — the concrete proof that "native hooks are just a plugin". Compaction (`PreCompact`/`PostCompact`), the Notification hook, Codex `PermissionRequest`, the permission/`ask` system, and the Stop loop-guard remain deferred (`FIXME(permissions)` marks the `ask`→deny degrade).
## Consequences
The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the `dsh-tools` registry runs a two-waterfall pipeline; both are documented in [architecture.md](../../../architecture.md) and the package READMEs, and the decision types in [core-data-structures](../../../core-data-structures/core.md#interception-decisions) + [tools.md](../../../core-data-structures/tools.md). All existing `tools/execute` and `turn-continuation` listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new `rejected` reason to `cancelled` (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the `dsh-hooks-claude` bridge, which is what makes a hook observable end-to-end through ACP.
@@ -15,7 +15,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch
## Proposal
A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall.
A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/pre-execute`/`tools/post-execute` waterfalls.
It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm.
@@ -55,7 +55,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi
**3c. The single tool — `run_code`.** Registered normally in `ctx.tools` with one parameter `{ code: string (required) }`. Because it is an ordinary tool, the unchanged loop dispatches it through the normal path — this is the crux of "zero loop changes." Its `execute(args, exec)`:
1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: <deterministic sub-id>, name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/execute` waterfall, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones.
1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: <deterministic sub-id>, name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/pre-execute`/`tools/post-execute` waterfalls, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones.
2. Calls `ctx.codeRuntime.run({ code: args.code, sdk: bindings, signal: exec.signal })`.
3. Surfaces the outcome. A *successful* run returns `[{ type: 'text', text: <console logs + return value> }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise<ContentBlock[]>` and `ToolRegistry.execute()` hardcodes `isError: false` on any successful return — `isError: true` arises only from the registry's catch path. So on an error result the tool **throws a `CodeRunError extends HarnessError`** (`HarnessError` is exported from `dsh-llm`; the registry catch turns any throw into `isError: true` with the message as text, and a `HarnessError` additionally carries structured `{ name, code }`). An alternative — registering `run_code` handling as a `tools/execute` listener that returns a full `ToolExecutionResult` and can set `isError` directly — is noted; the throw is simpler and preferred.
@@ -0,0 +1,39 @@
# RFC: Pre-tool input rewrite — a consistent design (proposed)
Status: proposed (2026-06-30)
<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
## Context
The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) added `tools/pre-execute` returning a `PreToolDecision` (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's `arguments` before it runs). Claude Code's `PreToolUse` hook offers an `updatedInput`, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision.
## The problem: three readers of pre-execution arguments
In the loop, a tool call's arguments are committed to the log and read by live consumers BEFORE the tool executes:
1. **`assistant/message`** is appended before tool dispatch — it is the model-history source `deriveMessages()` replays, so it carries the tool-call arguments the model itself emitted.
2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`.
3. **Live presentation reads `tool/call.arguments`**: the ACP bridge remembers them and passes them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate `exec.arguments` in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this.)
## Proposed design (sketch — to validate against the code when built)
Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution:
- The `tool/call` audit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping).
- The `assistant/message` in derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect.
- Presentation (`presentCall`/`presentResult`) reads the rewritten arguments, so the UI shows what actually ran.
The shape would extend `PreToolDecision` with an allow-variant `arguments` (or a dedicated `{kind:'rewrite', arguments}`), and the loop would thread the rewrite through the three readers above rather than only into `ctx.tools.execute()`.
## Why not now
The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an `updatedInput` logs it and surfaces a faithful-but-degraded warning (like `ask`→deny) until this lands. This RFC is the home for the consistent design; `TODO(pre-tool-input-rewrite)` in the loop's pre-execute call site anchors it.
## Open questions
- Does rewriting the `assistant/message` tool-call block corrupt any provider's expectation on replay, or is a separate correction safer?
- Should the original arguments be preserved on the `tool/call` event (audit) and, if so, under what field?
- How does this interact with a future permission `ask` flow (a user approving a rewritten call)?
+1 -1
View File
@@ -76,7 +76,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `llm/` | `llm` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `tools/` | `core` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |
+1 -1
View File
@@ -26,4 +26,4 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
## Sandboxing
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Wrap the `tools/execute` waterfall (veto/ask) or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
+2 -2
View File
@@ -4,8 +4,8 @@
* own process group (see `./run.ts` for the plumbing and the agent-tool
* survey notes), tracks background tasks, and kills everything on dispose.
*
* TODO(permissions/sandbox): execution policy does NOT belong here — wrap
* the `tools/execute` waterfall (see docs/architecture.md § plugin
* TODO(permissions/sandbox): execution policy does NOT belong here — use
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md § plugin
* checklist) or implement a sandboxing `BashExecutor`. Reference points:
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
* seatbelt/landlock plus an execpolicy prefix-rule engine.
+1 -1
View File
@@ -46,4 +46,4 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks
## Permissions
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
+1 -1
View File
@@ -31,7 +31,7 @@
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
*
* TODO(permissions): commands run with the executor's full authority. The
* permission/sandbox seam is the `tools/execute` waterfall (veto/ask) plus
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
* sandboxing `BashExecutor` implementations — see docs/architecture.md
* § plugin checklist.
*
@@ -123,7 +123,10 @@ describe('bash tool through the agent loop', () => {
textResponse('Background task finished.'),
])
// The second tool call needs the REAL task id from the first result;
// a tools/execute waterfall listener rewrites the scripted arguments.
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
// the low-level capability to mutate `exec` before dispatch — the
// unadvertised mechanism behind a future first-class input-rewrite decision;
// here it is a test shim to thread the generated id, not a product feature.)
let taskId = ''
const ctx = await harness(adapter)
@@ -137,7 +140,7 @@ describe('bash tool through the agent loop', () => {
if (match) taskId = match[1]!
}
})
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'bash_output') {
exec.arguments = { task_id: taskId }
}
+1 -1
View File
@@ -6,7 +6,7 @@ The packages every harness build is assembled from: the session log, the system-
|---|---|---|
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
+1 -1
View File
@@ -13,7 +13,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
@deepseek-ai/dsh-session event-sourced session log + store
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
@deepseek-ai/dsh-tools tool registry + tools/execute waterfall
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
+14 -6
View File
@@ -45,10 +45,14 @@ Agents listed in config are auto-created at startup.
One invocation of `runLoop()` drives one agent for its whole lifetime:
```
create agent → emit agent/session-start(source) ⟵ once, before turn 1
forever:
wait for queued messages (idle)
TURN (error-contained):
drain queued → 'turn/start' → session('user/message')
'turn/start'
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
inject additionalContext) | block (→ session('prompt/blocked'), drop)
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
STEP loop:
drain steering
assembly = systemPrompt.assemble()
@@ -58,10 +62,14 @@ forever:
stream llm.stream(request) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
each tool-call: session('tool/call')
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
→ session('tool/result')
append buffered post-execute additionalContext as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation
if !cont: break
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
if action==stop (and no pending steering): break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
@@ -75,9 +83,9 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/execute`
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
- Persistence: `session/event` + `session/flush`
- UI: `agent/stream-chunk` + `agent/*` events
+28 -7
View File
@@ -10,7 +10,7 @@
import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -129,7 +129,7 @@ export class AgentLoop extends Service implements AgentFactory {
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
const { agent } = this.start(id, options, session)
const { agent } = this.start(id, options, session, 'startup')
return agent
}
@@ -152,7 +152,9 @@ export class AgentLoop extends Service implements AgentFactory {
...options.seed !== undefined ? { seed: options.seed } : {},
meta: options.meta ?? {},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup')
}
/**
@@ -224,7 +226,7 @@ export class AgentLoop extends Service implements AgentFactory {
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
}
/**
@@ -261,14 +263,33 @@ export class AgentLoop extends Service implements AgentFactory {
* so a throwing `session/created`/`agent/created` listener unwinds the
* already-yielded disposers instead of leaking.
*
* `source` says why the session began ({@link SessionStartSource}); it is
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
* it) and BEFORE the loop starts its first turn. The emit is contained: a
* throwing session-start listener must not abort agent construction — it is
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
* no open turn here to balance; the durable evidence of a session-start hook
* is whatever it `inject()`ed.)
*
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
*/
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
const dispose = this.ctx.effect(function* (this: AgentLoop) {
yield this.ctx.sessions.enter(session)
this.ctx.sessions.announce(session)
yield this.ctx.agents.register(agent)
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
// never aborts construction (no open turn to balance here).
try {
this.ctx.emit('agent/session-start', agent, source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
const stop = agent.start()
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
// actual exit so its closing flush lands while onAppend (yielded above,
@@ -295,8 +316,8 @@ export class AgentLoop extends Service implements AgentFactory {
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
*/
private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session)
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session, source)
let disposing: Promise<void> | undefined
return { agent, dispose: () => (disposing ??= disposeAgent()) }
}
+94 -15
View File
@@ -10,6 +10,7 @@
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
@@ -142,10 +143,13 @@ export interface LoopHandle {
* The agent loop. One invocation drives one agent for its whole lifetime:
*
* ```
* create agent → emit agent/session-start(source) ⟵ once, before turn 1
* forever:
* wait for queued messages (idle)
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
* drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror)
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
@@ -158,13 +162,17 @@ export interface LoopHandle {
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* drain steering → session('steering/message'); emit agent/steering
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
* if !cont && steering arrived from step/end session-event/continuation listeners: cont = true
* if !cont: break
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* if action==stop: break
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
* re-enqueue leftover steering as queued ⟵ steering is never stranded
@@ -359,14 +367,55 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
// listener — append pushes before notifying — still gets its turn/end).
session.append('turn/start', { turn, trigger })
// Record the queued user messages INSIDE the turn (after turn/start), so
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
// while appending these is caught below and the turn is still closed.
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
let anyAllowed = false
// Seeded with a floor (only observable if the batch were empty, which
// runTurn never allows — it is called with ≥1 queued message); each `block`
// decision carries a required `reason` and overwrites it, so a fully-blocked
// batch always reports the last vetoing reason.
let lastBlockReason = 'prompt blocked by hook'
for (const message of queued) {
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
const decision = await ctx.waterfall(
'agent/prompt-submit', agent, message.content, message.source,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
if (decision.kind === 'block') {
lastBlockReason = decision.reason
// Record the veto durably: `PromptDecision.reason` is the durable record
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
// blocked, another allowed) does not end `rejected` at all — so without
// this append a blocked prompt would vanish from the log whenever any
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
// place of the `user/message` this prompt would have become.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
anyAllowed = true
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = decision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// `allow.additionalContext` is a SEPARATE context/message the next request
// also sees. The turn is open, so inject() appends it into THIS turn.
if (decision.additionalContext) {
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
}
}
while (true) {
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
// zero-step turn that ends `rejected`: break BEFORE the first step so the
// boundary stays balanced (turn/start → turn/end) and the block is a
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
// only ever fires on the first iteration.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
}
step += 1
// Steering from the previous round's continuation listeners joins before
@@ -484,10 +533,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
if (closeStep()) break
const defaultDecision = stepOutcome.hadToolCalls || steered
let shouldContinue: boolean
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
try {
shouldContinue = await ctx.waterfall(
decision = await ctx.waterfall(
'agent/turn-continuation', agent, turn, defaultDecision,
() => Promise.resolve(defaultDecision),
)
@@ -497,9 +546,18 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
break
}
// A forced `continue` may carry model-facing context: record it as
// next-STEP steering (the steering channel), so the continued turn's next
// iteration drains it before its request — the typed twin of the /goal
// step/end-steer pattern.
if (decision.action === 'continue' && decision.reason) {
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
}
let shouldContinue = decision.action === 'continue'
// Steering from step/end session-event or continuation listeners (the
// /goal pattern) demands the model see it — it overrides a negative
// decision; the next iteration's drain records it.
// /goal pattern) demands the model see it — it overrides a stop decision;
// the next iteration's drain records it.
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
// A cancel that landed during the continuation window — after the step's
@@ -682,6 +740,12 @@ async function runStep(
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
@@ -692,6 +756,12 @@ async function runStep(
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
@@ -703,7 +773,7 @@ async function runStep(
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a tools/execute waterfall listener returning a
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
@@ -715,6 +785,8 @@ async function runStep(
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// Buffer (don't append yet) any post-execute additionalContext for this call.
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
@@ -723,6 +795,13 @@ async function runStep(
/* v8 ignore stop */
}
// Append buffered post-execute context AFTER every tool/result, preserving
// tool-call/result adjacency across the whole batch. inject() appends into the
// open turn (a context/message at its chronological position).
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })
}
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
}
@@ -283,7 +283,7 @@ describe('Agent.cancel()', () => {
if (subject === agent && !continued) {
continued = true
agent.cancel('from continuation')
return true // vote to continue — the post-waterfall marker check must override
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
}
return next()
})
@@ -0,0 +1,529 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, {
AgentId,
type ContinuationDecision,
type PromptDecision,
type SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, the reshaped `agent/turn-continuation`
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
* split with `additionalContext` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
* external protocol — a native plugin uses the typed decisions directly.
*/
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
describe('agent/prompt-submit', () => {
it('allow (default via next) records the user/message unchanged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
send(agent, 'hello')
await waitForIdle(ctx, agent)
expect(seen).toEqual(['hello'])
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('allow with content REWRITES the prompt before it is recorded', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
send(agent, 'original')
await waitForIdle(ctx, agent)
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
// the rewritten prompt is what reached the model
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContext injects a separate context/message into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
const userMsg = log.find(e => e.type === 'user/message')
const ctxMsg = log.find(e => e.type === 'context/message')
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
// both the prompt and the injected context reach the model
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
// before the single deriveMessages(). So a compaction listener on
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
// otherwise it would measure/compact stale history. This cross-test proves
// the two seams compose in the right order (each is covered in isolation
// elsewhere; this asserts they see each other's effects on the same turn).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
}))
// The pre-step seam (where compaction lives) derives the surface it would act
// on. Capture what it sees on the first step.
let preStepDerived: string | undefined
ctx.on('agent/pre-step', (subject, _turn, step) => {
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
})
send(agent, 'ORIGINAL prompt')
await waitForIdle(ctx, agent)
// The pre-step seam ran and saw BOTH the rewrite (not the original) and the
// injected context — i.e. the prompt-submit effects landed before it.
expect(preStepDerived).toBeDefined()
expect(preStepDerived).toContain('REWRITTEN prompt')
expect(preStepDerived).toContain('injected ctx')
expect(preStepDerived).not.toContain('ORIGINAL prompt')
})
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy' }))
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'do something')
await waitForIdle(ctx, agent)
// the model was never called
expect(adapter.requests).toHaveLength(0)
// the turn opened and closed balanced, with no user/message and no step
const log = events(agent)
expect(log.some(e => e.type === 'turn/start')).toBe(true)
expect(log.some(e => e.type === 'turn/end')).toBe(true)
expect(log.some(e => e.type === 'user/message')).toBe(false)
expect(log.some(e => e.type === 'step/start')).toBe(false)
// the veto is recorded durably as a prompt/blocked in the open turn
const blocked = log.find(e => e.type === 'prompt/blocked')
expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
content: [{ type: 'text', text: 'do something' }],
reason: 'blocked by policy',
})
// ended rejected with the block reason
expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
const turnEnd = log.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
// vetoed prompt and its reason would vanish from the log entirely.
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
// both sends land before the loop drains → one batched turn
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
const log = events(agent)
// the allowed prompt became a user/message and drove exactly one model call
const userMsgs = log.filter(e => e.type === 'user/message')
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
// the blocked prompt is durably recorded, with its content + reason
const blocked = log.filter(e => e.type === 'prompt/blocked')
expect(blocked).toHaveLength(1)
expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
content: [{ type: 'text', text: 'secret' }],
reason: 'policy: no secrets',
})
// the turn did NOT reject — a sibling was allowed — so the boundary reason
// alone would not have preserved the block
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
})
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async () => {
if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'allow' as const }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
// turn balanced
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
// loop survives: a second prompt runs normally
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
})
})
describe('agent/session-start', () => {
it('fires once with source "startup" for a fresh create, before the first turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// fires synchronously at create, before any turn
expect(sources).toEqual(['startup'])
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
send(agent, 'go')
await waitForIdle(ctx, agent)
// still only one session-start
expect(sources).toEqual(['startup'])
})
it('a session-start listener can inject context the first request sees', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
// the injected context reached the model on the first (only) request
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
// and is recorded with the plugin source, never mislabeled as a user prompt
const ctxMsg = events(agent).find(e => e.type === 'context/message')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
})
it('a throwing session-start listener does not abort agent construction', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
// create must not throw — the listener error is contained/logged
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
expect(agent.id).toBe(AgentId('a1'))
// and the agent still runs
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
})
describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
if (!forced) {
forced = true
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
}
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
// same turn, two steps
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
// the reason was recorded as steering BEFORE step 2, with its plugin source
const steering = log.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }])
expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' })
// and reached the next request
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal')
})
it('a stop decision ends the turn even when the step had tool calls', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
send(agent, 'go')
await waitForIdle(ctx, agent)
// default would have continued (had tool calls), but the stop decision wins
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'tool/result')).toBe(true)
})
})
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
// One assistant step with TWO tool calls; the second model response stops.
const twoCalls = [
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
{ type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
{ type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
{ type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
]
const adapter = new MockAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Each call attaches additionalContext naming itself.
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
send(agent, 'go')
await waitForIdle(ctx, agent)
// Event order in the log: both tool/results, THEN both context/messages —
// never interleaved (which would break tool-call/result adjacency).
const types = events(agent).map(e => e.type)
const firstResult = types.indexOf('tool/result')
const lastResult = types.lastIndexOf('tool/result')
const firstCtx = types.indexOf('context/message')
expect(firstResult).toBeGreaterThanOrEqual(0)
expect(lastResult).toBeGreaterThan(firstResult) // two results
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
// both contexts present
const ctxTexts = events(agent)
.filter(e => e.type === 'context/message')
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
})
})
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
it('deny short-circuits dispatch into an isError result the model sees', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
const ctx = await harness(adapter)
let ran = false
ctx.tools.register(defineTool({
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result'
&& result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
})
})
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
// The whole point of the interception taxonomy: a "native hook" needs no
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
// cordis plugin subscribing to the canonical events and returning typed
// decisions. This proves all four seams compose end-to-end through the REAL
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
const NativeGuard = {
name: 'native-guard',
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => {
agent.inject(
[{ type: 'text', text: `policy active (started: ${source})` }],
{ source: { kind: 'plugin', plugin: 'native-guard' } },
)
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
return next()
})
// 3. PreToolUse: deny a dangerous tool by name.
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
return next()
})
// 4. PostToolUse: attach context after a tool runs.
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
const decision = await next()
if (decision.kind === 'accept') {
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
}
return decision
})
},
}
it('all four seams fire for a real allowed turn with a tool call', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'please echo hi')
await waitForIdle(ctx, agent)
const log = events(agent)
// session-start preamble injected
expect(log.some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
// prompt allowed → user/message recorded
expect(log.some(e => e.type === 'user/message')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
expect(log.some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
})
it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
})
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const fiber = await ctx.plugin(NativeGuard)
await fiber.dispose()
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
})
})
+3 -3
View File
@@ -302,7 +302,7 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
if (steps < 3) return true
if (steps < 3) return { action: 'continue' as const }
return next()
})
@@ -325,7 +325,7 @@ describe('agent loop', () => {
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async () => false as const)
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -510,7 +510,7 @@ describe('agent loop', () => {
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
if (steps < 2) return true
if (steps < 2) return { action: 'continue' as const }
return next()
})
@@ -94,6 +94,36 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose()
})
it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
// Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
// Lifecycle 2: resuming the persisted session emits session-start 'resume'.
const adapter2 = new MockAdapter([textResponse('b')])
const ctx2 = new Context()
await ctx2.plugin(LlmService)
await ctx2.plugin(SessionStore)
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const sources2: string[] = []
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
expect(sources2).toEqual(['resume'])
await ctx2.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
// in its header) by creating it with a complete-turn seed — the write path
@@ -4,7 +4,7 @@ import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -272,12 +272,12 @@ describe('HIGH: plugin exceptions are contained', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken continuation plugin')
}
return false
return { action: 'stop' }
})
const errors: Error[] = []
@@ -913,7 +913,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
it('a tools/execute listener returning a mismatched callId cannot orphan the call↔result pairing', async () => {
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
// Model emits a tool-call with id "c1", then a final text turn.
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { x: 1 }),
@@ -927,12 +927,13 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
async execute() { return [{ type: 'text', text: 'ok' }] },
}))
// A waterfall listener short-circuits with a result carrying the WRONG
// callId (a listener-internal/proxy id). The loop must still record the
// tool/result under the model's authoritative call.id.
ctx.on('tools/execute', (exec) => {
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
}, { prepend: true })
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
+10 -3
View File
@@ -31,6 +31,7 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag
- `agent/created`, `agent/disposed` — registration/deregistration
- `agent/status` — idle / running / disposed transition
- `agent/queued` — message entered inbox (source-resolved, steering flag)
- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees).
#### Boundaries are durable session events, not `agent/*` emits
@@ -38,10 +39,16 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
#### Interception seams
`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly):
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard)
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
#### Streaming + tool (emit)
+105 -11
View File
@@ -16,13 +16,14 @@
* durability checkpoint. Answers "what happened, durably/replayably." A
* consumer that wants the live transcript subscribes here.
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
* live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`,
* `agent/step-result`, `agent/turn-continuation`) that mutate/veto, and
* TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`,
* `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that
* notify with the `Agent` in hand. Turn/step boundaries are NOT here — they
* are durable `session/event` records (see the rule below). Answers "right
* now, with the agent object — intercept or observe."
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/steering`, `agent/session-start`)
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
* they are durable `session/event` records. Answers "right now, with the agent
* object — intercept or observe."
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
*
* **The rule:** a durable, replayable fact is a SessionEvent; a live
@@ -34,6 +35,11 @@
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
* the convention pinned by
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
*
* @module @deepseek-ai/dsh-agent/types
*/
@@ -66,6 +72,68 @@ export interface SendOptions {
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* Model-facing context an interception listener wants the agent to SEE on the
* next request — the canonical shape behind every "inject extra context"
* decision ({@link PromptDecision}, {@link PostToolDecision},
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
* context as a user prompt and corrupt derived history. A bridge sets
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
* optional — the label is load-bearing, never defaulted here.
*/
export interface HookContext {
content: ContentBlock[]
source: MessageSource
}
/**
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
*
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
* separate `context/message` the next request also sees.
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
* the durable record of why. The loop appends a `prompt/blocked` session event
* (carrying the original content, source, and `reason`) in place of the
* dropped `user/message`, so the veto survives replay even in a MIXED batch
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
* hook").
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
/**
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
* returns. The loop computes the default (`continue` when the step had tool
* calls or steering was injected, else `stop`); listeners override it to
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
*
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
* steering within the SAME turn (the loop enqueues it through the steering
* channel, so the continued turn's next step sees it). This is the typed twin of
* the existing "steer from a step/end listener" `/goal` pattern.
*/
export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
/**
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
* bridge keys its SessionStart hook's matcher on this (Claude Code's
* `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create
* (including a seeded/forked create — a seed is NOT a resume); `resume` = a
* persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are
* driven by those subsystems (compact = `TODO(compaction)`).
*/
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/**
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
* programs against. The concrete implementation lives in
@@ -183,6 +251,19 @@ declare module 'cordis' {
*/
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- session lifecycle (emit) ----
/**
* The agent's session lifecycle began, fired once before its first turn.
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
* carries no veto — a session-start listener that wants to seed context does
* so via `agent.inject()` (a `context/message` the first request sees), not
* by returning a decision. Cannot block the session from starting; that gap
* is deliberate (a bridge logs/injects, it does not gate startup).
* @mode emit
*/
'agent/session-start'(agent: Agent, source: SessionStartSource): void
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
// `step/end` session events off the `session/event` feed (the session log is
@@ -222,6 +303,16 @@ declare module 'cordis' {
// prompt provider, or move token-pressure measurement behind a
// compaction-specific seam instead of the shared pre-step checkpoint.
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
/**
* Waterfall: decide what happens to ONE drained queued message before it
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
* attaching `additionalContext`) or block it. Fires inside the already-open
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
* Call `next()` to delegate to the default (allow unchanged), or return a
* {@link PromptDecision} without calling `next()` to short-circuit.
* @mode waterfall
*/
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, model switching, tool filtering, …). Call `next()` to
@@ -238,12 +329,15 @@ declare module 'cordis' {
*/
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
/**
* Waterfall: override the turn-continuation decision. The default
* (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners
* can force-continue (/goal, /loop) or force-stop (budget guards).
* Waterfall: override the turn-continuation decision via a typed
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
* when the step had tool calls or steering was injected, else `stop`.
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
* `reason` recorded as next-step steering) or force-stop (budget guards).
* Call `next()` to delegate to the default, or return a decision to override.
* @mode waterfall
*/
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean>
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
// ---- streaming + tool notifications (emit) ----
/**
+1 -1
View File
@@ -49,7 +49,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
+21
View File
@@ -134,6 +134,16 @@ export interface TurnEndReasonMap {
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
'max-tokens': { kind: 'max-tokens' }
/**
* The turn's entire prompt batch was BLOCKED before any step ran — every
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
* hook). The turn still opened (so the boundary stays balanced and the block
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
* message from the vetoing decision. Distinct from `aborted` (a user-driven
* cancel) and `error` (a failure): the prompt was rejected by policy, not
* interrupted or broken. A UI renders it as "prompt blocked by hook".
*/
rejected: { kind: 'rejected'; reason: string }
/**
* The turn never ended on its own: the process crashed mid-turn and a
* persistence backend later closed the orphaned (open) turn on reload so the
@@ -194,6 +204,17 @@ export interface SessionEventMap {
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
* record of a blocked prompt and why. Appended in place of the `user/message`
* the prompt would have become, so the block survives replay even in a MIXED
* batch where another queued prompt is allowed (there the turn does not end
* `rejected`, so the boundary reason alone would not preserve it). `content`
* is the original prompt the listener rejected; `reason` is the veto text
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
+8 -5
View File
@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall.
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context).
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -9,7 +9,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
### Injected services
@@ -19,20 +19,23 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
| Event | Mode | Purpose |
|---|---|---|
| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) |
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards both `error` and `meta` onto the `tool/result` session event (for retry/sandbox plugins, replay, and result-card rendering).
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto).
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
+176 -34
View File
@@ -1,8 +1,9 @@
/**
* Tool registry and execution waterfall. Plugins register tools; the registry
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through the `tools/execute` waterfall for sandbox, permission, and hook
* plugins to wrap or veto.
* through `tools/pre-execute` (the allow/deny gate) → core dispatch →
* `tools/post-execute` (inspect/replace the result, attach context) for
* sandbox, permission, and hook plugins to gate or transform a call.
*
* @module @deepseek-ai/dsh-tools
*/
@@ -10,7 +11,7 @@
import { Context, Service } from 'cordis'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -51,14 +52,31 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall around every tool execution — the single seam where sandbox,
* permission, hook, and plan-mode plugins wrap or veto a call. Listeners
* receive `(exec, next)`: call `next()` to proceed (possibly around your
* own logic), or return a {@link ToolExecutionResult} without calling
* `next()` to short-circuit (veto).
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
* or return a {@link PreToolDecision} without calling `next()` to
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` degrades to deny until the permission
* system lands (`FIXME(permissions)`).
* @mode waterfall
*/
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. The core tool
* dispatch sits between the two waterfalls as plain code, all inside
* `execute`'s outer try/catch (and the tool body keeps its own inner
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
* result).
* @mode waterfall
*/
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* A tool was registered or unregistered (the available tool set changed).
* @mode emit
@@ -121,7 +139,7 @@ export interface ToolResult {
meta?: unknown
}
/** One pending tool call, as it flows through the execution waterfall. */
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
export interface ToolExecution {
callId: CallId
name: string
@@ -162,6 +180,18 @@ export interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
* `additionalContext` is a SEPARATE `context/message`. A step can carry
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
* and appends them only AFTER all `tool/result`s for the step, keeping
* tool-call/result adjacency intact. Carried on the result purely to ferry it
* from `execute()` up to the loop's per-step buffer.
*/
additionalContext?: HookContext
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
@@ -171,6 +201,41 @@ export interface ToolExecutionResult {
meta?: unknown
}
/**
* The decision a `tools/pre-execute` listener returns for one pending call.
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
*
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
* presentation, read the pre-execution arguments, so an execution-only rewrite
* would desync the UI from what RAN. That consistency redesign is its own
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
* - `ask` is the permission-prompt intent; until the permission system exists it
* degrades to `deny` (`FIXME(permissions)`).
*/
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
/**
* The decision a `tools/post-execute` listener returns for one finished call.
* Maps onto Claude Code's `PostToolUse` decision.
*
* - `accept` keeps the call successful; optional `content` REPLACES the
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
* returns, so a replaced result is the single source of truth for both derived
* history and UI). Optional `additionalContext` rides to the next request.
* - `block` turns the call into an `isError` result whose content is the
* corrective `feedback` (the model is told the call was rejected and why),
* optionally also attaching `additionalContext`.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
* instances use `.message`; non-Error objects with a string `message`
@@ -193,8 +258,9 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/execute` waterfall. The registry
* contributes its schemas into the system-prompt assembly.
* loop executes calls through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
@@ -258,36 +324,112 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/execute` waterfall. If the tool is
* not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. If the tool or a waterfall listener throws, the error is
* caught and returned as an `isError` result so the loop records a failed tool
* call instead of failing the whole turn; a thrown {@link HarnessError}
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
* and the inspect/transform seam; core dispatch sits between them as plain
* code. The whole thing is wrapped in one outer try/catch so a throwing
* listener (in either waterfall) becomes an `isError` result instead of
* failing the turn; the tool body ALSO keeps its own inner try/catch, so a
* thrown tool becomes an `isError` result that `post-execute` listeners can
* still inspect. If the tool is not registered, the result is an `isError`
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
* surfaces its `{ name, code }` on the result.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
try {
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
try {
const tool = this.store.get(exec.name)
// Unknown tool routes through the same catch as a tool-thrown error, so
// both failure classes get structured `{ name, code }` from one path.
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
// until the permission system lands) skips dispatch entirely. ---
const decision = await this.ctx.waterfall(
this, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
if (decision.kind !== 'allow') {
// deny → isError. ask has no permission UI yet, so degrade to deny
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
// a real prompt; today it is the conservative "not allowed".
const reason = decision.kind === 'deny'
? decision.reason
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${reason}` }],
isError: true,
}
})
return await this.postExecute(exec, denied)
}
// --- Core dispatch (plain code between the waterfalls). The tool body's
// own try/catch turns a throw into an isError result so post-execute can
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
result = toolErrorResult(exec.callId, error)
}
return await this.postExecute(exec, result)
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
// machinery) becomes an isError result, never a turn failure.
return toolErrorResult(exec.callId, error)
}
}
/**
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
* `content` when given), `block` turns it into an `isError` whose content is
* the corrective `feedback`. Either decision may attach `additionalContext`,
* which is ferried on the returned result for the loop's per-step buffer.
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
// the same `result` reference, so a post-waterfall read of `result.callId`/
// `.isError`/`.error` could carry a listener's mutation — violating the
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. `content` is copied into
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
// cannot leak into the returned content either (the elements are the same
// references — the snapshot guards the array structure, not deep immutability).
const dispatched = {
callId: exec.callId,
content: [...result.content],
isError: result.isError,
...result.error ? { error: result.error } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
const decision = await this.ctx.waterfall(
this, 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
return {
callId: dispatched.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
}
}
// accept: replace content if supplied, preserve the dispatched isError/error.
return {
...dispatched,
...decision.content ? { content: decision.content } : {},
...additionalContext ? { additionalContext } : {},
}
}
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
+1 -1
View File
@@ -182,7 +182,7 @@ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
/**
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
* (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and
* (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
* returns an `isError` ToolExecutionResult carrying the structured error, so
* the model can self-correct and downstream plugins can route on the code.
*/
+140 -27
View File
@@ -4,7 +4,7 @@ import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type ToolExecutionResult,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -144,53 +144,150 @@ describe('ToolRegistry', () => {
expect(err.message).toBe('unknown tool "ghost"')
})
it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.name === 'echo') {
return {
callId: exec.callId,
content: [{ type: 'text', text: 'denied by policy' }],
isError: true,
}
}
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
it('an ask decision degrades to deny until the permission system lands', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
({ kind: 'ask', reason: 'needs approval' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
})
it('an ask decision with no reason degrades to deny with a default message', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
})
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
})
it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
it('a block decision can ALSO attach additionalContext', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({
kind: 'block',
feedback: [{ type: 'text', text: 'rejected' }],
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
// execute() returns — the registry snapshots the authoritative fields before
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const order: string[] = []
ctx.on('tools/execute', async (_exec, next) => {
order.push('first:before')
const result = await next()
order.push('first:after')
return result
ctx.on('tools/pre-execute', async (_exec, next) => {
order.push('pre:before')
const decision = await next()
order.push('pre:after')
return decision
})
ctx.on('tools/execute', async (_exec, next) => {
order.push('second:before')
const result = await next()
order.push('second:after')
return result
ctx.on('tools/post-execute', async (_exec, _result, next) => {
order.push('post:before')
const decision = await next()
order.push('post:after')
return decision
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
expect(result.isError).toBe(false)
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
// pre runs fully (gate) before dispatch, then post runs over the result.
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
})
it('returns an isError result when a tools/execute listener throws', async () => {
it('returns an isError result when a tools/pre-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
ctx.on('tools/pre-execute', async () => {
throw new Error('permission hook broke')
})
@@ -203,10 +300,26 @@ describe('ToolRegistry', () => {
})
})
it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
it('returns an isError result when a tools/post-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
ctx.on('tools/post-execute', async () => {
throw new Error('post hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: post hook broke' }],
isError: true,
})
})
it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async () => {
throw new HarnessError('denied', 'DENIED')
})
@@ -276,6 +276,14 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
// Abort AFTER the tool body has had a chance to register its abort listener
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
// body runs, so the listener is not registered synchronously). A few
// microtask turns let execute() reach `addEventListener('abort')`, so this
// exercises the LIVE onAbort bridge — distinct from the already-aborted
// sync path the next test covers.
await Promise.resolve()
await Promise.resolve()
controller.abort()
const result = await pending
expect(cancelled).toHaveBeenCalledTimes(1)
+1 -1
View File
@@ -34,7 +34,7 @@ Session log (per session):
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown `tools/execute` waterfall ends the step with no `tool/result`, which is legal).
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
Agent status (per agent):
+2 -2
View File
@@ -256,8 +256,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
case 'tool/result': {
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tools/execute
// waterfall ends the step with no tool/result, which is legal.)
// does NOT hold: a call may have no result — a throwing tool-execution
// pipeline step ends the turn with no tool/result, which is legal.)
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) {
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
+1 -1
View File
@@ -71,7 +71,7 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as
## Known limitations (tracked TODOs)
- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
## stdout is the protocol
+8 -1
View File
@@ -17,7 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* The mapping is total over the kinds the loop actually produces today
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`).
* `TurnEndReason` is
* merge-extensible, so an unknown future kind falls through to `end_turn` —
* the safest default (the turn DID end; we just lack a more specific wire
* reason) — rather than throwing into the SDK, which would reject an unknown
@@ -34,6 +35,10 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* for any non-bridge caller / property test.)
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
* cancellation from the client's perspective)
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
* hook before any step ran — ACP has no "rejected" reason, and a
* blocked prompt is, from the client's view, the prompt not being
* carried out; `cancelled` is the closest legal wire reason)
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
@@ -45,6 +50,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
return 'cancelled'
case 'disposed':
return 'cancelled'
case 'rejected':
return 'cancelled'
case 'error':
return 'end_turn'
// Merge-extensible: an unknown future TurnEndReason kind still has to
+1 -1
View File
@@ -22,7 +22,7 @@
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. The `tools/execute` permission gate is
* `session/update` notifications. The `tools/pre-execute` permission gate is
* deferred — see the TODO(rfc010-permission-gate) note below.
*
* stdout is the protocol: this plugin must run in an example that loads NO
+1
View File
@@ -16,6 +16,7 @@ describe('turnEndToStopReason', () => {
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
})
+6
View File
@@ -10,6 +10,10 @@
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
@@ -34,6 +38,8 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },