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:
49 files changed
+1476
-195
No files matched your search
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) ----
|
||||
/**
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user