Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks

Adopts #211 (Code Mode tools: run_code + the code/both-mode snapshot
scenarios). Tool-catalog expectations take the union (run_code +
task_*); the two new pinsHeader fixtures (code-mode-turn,
both-mode-turn) were recorded on master without the task runtime, so
they are re-pinned KEYLESSLY by replaying their recorded chunks against
the merged tree (same procedure as text-turn) — the fixture diff is
exactly the header delta: task tool schemas, the tool:tasks prompt
section, and the bash background wording.
This commit is contained in:
Yichen Jiang
2026-07-10 00:08:03 +08:00
60 changed files with 2621 additions and 129 deletions
+2 -1
View File
@@ -113,6 +113,7 @@ flowchart LR
svc_bash --> pkg_hooks_claude
svc_bash --> pkg_hooks_codex
svc_bash --> pkg_tool_bash
svc_codeRuntime --> pkg_tools
svc_compact --> pkg_compact_basic
svc_fs --> pkg_tool_fs
svc_llm --> pkg_agent_loop
@@ -159,7 +160,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | - | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer). |
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
+52 -10
View File
@@ -42,7 +42,8 @@ Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts)
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
* `persistenceRoot` is the JSONL backend's directory.
* `tools` is the tool registry's config (its presentation `mode`, forwarded
* through agent-core); `persistenceRoot` is the JSONL backend's directory.
*/
export interface Config {
/** Model name for ACP-created agents (must have a registered adapter). */
@@ -51,12 +52,16 @@ export interface Config {
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
}
```
Source: [`packages/ui/acp-agent/src/index.ts:50`](../packages/ui/acp-agent/src/index.ts)
Depends on: [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/ui/acp-agent/src/index.ts:52`](../packages/ui/acp-agent/src/index.ts)
## `@deepseek-ai/dsh-agent-core`
@@ -66,10 +71,12 @@ Source: [`packages/ui/acp-agent/src/index.ts:50`](../packages/ui/acp-agent/src/i
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order). Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic); the schema is
* the INTERSECTION of the owners' own schemas, so validation and defaulting
* can never drift from them.
* order), the `tools` object to the tool registry (its presentation `mode`).
* Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
* schema is the INTERSECTION of the owners' own schemas (the registry's
* nested under its `tools` key), so validation and defaulting can never
* drift from them.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -78,12 +85,14 @@ export interface Config {
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
}
```
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt)
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/core/agent-core/src/index.ts:72`](../packages/core/agent-core/src/index.ts)
Source: [`packages/core/agent-core/src/index.ts:74`](../packages/core/agent-core/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -492,6 +501,8 @@ export interface Config {
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -505,7 +516,9 @@ export interface Config {
}
```
Source: [`packages/ui/stdio-agent/src/index.ts:62`](../packages/ui/stdio-agent/src/index.ts)
Depends on: [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/ui/stdio-agent/src/index.ts:63`](../packages/ui/stdio-agent/src/index.ts)
## `@deepseek-ai/dsh-subagent-acp`
@@ -804,6 +817,36 @@ export interface Config {
Source: [`packages/web/tool-web/src/index.ts:40`](../packages/web/tool-web/src/index.ts)
## `@deepseek-ai/dsh-tools`
Requires: `systemPrompt`
```ts config-catalog
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* registered tool as a wire function definition — byte-for-byte today's
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
* the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
*/
mode?: ToolPresentationMode
}
/** How the registry presents its tools to the model (see {@link Config.mode}). */
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:319`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-web`
```ts config-catalog
@@ -930,7 +973,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts))
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
## Seam packages (not directly loadable)
+4
View File
@@ -47,6 +47,10 @@ Hand long-running work to the shared task runtime instead of inventing a task pr
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.
## Code Mode reaches your tool for free
Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), a registered tool is ALSO callable from a `run_code` program as `await tools.<name>(args)` — nothing to add. The generated SDK declares your parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`), each program call re-enters `execute()` through both waterfalls, and a failed call rejects the program-side promise with your error text. Two consequences worth designing for: your `description` and parameter `description`s become JSDoc a model reads while WRITING CODE, and non-text result blocks reach programs as placeholders (text is the lingua franca of the bridge).
## How your tool renders in an editor (ACP presentation)
Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input).
+4 -4
View File
@@ -323,7 +323,7 @@ A tool was registered or unregistered (the available tool set changed).
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:118`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:132`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
@@ -335,7 +335,7 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:111`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
@@ -347,7 +347,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:127`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
@@ -359,7 +359,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
Types: [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:77`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:91`](../../packages/core/tools/src/index.ts)
## Inherited events (cordis core + loader/hmr/timer)
+2 -2
View File
@@ -230,7 +230,7 @@ Source: [`packages/tasks/tasks/src/index.ts:95`](../../packages/tasks/tasks/src/
## `ctx.tools` — `ToolRegistry`
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. 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` → `tools/execute` → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also registers the `run_code` tool and the `tools:sdk` prompt section itself.
```ts cordis-catalog
register(definition: ToolDefinition): () => void
@@ -241,7 +241,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:307`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:345`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
+1 -1
View File
@@ -1,6 +1,6 @@
# Code Runtime
The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/proposed/feature/2026-06-15-code-mode.md).
The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md).
Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts)
+4 -4
View File
@@ -32,9 +32,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:118`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:77`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:132`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:127`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:91`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.
+7 -3
View File
@@ -130,7 +130,9 @@ flowchart TD
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_tools --> pkg_agent
pkg_tools --> pkg_code_runtime
pkg_tools --> pkg_llm
pkg_tools --> pkg_session
pkg_tools --> pkg_system_prompt
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
@@ -242,6 +244,7 @@ flowchart TD
pkg_acp_agent --> pkg_agent_core
pkg_acp_agent --> pkg_app_boot
pkg_acp_agent --> pkg_session_persistence_jsonl
pkg_acp_agent --> pkg_tools
pkg_acp_agent --> pkg_user_interaction
pkg_stdio_agent --> pkg_agent
pkg_stdio_agent --> pkg_agent_core
@@ -250,6 +253,7 @@ flowchart TD
pkg_stdio_agent --> pkg_session
pkg_stdio_agent --> pkg_session_persistence_jsonl
pkg_stdio_agent --> pkg_tool_ask_user
pkg_stdio_agent --> pkg_tools
pkg_stdio_agent --> pkg_user_interaction
```
@@ -281,7 +285,7 @@ flowchart TD
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
@@ -309,5 +313,5 @@ flowchart TD
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/ui/user-interaction) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+12
View File
@@ -209,6 +209,18 @@ Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction.
```ts persistence-catalog
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
```
Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/tools/src/code-mode.ts:36`](../packages/core/tools/src/code-mode.ts)
#### `tool/result` — surface
A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
+1 -1
View File
@@ -10,7 +10,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|---|---|
| [Agent Client Protocol (ACP) support — drive the coding agent from 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 |
| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-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 |
| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 |
@@ -49,6 +48,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| Title | First proposed |
|---|---|
| [Code Mode — the model writes TypeScript against the tool registry](implemented/feature/2026-06-15-code-mode.md) | 2026-06-15 |
| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 |
| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
@@ -1,6 +1,6 @@
# RFC: Code Mode — the model writes TypeScript against the tool registry
Status: proposed
Status: implemented
## Problem
@@ -12,7 +12,7 @@ Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alt
An earlier draft of this RFC designed Code Mode as an add-on consumer plugin with zero core changes, deferring the execution substrate to a follow-up. Both constraints are dropped here, deliberately. First, the harness is pre-release and optimizes for the correct foundation over blast radius: tool presentation is the registry's own concern, and bolting a second presentation onto it from outside means transforming the registry's contribution after the fact — a waterfall listener whose correctness depends on listener ordering, which fights the [reconstructable-requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) design instead of riding it (that refactor removed request mutation from `agent/request`, the seam the old draft relied on). Second, the substrate question is answerable now: a Node `worker_threads` runtime gives real containment — separate isolate, empty environment, heap caps, and a `terminate()` that reliably stops a hot synchronous loop — where the old draft's `node:vm` stub had none of those, and it fits the harness's existing trust model (§Trust posture) without a hardening follow-up.
## Proposal
## Decision
Three decisions, each elaborated in its own section below:
@@ -82,16 +82,25 @@ The worker runtime is **containment, not a security boundary**, and the RFC says
The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program is the body of an async TypeScript function (erasable syntax only — no `enum`/namespaces; type annotations are advisory); call tools as `await tools.name(args)` (quoted access for exotic names); a failed tool call **rejects** with an `Error` carrying the tool's error text — catch it to handle and continue; calls run **sequentially** even under `Promise.all`; emit results via `return` and/or `console.log`, and only that curated output returns to the context — intermediate tool results never do. That last line is the payoff the whole design serves: output-side context cost becomes the model's own editorial decision. On the input side the `.d.ts` is not free — for a large tool surface it can rival the native JSON schemas it replaces (and `'both'` pays for the two side by side) — but it is prefix-stable, so provider prefix caching amortizes it; the win is workload-dependent and the RFC claims no more.
## Plan
## Consequences
Four stacked PRs, each gates-green (`typecheck`, `lint`, `test:coverage`, `test:snapshot`, `doc-sync`, `verify-module-graph`, `build`, `hygiene`) with docs updated in the same change:
The design shipped as four stacked changes — this RFC, the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` integration — each gates-green with docs in the same change; review fixes landed on the change that introduced them and merged down.
1. **This RFC revision** (docs-only): the file rewritten as above (renamed `2026-06-15-code-mode.md`, same first-proposed date), regenerated RFC index.
2. **`dsh-code-runtime`** (interface package): the group `packages/code-runtime/`, abstract `CodeRuntime`, vocabulary types, ctx-key declaration; docs in the same change — group README + package README, the `packages/README.md` group table row, the `ctx.codeRuntime` row in [docs/architecture.md](../../../architecture.md)'s service map, and the regenerated cordis catalog (the new service class). Unit tier: HMR safety (dispose removes `ctx.codeRuntime`), contract docs. This package has no behavior to snapshot or e2e; its coverage story is unit-only by design.
3. **`dsh-code-runtime-worker`**: the implementation above, plus the regenerated config catalog (its `Config`). Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM), the two budgets from both sides (a hot loop with an un-awaited pending dispatch still dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`), binding bridge hostility cases (unknown name, duplicate id, post-settlement message, `__proto__`/`constructor`/`toString` binding names), structured-clone fallback, cap truncation, `env` emptiness verified from inside the program, dispose-awaits-exit. A real-load-path test runs the built package (`lib/`) so the worker entry resolves both unbuilt (tsx) and built — the published-bin guard from [docs/testing.md](../../../testing.md).
4. **Native code mode in `dsh-tools`** + the end-to-end surface: mode config, provider switch, `tools:sdk` section, `jsonSchemaToTs`, `run_code` + dispatch bridge + serialization queue, `tool/code-dispatch` event; regenerated tool, config, and persistence catalogs; [docs/architecture.md](../../../architecture.md) (tool-pipeline prose) and the [adding-a-tool cookbook](../../../cookbook/adding-a-tool.md) cross-reference updated in the same change; an `examples/` leaf + `demo:code` script wiring the worker runtime with `mode: 'code'`; move this RFC to `implemented/`. Coverage named per tier now, per the plan-time rule: **unit** — codegen table (DSL subset, quoted names, `unknown` degradation, determinism), provider contribution per mode, `toolOrder × mode` rejection, missing-runtime/wrong-language loud failures, serialization non-overlap (a probe tool records enter/exit under `Promise.all`), abort stops the queue, binding rejection on `isError`, `CodeRunFailedError` → structured `isError`, event payloads, `deriveMessages()` ignores the event, HMR safety (mode flip via config reload removes tool + section); **e2e (with-key, self-skips)** — a real model, `mode: 'code'`, a task requiring two tool calls and curation, asserting the wire tool list was exactly `run_code` and the transcript's dispatch events; **snapshot (keyless replay)** — goldens for a `run_code` turn in `'code'` and `'both'`, pinning the SDK section text, the collapsed header tools, dispatch events, and the result card.
What exists now:
The four PRs land in order (each on the previous); per stacked-review practice, review fixes land on the PR that introduced them and merge down.
- **The seam**: `packages/code-runtime/``@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog.
- **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog).
- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls.
- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on.
## Testing
What the suites pin, per tier:
- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md).
- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` unchanged, `'code'` exactly `[run_code]`, `'both'` all + `run_code`); `toolOrder × mode` rejection; missing-runtime / wrong-language loud failures; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety (disposing the registry removes the tool and the section).
- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output.
- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed.
## Alternatives considered
@@ -111,17 +120,6 @@ The four PRs land in order (each on the previous); per stacked-review practice,
**A REPL-style persistent kernel** (state survives across `run_code` calls). Rejected for the MVP: cross-call state would be invisible to the session log, breaking the reconstructability guarantee that every request is a pure function of the log; fresh-per-run keeps it. A kernel-style backend remains expressible behind the seam later, with its own logging story.
## Acceptance criteria
- `mode: 'native'` (and unset) is byte-for-byte today's behavior: same assemblies, same headers, same snapshots.
- Under `mode: 'code'`, the assembled tool list (and thus the logged `request/header`) is exactly `[run_code]`; under `'both'`, every native schema plus `run_code`; the `tools:sdk` section is present in both, absent under `'native'`, and its text is deterministic for a fixed tool set (byte-identical across consecutive assemblies).
- The generated `.d.ts` covers every registered tool except `run_code`, non-identifier names via quoted keys, unsupported schema constructs as `unknown`, without codegen ever throwing.
- A program calling two tools returns only its curated output; each sub-call appears as a `tool/code-dispatch` event ordered by log `seq`, flows through `tools/pre-execute`/`post-execute` (a deny reaches the program as a binding rejection), and never enters derived messages; a binding argument that does not survive JSON normalization (`BigInt`, a circular structure) rejects before dispatch — nothing executes unlogged.
- `Promise.all` over three bindings produces non-overlapping `ctx.tools.execute()` intervals (probe-tool assertion); aborting mid-program stops the worker and dispatches nothing further; a budget expiry during a slow sub-dispatch aborts that dispatch (the probe tool observes its signal fire), `run_code` returns only after the queue drains, and no `tool/code-dispatch` event lands after `run_code`'s own `tool/result` in the log.
- Worker runtime: a hot `for(;;){}` run ends at the `computeMs` busy-time budget with `error.kind: 'timeout'` — including when the program fired an un-awaited binding call first (the pending-RPC decoy); a program idling on a slow binding does not consume `computeMs` and is bounded only by `maxWallMs`; OOM under `resourceLimits` yields `kind: 'worker-exit'` with the host process healthy; `process.env` inside a program is empty; non-erasable syntax yields `kind: 'exception'` without a worker spawn; disposal awaits worker exit.
- Misconfiguration is loud before any model request: non-native mode with no `ctx.codeRuntime`, a runtime whose `language ≠ 'typescript'`, and `toolOrder` naming a non-contributed tool all reject the assembly with actionable messages.
- The demo runs against the real API via `demo:code`; the snapshot goldens replay keylessly; all repo gates pass on every PR of the stack.
## Risks
**The worker is not a hard security boundary.** Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same seams. Deployments needing more need a future `isolation: 'container'` backend — tracked as the seam's designed extension, not a TODO on this design.
+26
View File
@@ -16,6 +16,7 @@ This table connects model-visible tool names to the plugin package and service s
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
@@ -94,6 +95,31 @@ Source: [`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/
ask_user_question pauses the tool call until the active UI provider returns a human answer.
## `@deepseek-ai/dsh-tools`
### `run_code`
Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.
```json
{
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The program: the body of an async TypeScript function."
}
},
"required": [
"code"
]
}
```
Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts)
Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.
## `@deepseek-ai/dsh-tool-bash`
### `bash`
+2 -2
View File
@@ -15,7 +15,7 @@ flowchart TD
around["<code>tools/execute</code> waterfall<br/>timeout, retry, metrics (around dispatch)"]
toolBody["Registered tool execute() body"]
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>"]
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/code-dispatch</code>"]
post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"]
context["Buffered additionalContext<br/>context/message after all tool results"]
toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"]
@@ -37,6 +37,6 @@ flowchart TD
toolResult --> presentResult
```
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md — Examples
Runnable demos that show how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub with no build. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`.
Runnable demos showing how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub, never built. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`.
Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue (Loader tail, `.env` load, snapshot-mode selection, stdin-dispose lifecycle) lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`.
@@ -20,7 +20,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P
| Example | Keyless smoke | With-key smoke |
|---|---|---|
| `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) |
| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified |
| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit; `tests/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified |
| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject |
| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written |
+3 -1
View File
@@ -19,6 +19,8 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th
Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try.
## cordis-agent
The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on.
@@ -29,4 +31,4 @@ Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/R
An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests.
Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design.
Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design.
+2 -1
View File
@@ -4,9 +4,10 @@ The DeepSeek Harness SDK agent demo exposed as an **Agent Client Protocol (ACP)*
```sh
pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code
```
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC.
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:code-mode acp` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)).
## stdout is the protocol
@@ -0,0 +1,32 @@
# Both-mode REPLAY overlay: the same patched tree as both-mode.cordis.yml
# (registry in `mode: both` + the worker code runtime) with the keyless model
# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving
# the recorded fixture). Patches do not compose across nested includes —
# an outer include's patch can only target entries in the file IT loads — so
# this file patches ./cordis.yml directly with the union of both overlays.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: acp-agent
name: '@deepseek-ai/dsh-acp-agent'
config:
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
tools:
mode: both
persona: |
You are a coding assistant powered by the {{model}} model. Your working
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and
factual.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
+29
View File
@@ -0,0 +1,29 @@
# Both-mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two
# load-time patches — the app entry's config gains `tools: { mode: both }`
# (every native tool definition stays on the wire AND run_code + the generated
# TypeScript SDK prompt section ride along) and the worker-thread code runtime joins the
# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the
# snapshot harness records the both-mode scenario; DSH_SNAPSHOT=replay swaps
# it for the sibling both-mode.cordis.snapshot.yml. A config patch REPLACES
# the entry's whole config, so the base entry's fields are restated verbatim.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-agent'
config:
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
tools:
mode: both
persona: |
You are a coding assistant powered by the {{model}} model. Your working
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and
factual.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
@@ -0,0 +1,32 @@
# Code Mode REPLAY overlay: the same patched tree as code-mode.cordis.yml
# (registry in `mode: code` + the worker code runtime) with the keyless model
# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving
# the recorded fixture). Patches do not compose across nested includes —
# an outer include's patch can only target entries in the file IT loads — so
# this file patches ./cordis.yml directly with the union of both overlays.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: acp-agent
name: '@deepseek-ai/dsh-acp-agent'
config:
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
tools:
mode: code
persona: |
You are a coding assistant powered by the {{model}} model. Your working
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and
factual.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
+30
View File
@@ -0,0 +1,30 @@
# Code Mode overlay: the live acp-agent tree (./cordis.yml) with two
# load-time patches — the app entry's config gains `tools: { mode: code }`
# (the registry offers exactly one wire tool, run_code, plus the generated
# TypeScript SDK prompt section) and the worker-thread code runtime joins the
# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file for
# `pnpm run demo:code-mode acp` and when the snapshot harness records the
# code-mode scenarios; DSH_SNAPSHOT=replay swaps it for the sibling
# code-mode.cordis.snapshot.yml. A config patch REPLACES the entry's whole
# config, so the base entry's fields are restated verbatim.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-agent'
config:
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
tools:
mode: code
persona: |
You are a coding assistant powered by the {{model}} model. Your working
directory is {{cwd}}.
Verify your work by running the code or tests. Keep answers brief and
factual.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
+12
View File
@@ -21,6 +21,11 @@ const AGENT = {
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
}
// The Code Mode overlay configs (include-patched variants of cordis.yml; the
// replay swap resolves each one's sibling `*cordis.snapshot.yml`).
const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
const SCENARIOS: Scenario[] = [
{ name: 'handshake', hasModelTurn: false, recorded: false },
{ name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
@@ -89,6 +94,13 @@ const SCENARIOS: Scenario[] = [
{ name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
// Code Mode: the registry in `mode: code` — the wire tool list collapses to
// [run_code], the tools:sdk section rides in the prompt, and the program's
// tool calls land as tool/code-dispatch events. Each mode boots its own
// overlay config, composes a different header by construction, and
// therefore pins its own class.
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
{ name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG },
]
defineAcpSnapshotSuite({
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop." }
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"tools"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" B"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528","title":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eZXVwOupAyCOXGgrtxXw7528","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OTH"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"B"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OTH"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_OK"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop." }
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"bash"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Jo"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ins"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Returns"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","title":"const r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn r1.trim() + \"+\" + r2.trim();","kind":"execute","status":"in_progress","rawInput":"const r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO\" });\nreturn r1.trim() + \"+\" + r2.trim();"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Km1jhimv1ZVR4qbgjxAo9869","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"+"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_T"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WO"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+16 -1
View File
@@ -31,6 +31,21 @@ RESUME_SESSION_ID=<prior-session-id> pnpm run demo:repl
The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent.
## Code Mode
[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The model is then offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool; it composes them by writing a program, each program tool call bridges back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time and is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters its context. (Flip the mode to `both` to offer native calls AND `run_code` side by side.)
```sh
pnpm run demo:code-mode # this overlay under the REPL (default UI)
pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay
```
Try a task that spans several tool calls, e.g.:
> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt.
and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output.
## What each leaf entry demonstrates
This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app and the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle it loads; the leaf wires the backends and model-facing optional tools:
@@ -54,4 +69,4 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction.
- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event.
These self-skip without `DEEPSEEK_API_KEY`. The keyless boot smoke is `tests/keyless-smoke.e2e.ts` (boots the full real tree with a dummy key and no prompt, so no model call), which runs in the default e2e gate.
These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay).
@@ -0,0 +1,33 @@
# Code Mode overlay: the live coding-agent tree (./cordis.yml) with two
# load-time patches — the app entry's config gains `tools: { mode: code }`
# (the registry offers exactly one wire tool, run_code, plus the generated
# TypeScript SDK prompt section declaring bash/read/write/edit/subagent/
# todo_write) and the worker-thread code runtime joins the tree as
# `ctx.codeRuntime`. The dsh-stdio-agent bin boots this file for
# `pnpm run demo:code-mode` (the acp-agent example carries the same-shaped
# overlay for the `acp` UI). A config patch REPLACES the entry's whole
# config, so the base entry's fields are restated verbatim; only `tools`,
# the welcome, and the persona's second paragraph are Code Mode deltas.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-agent'
config:
model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
tools:
mode: code
welcome: 'code-mode agent ready. Give it a multi-tool task.'
persona: |
You are coding-agent, a coding assistant powered by the {{model}} model.
You work by writing TypeScript programs for run_code: batch related
tool work into one program, loop and branch where it helps, and print
or return ONLY the findings that matter.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
@@ -0,0 +1,91 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for the Code Mode overlay: boot the REAL
* example through the `@deepseek-ai/dsh-stdio-agent` bin against
* `code-mode.cordis.yml` (the cordis Loader, `unwrapExports`, the include
* patches over ./cordis.yml, the worker-thread code runtime, and the
* registry in `mode: code`), then close stdin with no prompt and assert
* the Code Mode banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called and no `run_code`
* turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot
* the tree. This is the export-shape guard (postmortem 0001) for the Code
* Mode composition; the with-key proof lives in `code-mode.e2e.ts`.
*/
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
afterEach(async () => {
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
child = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function bootAndEof(): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'code-mode-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`code-mode overlay did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 10_000)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`code-mode overlay exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// No prompt — just EOF, so the stdio UI exits without ever running a turn.
proc.stdin.end()
})
}
describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => {
it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout, code } = await bootAndEof()
expect(code).toBe(0)
expect(stdout).toContain('code-mode agent ready.')
}, 15_000)
})
@@ -0,0 +1,115 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
/**
* The Code Mode with-key proof (the RFC's e2e tier): a REAL model under
* `mode: 'code'`, a task that requires composing two tool calls, verified
* against the WORLD — the persisted request header carried exactly
* `[run_code]` as the wire tool list, each sub-call landed as a
* `tool/code-dispatch` event, the file the program wrote exists on disk, and
* the final answer is the program's curated output. Key-gated (see
* vitest.e2e.config.ts); the keyless Loader-path smoke of the overlay lives
* in `code-mode-keyless-smoke.e2e.ts`.
*/
const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: '
+ 'batch related tool work into one program and print or return ONLY the findings that matter.'
let ctx: Context | undefined
let workdir: string | undefined
afterEach(async () => {
// Always dispose, even on failure/retry/timeout: agent-loop teardown stops
// the loop, the executor kills stray processes, and the code runtime's
// dispose awaits worker exits.
await ctx?.fiber.dispose()
ctx = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function codeModeHarness(cwd: string): Promise<Context> {
const harness = new Context()
await harness.plugin(LlmService)
await harness.plugin(SessionStore)
await harness.plugin(SystemPrompt, { persona: PERSONA })
await harness.plugin(ToolRegistry, { mode: 'code' })
await harness.plugin(AgentRegistry)
await harness.plugin(AgentLoop, { agents: [] })
await harness.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await harness.plugin(ToolBash)
await harness.plugin(WorkerCodeRuntime, {})
return harness
}
function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = harness.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => {
it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-'))
ctx = await codeModeHarness(workdir)
const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { model: 'deepseek-v4-flash' })
agent.send([{
type: 'text',
text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
+ 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
+ 'and return only the joined string.',
}])
await waitForIdle(ctx, agent)
const events: SessionEvent[] = [...agent.session.events]
// The wire contract: every request this session made offered EXACTLY ONE
// tool — run_code (the logged header snapshots the assembled list).
const headers = events.filter(event => event.type === 'request/header')
expect(headers.length).toBeGreaterThan(0)
for (const header of headers) {
expect(header.data.header.tools?.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
}
// The model actually went through run_code…
const calls = events.filter(event => event.type === 'tool/call')
expect(calls.length).toBeGreaterThan(0)
expect(calls.every(event => event.data.name === RUN_CODE_NAME)).toBe(true)
// …and the program's tool calls landed as dispatch events under it.
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
expect(dispatches.length).toBeGreaterThanOrEqual(2)
expect(dispatches.every(event => event.data.name === 'bash')).toBe(true)
const parents = new Set(calls.map(event => event.data.callId))
expect(dispatches.every(event => parents.has(event.data.parentCallId))).toBe(true)
// World verification: the file the program wrote, and the curated answer.
const combined = await readFile(join(workdir, 'combined.txt'), 'utf8')
expect(combined).toContain('alpha-7')
expect(combined).toContain('beta-9')
const finalMessage = events.findLast(event => event.type === 'assistant/message')
const finalText = finalMessage !== undefined
? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
: ''
expect(finalText).toContain('alpha-7')
expect(finalText).toContain('beta-9')
}, 180_000)
})
+1
View File
@@ -65,6 +65,7 @@
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
"demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
"demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/cordis-agent/cordis.yml",
"demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml",
"postinstall": "node scripts/install-lefthook.mjs"
+1 -1
View File
@@ -1,6 +1,6 @@
# code-runtime/ — code-execution capability family
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's Code Mode, specified alongside the seam in the [Code Mode RFC](../../docs/rfc/proposed/feature/2026-06-15-code-mode.md). **Product** packages.
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-code-runtime-worker
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
## Config
+1 -1
View File
@@ -2,7 +2,7 @@
The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
## Service API (`ctx.codeRuntime`)
@@ -7,7 +7,7 @@
* substrate (worker thread, separate process, container) and by source
* language, both declared as readonly descriptors. The design and its
* consumer (the tool registry's Code Mode) are specified in the Code Mode RFC
* (docs/rfc/proposed/feature/2026-06-15-code-mode.md).
* (docs/rfc/implemented/feature/2026-06-15-code-mode.md).
*
* The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing
* about tools or sessions — it is handed named async functions and a program,
+12 -8
View File
@@ -49,7 +49,7 @@ import z from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
@@ -64,10 +64,12 @@ export const name = 'agent-core'
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order). Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic); the schema is
* the INTERSECTION of the owners' own schemas, so validation and defaulting
* can never drift from them.
* order), the `tools` object to the tool registry (its presentation `mode`).
* Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
* schema is the INTERSECTION of the owners' own schemas (the registry's
* nested under its `tools` key), so validation and defaulting can never
* drift from them.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -76,10 +78,12 @@ export interface Config {
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
}
/** Intersect the owners' schemas so validation + defaulting stay identical. */
export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z<Config>
/** Intersect the owners' schemas so validation + defaulting stay identical (the registry's nested under `tools`). */
export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config, z.object({ tools: ToolRegistry.Config })]) as unknown as z<Config>
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
@@ -104,7 +108,7 @@ export function apply(ctx: Context, config: Config): void {
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry)
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
+20 -1
View File
@@ -1,9 +1,18 @@
# dsh-tools
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) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context).
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) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
## Service: `ToolRegistry` (ctx key: `tools`)
### Config
```yaml
tools:
mode: native # native (default) | code | both
```
`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
@@ -119,6 +128,16 @@ const bash = defineTool({
})
```
### Code Mode
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
### What is NOT here (TODO)
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
+7
View File
@@ -23,13 +23,20 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
* async binding per registered tool, serializes every binding call through a
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
* program's curated output. The registry itself decides WHEN this tool
* exists (its `mode` config); this module owns only the tool and the bridge.
*
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
import { inspect } from 'node:util'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type {} from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { ToolDefinition, ToolRegistry } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
}
}
/** The model-facing name of the Code Mode tool. */
export const RUN_CODE_NAME = 'run_code'
/** The `tools:sdk` section order: inside the 100199 tool-guidance band, after per-tool guidance sections. */
export const SDK_SECTION_ORDER = 150
/**
* Thrown by `run_code` when the program run itself failed — a program
* exception, a budget expiry, an abort, or substrate death. Extends
* {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
* pipeline converts it into a structured `isError` result whose text carries
* the failure kind plus the captured logs, so the model can self-correct.
*/
export class CodeRunFailedError extends HarnessError {
constructor(message: string) {
super(message, 'CODE_RUN_FAILED')
this.name = 'CodeRunFailedError'
}
}
/**
* Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
* constant, not config: the full result already flows to the program; the
* summary exists so log readers see what a sub-call returned at a glance.
*/
const SUMMARY_MAX_CHARS = 200
/** Bounded inspect for rendering a program's completion value into the model-facing text. */
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */
function textOf(content: ContentBlock[]): string {
return content
.map((block) => {
switch (block.type) {
case 'text': return block.text
// ContentBlockMap is merge-extensible — future block kinds land here
// deliberately (no assertNever on merge-extensible unions).
default: return `[${block.type} content]`
}
})
.join('\n')
}
/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
function summarize(text: string): string {
return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}` : text
}
/**
* JSON-normalize one binding call's argument into TWO independent parses of
* the same canonical text: `dispatched` goes to the tool, `logged` to the
* `tool/code-dispatch` event — identical by construction (the runtime's
* structured-clone boundary is wider than JSON; the session log accepts only
* JSON), and separate objects, so a tool mutating its args can neither
* desync the log from what was dispatched nor re-poison the append. A value
* that does not survive the round-trip (`undefined` — the log rejects it as
* event data — `BigInt`, a circular structure, a bare function) rejects that
* one call BEFORE dispatch with a model-correctable error: nothing ever
* executes unlogged.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
if (value === undefined) {
throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
}
let text: string | undefined
try {
text = JSON.stringify(value)
} catch (error: unknown) {
throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`)
}
// JSON.stringify's lib type claims `string`, but a bare function or symbol
// root really yields `undefined` at runtime — the guard is live.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
}
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
function renderValue(value: unknown): string {
if (value === undefined) return ''
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
}
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta {
logs: CodeRunResult['logs']
dispatches: number
}
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
return m as unknown as RunCodeMeta
}
/**
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
* executed through the dispatch bridge described in the module doc. The
* registry registers it under non-native modes.
* @param registry - the owning registry (sub-calls go through its `execute`,
* bindings cover its registered tools).
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
* misconfiguration error (shared with the registry's assembly-time checks).
* @returns the registry-ready definition.
*/
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
return defineTool({
name: RUN_CODE_NAME,
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
+ 'Only what you print or return comes back — curate it.',
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
},
async execute(args, exec) {
const runtime = requireRuntime()
// The run-scoped abort: follows the outer signal in, and fires when the
// run settles for ANY reason, so an in-flight sub-dispatch is aborted
// (its executor kills on this signal) instead of orphaned, and
// queued-unstarted dispatches are abandoned.
const runController = new AbortController()
const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
if (exec.signal?.aborted) onOuterAbort()
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the
// tail, so even `Promise.all` executes the underlying tool calls one at
// a time in submission order (the tool contract carries no
// concurrency-safety metadata yet). The fold keeps the tail non-rejecting
// so one failed dispatch never poisons the chain.
let queue: Promise<void> = Promise.resolve()
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
const turn = queue.then(() => {
if (runController.signal.aborted) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
}
return task()
})
queue = turn.then(() => undefined, () => undefined)
return turn
}
// Read through a call, not a bare property: the abort state genuinely
// changes across awaits, and a direct `.aborted` re-check after one
// would be narrowed away by control flow analysis.
const runOver = (): boolean => runController.signal.aborted
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<unknown> => {
if (runOver()) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
}
const normalized = jsonNormalizeArgs(rawArgs)
const outcome = await enqueue(async () => {
const n = ++dispatches
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
const result = await registry.execute({
callId: subCallId,
name,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
signal: runController.signal,
})
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the
// loop's buffering (append after the step's tool/results) has no
// safe analogue from inside a running run_code — injecting now
// would break tool-call/result adjacency. Deferred until a real
// hook needs it through Code Mode.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
name,
// The SIBLING parse of the dispatched value: byte-identical JSON,
// but a separate object — a tool mutating its args cannot desync
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
resultSummary: summarize(text),
})
return { text, isError: result.isError }
})
// A budget expiry or outer cancel that lands while this call was in
// flight already aborted the dispatch; stop the program now rather
// than hand it a result from a run that is over.
if (runOver()) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
}
// A failed tool call REJECTS — real code signals failure by throwing,
// so try/catch and Promise.all short-circuiting behave as models
// expect (the error text is the tool's model-facing result text).
if (outcome.isError) throw new Error(outcome.text)
return outcome.text
}
// Null-prototype + defineProperty, mirroring the worker-side namespace
// build: a registered tool named `__proto__` must become an ordinary
// own key (a plain-object assignment would hit the prototype setter,
// silently dropping the binding), and the runtime host resolves
// binding names as own properties only.
const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
for (const schema of registry.schemas()) {
if (schema.name === RUN_CODE_NAME) continue
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
}
try {
let result: CodeRunResult
try {
result = await runtime.run({
program: args.code,
bindings: [{ global: 'tools', functions }],
signal: runController.signal,
})
} finally {
// Quiescence before returning, whether the runtime fulfilled or
// REJECTED (a backend that starts a binding call and then throws
// must not leak a live sub-dispatch past this settlement): fire
// the run-scoped abort (cancelling an in-flight sub-dispatch,
// abandoning queued ones), then await the queue's drain — an
// aborted sub-call still settles and logs its event INSIDE the
// open turn; nothing can append after we return. `queue` is the
// FOLDED tail (every link swallows its rejection into undefined),
// so this await cannot itself reject — an abandoned queued call
// can never mask the runtime's own failure, returned or thrown;
// rejections surface only on the per-call promises the program
// holds.
runController.abort('run_code settled')
await queue
}
if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches }
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)
}
},
// The program IS the title, the way command tools title their cards with
// the command: an execute-card's title is the one slot an ACP client
// always shows (Zed's execute cards render no body content and no raw
// input without a real terminal attached), so anywhere else the code
// would be invisible. Multi-line titles are the execute-card idiom —
// capable clients render them whole; others truncate to the first line
// and still hold the full program in rawInput.
presentCall: args => ({
card: 'generic',
title: args.code,
kind: 'execute',
rawInput: args.code,
}),
// Title omitted on the result: an update replaces only the fields it
// carries, so the pending card's program title persists through
// completion; the captured output rides as body content.
presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n')
return {
card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
}
},
})
}
+98 -4
View File
@@ -6,15 +6,26 @@
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
*
* The registry also owns HOW its tools are presented to the model — its
* `mode` config: `'native'` (every tool as a wire function definition,
* today's behavior and the default), `'code'` (the wire carries exactly one
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
*
* @module @deepseek-ai/dsh-tools
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { ToolCallView, ToolResultView } from './presentation.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
export {
defineTool,
@@ -39,6 +50,9 @@ export {
type StructuredScalar,
} from './json-schema.ts'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
@@ -298,20 +312,100 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
}
/** How the registry presents its tools to the model (see {@link Config.mode}). */
export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* registered tool as a wire function definition — byte-for-byte today's
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
* the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
*/
mode?: ToolPresentationMode
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
* system-prompt assembly — WHICH schemas is governed by its `mode` config
* (see {@link Config.mode}); under a non-native mode it also registers the
* `run_code` tool and the `tools:sdk` prompt section itself.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
private store = new Map<string, ToolDefinition>()
static Config: z<Config> = z.object({
mode: z.union(['native', 'code', 'both'] as const).default('native'),
})
constructor(ctx: Context) {
private store = new Map<string, ToolDefinition>()
private readonly mode: ToolPresentationMode
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'tools')
ctx.systemPrompt.tools(() => this.schemas())
// The schema already defaulted an omitted mode; the ?? narrows the
// optional-input type for direct (non-Loader) construction in tests.
this.mode = config.mode ?? 'native'
ctx.systemPrompt.tools(() => this.wireSchemas())
if (this.mode !== 'native') {
this.register(createRunCodeTool(this, () => this.requireCodeRuntime()))
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// A lazy thunk over the live store: regenerated at each assembly, in
// lexicographic tool order, so an unchanged tool set renders
// byte-identical text (prefix-cache-friendly) and a mid-session
// registration surfaces exactly like a native-mode tool change.
text: () => {
this.requireCodeRuntime()
return renderToolsSdk(this.schemas().filter(schema => schema.name !== RUN_CODE_NAME))
},
})
}
}
/**
* The registry's contribution to the wire tool list, per {@link Config.mode}.
* Because `PromptAssembly.tools` is what the loop's request header
* snapshots, the mode's collapse is logged and reconstructable for free.
* Under a non-native mode this is also the loud misconfiguration gate: no
* usable code runtime → every assembly rejects before any model request.
*/
private wireSchemas(): ToolSchema[] {
if (this.mode === 'native') return this.schemas()
this.requireCodeRuntime()
const all = this.schemas()
return this.mode === 'code' ? all.filter(schema => schema.name === RUN_CODE_NAME) : all
}
/**
* Resolve the code runtime or throw the actionable misconfiguration error.
* Read at use time (assembly / run_code execution), NOT via static
* `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
* behind it — hostage to a code runtime existing even under `mode:
* 'native'` (the loop's optional-backend idiom, same as
* `sessionPersistence`).
*/
private requireCodeRuntime(): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (runtime.language !== 'typescript') {
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
}
return runtime
}
/**
+121
View File
@@ -0,0 +1,121 @@
/**
* Code Mode codegen: the pure projection from registered tool schemas to the
* TypeScript SDK text the model programs against (the `tools:sdk` prompt
* section). Sibling of `json-schema.ts` — `schemas()` (native function
* calling) and this module (the generated `declare const tools` surface) are
* two projections of the same store.
*
* TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the
* `defineTool` DSL emits and degrades every construct outside it (`$ref`,
* `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever
* throwing — codegen must never be the thing that fails an assembly.
* Deterministic: a fixed tool set renders byte-identical text (tools in
* lexicographic name order), so the section is prefix-cache-friendly.
*
* @module @deepseek-ai/dsh-tools/src/ts-types
*/
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
/** Property names that are valid bare TS identifiers; anything else is quoted. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Render an object key: bare when it is a valid identifier, quoted otherwise (every name stays reachable, no aliasing). */
function renderKey(name: string): string {
return IDENTIFIER.test(name) ? name : JSON.stringify(name)
}
/** One `indent`-deep line prefix (two spaces per level). */
function pad(indent: number): string {
return ' '.repeat(indent)
}
/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */
function docLines(description: unknown, indent: number): string[] {
if (typeof description !== 'string' || description.length === 0) return []
// Keep the doc a single-line comment per property: descriptions are prose
// (possibly with newlines); collapse whitespace so the rendered SDK stays
// stable and compact. A comment-closer inside the description is escaped so
// it cannot terminate the generated JSDoc early.
const collapsed = description.replace(/\s+/g, ' ').trim()
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
}
/**
* Map one JSON-Schema node to a TypeScript type literal. Handles exactly the
* subset the `defineTool` DSL emits — `object` (`properties` + `required`),
* `string` (with `enum` → a literal union), `number`, `boolean`, `array`
* (`items`) — and returns `unknown` for anything else, without throwing.
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
* @param indent - the indentation level for nested object members.
* @returns the TS type text (multi-line for objects with properties).
*/
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
if (typeof schema !== 'object' || schema === null) return 'unknown'
const node = schema as Record<string, unknown>
switch (node.type) {
case 'string': {
if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) {
return node.enum.map(value => JSON.stringify(value)).join(' | ')
}
return 'string'
}
case 'number': return 'number'
case 'boolean': return 'boolean'
case 'array': {
const item = jsonSchemaToTs(node.items, indent)
// Parenthesize a union item type so `('a' | 'b')[]` parses as intended.
return item.includes('|') ? `(${item})[]` : `${item}[]`
}
case 'object': {
const properties = node.properties
if (typeof properties !== 'object' || properties === null) return 'Record<string, unknown>'
const entries = Object.entries(properties as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, unknown>'
const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : [])
const lines: string[] = ['{']
for (const [name, prop] of entries) {
const description = typeof prop === 'object' && prop !== null ? (prop as Record<string, unknown>).description : undefined
lines.push(...docLines(description, indent + 1))
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
}
lines.push(`${pad(indent)}}`)
return lines.join('\n')
}
default: return 'unknown'
}
}
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode RFC's "What the model sees"). */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue.
- Calls execute sequentially, even under \`Promise.all\`.
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:`
/**
* Render the full `tools:sdk` prompt section: the fixed usage instructions
* plus one `declare const tools` interface covering every given tool.
* Deterministic — tools are emitted in lexicographic name order, so an
* unchanged tool set produces byte-identical text across assemblies.
* @param schemas - the tool schemas to declare (the caller excludes
* `run_code` itself).
* @returns the complete section text.
*/
export function renderToolsSdk(schemas: ToolSchema[]): string {
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
const members: string[] = []
for (const schema of sorted) {
members.push(...docLines(schema.description, 1))
members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise<string>;`)
}
const declaration = members.length > 0
? `declare const tools: {\n${members.join('\n')}\n}`
: 'declare const tools: {}'
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\``
}
+640
View File
@@ -0,0 +1,640 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
/**
* Code Mode unit tier (per the RFC's plan): provider contribution per mode,
* misconfiguration rejections, the run_code dispatch bridge (serialization,
* abort, JSON normalization, error mapping, events, quiescence), and HMR
* safety — all against an in-repo fake runtime, exactly the
* interface/implementation/consumer shape the seam promises.
*/
/** A scriptable in-repo CodeRuntime: each test sets `behavior` to drive the bindings however it needs. */
class FakeRuntime extends CodeRuntime {
readonly language: string
readonly isolation = 'fake'
behavior: (request: CodeRunRequest) => Promise<CodeRunResult> = () => Promise.resolve({ logs: [] })
lastRequest?: CodeRunRequest
constructor(ctx: Context, config: { language?: string } = {}) {
super(ctx)
this.language = config.language ?? 'typescript'
}
run(request: CodeRunRequest): Promise<CodeRunResult> {
this.lastRequest = request
return this.behavior(request)
}
}
interface SetupOptions {
mode?: Config['mode']
runtime?: false | { language?: string }
toolOrder?: string[]
}
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} })
await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code' })
let runtime: FakeRuntime | undefined
if (options.runtime !== false) {
await ctx.plugin(FakeRuntime, options.runtime ?? {})
runtime = ctx.codeRuntime as FakeRuntime
}
return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! }
}
/** Register a trivial echo tool; returns the calls it received. */
function registerEcho(ctx: Context, name = 'echo'): unknown[] {
const calls: unknown[] = []
ctx.tools.register(defineTool({
name,
description: `Echo tool ${name}.`,
parameters: { value: { type: 'string', required: true } },
execute(args) {
calls.push(args)
return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }])
},
}))
return calls
}
/** A structural fake of the owning agent: captures session appends. */
function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } {
const events: { type: string; data: unknown }[] = []
const agent = {
session: {
append: (type: string, data: unknown) => { events.push({ type, data }) },
},
} as unknown as Agent
return { agent, events }
}
/** Dispatch run_code through the registry pipeline, as the loop would. */
async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
callId: CallId('call-1'),
name: RUN_CODE_NAME,
arguments: { code },
...extras.agent ? { agent: extras.agent } : {},
...extras.signal ? { signal: extras.signal } : {},
})
}
describe('mode-aware wire contribution', () => {
it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it("mode 'code' contributes exactly [run_code] plus the SDK section declaring the other tools", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
expect(sdk?.text).toContain('declare const tools: {')
expect(sdk?.text).toContain('echo(args:')
expect(sdk?.text).not.toContain('run_code(args:')
})
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
})
it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => {
const { ctx, runtime } = await setup({ mode: 'both' })
registerEcho(ctx)
runtime.behavior = (request) => {
const functions = request.bindings[0]!.functions
return Promise.resolve({
logs: [],
value: JSON.stringify({
names: Object.keys(functions).sort(),
// Own-property AND prototype-chain reads both come back empty —
// there is no handle a program could re-enter run_code through.
runCode: String(functions[RUN_CODE_NAME]),
}),
})
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(JSON.parse((result.content[0] as { text: string }).text)).toEqual({ names: ['echo'], runCode: 'undefined' })
})
it('renders byte-identical SDK text across consecutive assemblies of an unchanged tool set', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
registerEcho(ctx)
const first = await systemPrompt.assemble()
const second = await systemPrompt.assemble()
const text = (assembly: typeof first) => assembly.sections.find(section => section.name === 'tools:sdk')?.text
expect(text(first)).toBe(text(second))
})
it('rejects every assembly when a non-native mode has no code runtime', async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: false })
await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/)
})
it("rejects every assembly when the runtime's language is not typescript", async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/)
})
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', toolOrder: ['echo', '<unlisted-tools>'] })
registerEcho(ctx)
await expect(systemPrompt.assemble()).rejects.toThrow(/toolOrder lists unregistered tool "echo"/)
})
it('removes run_code and the SDK section when the registry fiber disposes (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(FakeRuntime, {})
const fiber = await ctx.plugin(ToolRegistry, { mode: 'code' })
expect(ctx.tools.get(RUN_CODE_NAME)).toBeDefined()
await fiber.dispose()
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools).toEqual([])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
})
describe('the run_code dispatch bridge', () => {
it('bridges tool calls, returns only the curated output, and logs one event per dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const first = await tools.echo!({ value: 'one' })
const second = await tools.echo!({ value: 'two' })
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second }
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }])
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
expect(dispatches.map(event => event.data)).toEqual([
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
])
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
})
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const intervals: [string, string][] = []
let active = 0
ctx.tools.register(defineTool({
name: 'probe',
description: 'Records execution overlap.',
parameters: { id: { type: 'string', required: true } },
async execute(args) {
active++
expect(active, 'probe executions overlapped').toBe(1)
intervals.push(['enter', args.id])
await new Promise(resolve => setTimeout(resolve, 20))
intervals.push(['exit', args.id])
active--
return [{ type: 'text' as const, text: args.id }]
},
}))
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })])
return { logs: [], value: values.join(',') }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(intervals).toEqual([
['enter', 'a'], ['exit', 'a'],
['enter', 'b'], ['exit', 'b'],
['enter', 'c'], ['exit', 'c'],
])
expect(result.content[0]).toEqual({ type: 'text', text: 'a,b,c' })
})
it('rejects the program-side call when the tool errors, with the tool error text', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
name: 'fail',
description: 'Always fails.',
parameters: {},
execute(): Promise<never> { return Promise.reject(new Error('deliberate failure')) },
}))
runtime.behavior = async (request) => {
try {
await request.bindings[0]!.functions.fail!({})
return { logs: [], value: 'unreachable' }
} catch (error: unknown) {
return { logs: [], value: `caught: ${error instanceof Error ? error.message : String(error)}` }
}
}
const result = await runCode(ctx, 'program')
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' })
})
it('a tools/pre-execute deny reaches the program as a binding rejection', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'echo') return Promise.resolve({ kind: 'deny' as const, reason: 'not on my watch' })
return next()
})
runtime.behavior = async (request) => {
try {
await request.bindings[0]!.functions.echo!({ value: 'x' })
return { logs: [], value: 'unreachable' }
} catch (error: unknown) {
return { logs: [], value: `denied: ${error instanceof Error ? error.message : String(error)}` }
}
}
const result = await runCode(ctx, 'program')
expect(result.content[0]?.type).toBe('text')
expect((result.content[0] as { text: string }).text).toContain('not on my watch')
})
it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
try {
await request.bindings[0]!.functions.echo!({ value: 'x', big: 1n })
return { logs: [], value: 'unreachable' }
} catch (error: unknown) {
return { logs: [], value: error instanceof Error ? error.message : String(error) }
}
}
const result = await runCode(ctx, 'program', { agent })
expect((result.content[0] as { text: string }).text).toContain('JSON-serializable')
expect(calls).toEqual([])
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
// A Date survives structured clone but is not JSON; the bridge
// normalizes it to its JSON form (an ISO string) BEFORE dispatch.
await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined)
return { logs: [] }
}
await runCode(ctx, 'program', { agent })
expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }])
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
})
it('suppresses sub-call additionalContext (deliberately; pinned)', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name === 'echo') {
return Promise.resolve({
kind: 'accept' as const,
additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } },
})
}
return next()
})
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'x' })
return { logs: [], value: 'done' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
// The sub-call's context has no safe outlet mid-run; the parent result
// must not carry it either.
expect(result.additionalContext).toBeUndefined()
})
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({
logs: [{ source: 'console', level: 'log', text: 'got this far' }],
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
})
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' })
const text = (result.content[0] as { text: string }).text
expect(text).toContain('code run failed (timeout)')
expect(text).toContain('compute budget exhausted')
expect(text).toContain('got this far')
})
it('CodeRunFailedError is a HarnessError with the CODE_RUN_FAILED code', () => {
const error = new CodeRunFailedError('boom')
expect(error.code).toBe('CODE_RUN_FAILED')
expect(error.name).toBe('CodeRunFailedError')
})
it('aborting the outer signal aborts the in-flight sub-dispatch and abandons queued ones', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const seen: string[] = []
let sawAbort = false
ctx.tools.register(defineTool({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
async execute(args, exec) {
seen.push(args.id)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
}))
const controller = new AbortController()
runtime.behavior = async (request) => {
const tools = request.bindings[0]!.functions
const calls = [tools.slow!({ id: 'first' }).catch(() => 'rejected'), tools.slow!({ id: 'second' }).catch(() => 'rejected')]
setTimeout(() => { controller.abort('user-cancel') }, 50)
await Promise.all(calls)
// A real runtime would be terminated by the abort; the fake honors the
// contract by reporting the abort as the run failure.
return { logs: [], error: { kind: 'abort', message: 'user-cancel' } }
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(seen).toEqual(['first'])
expect(sawAbort).toBe(true)
})
it('a runtime that starts a binding call and then REJECTS still reaches quiescence before returning', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
let sawAbort = false
let started!: () => void
const inFlight = new Promise<void>((resolve) => { started = resolve })
ctx.tools.register(defineTool({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
async execute(args, exec) {
started()
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
}))
runtime.behavior = async (request) => {
// Start a sub-dispatch, keep its rejection held, and fail the run once
// the tool is genuinely in flight — a seam error AFTER work has begun.
// The bridge's settlement still owes quiescence: without the finally,
// run_code would return now and the slow tool would finish (and log)
// afterwards.
request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held')
await inFlight
throw new Error('backend exploded')
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('backend exploded')
// Quiescence held: the in-flight sub-dispatch was aborted and its event
// logged INSIDE the run_code execution, not after it returned.
expect(sawAbort).toBe(true)
expect(events.filter(event => event.type === 'tool/code-dispatch').map(event => (event.data as { name: string }).name)).toEqual(['slow'])
})
it('runs without an owning agent: dispatches work, event logging is skipped', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'x' })
return { logs: [], value: 'ok' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(calls).toEqual([{ value: 'x' }])
})
it('executing run_code under a missing runtime is a structured isError, not a crash', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRegistry, { mode: 'code' })
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('requires a code runtime')
})
it('presents the PROGRAM as the execute-card title on both call and result (the one slot execute cards always show)', async () => {
const { ctx } = await setup({ mode: 'code' })
const tool = ctx.tools.get(RUN_CODE_NAME)!
// The program IS the title, mirroring how command tools title their cards
// with the command: an ACP client's execute-card header is the only
// always-visible slot (Zed renders no body content and no raw input for
// execute-kind cards without a real terminal).
expect(tool.presentCall?.({ code: 'return 1' })).toEqual({
card: 'generic',
title: 'return 1',
kind: 'execute',
rawInput: 'return 1',
})
const view = tool.presentResult?.({ code: 'return 1' }, {
content: [{ type: 'text', text: 'model-facing' }],
isError: false,
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 },
})
// The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion.
expect(view).toEqual({
card: 'generic',
content: [{ type: 'text', text: 'printed' }],
})
// No captured output → no content either; everything pending persists.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } }))
.toEqual({ card: 'generic' })
// Replay with an unrecognizable meta falls back to the generic rendering.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
})
it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
const long = 'x'.repeat(300)
ctx.tools.register(defineTool({
name: 'mixed',
description: 'Returns mixed content.',
parameters: {},
execute() {
return Promise.resolve([
{ type: 'text' as const, text: long },
{ type: 'reasoning' as const, text: 'hidden' },
])
},
}))
runtime.behavior = async (request) => {
const value = await request.bindings[0]!.functions.mixed!({})
return { logs: [], value }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`)
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.resultSummary.length).toBe(201)
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
})
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const echo = request.bindings[0]!.functions.echo!
const catchMessage = (promise: Promise<unknown>) => promise.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error))
return {
logs: [],
value: [
// Root undefined must reject up front: the event log rejects it as
// data, and nothing may execute unlogged.
await catchMessage(echo(undefined)),
// A toJSON that throws a NON-Error propagates out of JSON.stringify.
await catchMessage(echo({ toJSON() { throw 'raw-throw' } })),
// A bare function is a value JSON cannot represent at all.
await catchMessage(echo(() => 1)),
].join(' | '),
}
}
const result = await runCode(ctx, 'program', { agent })
const text = (result.content[0] as { text: string }).text
expect(text).toContain('call the tool with an arguments object')
expect(text).toContain('JSON-serializable: raw-throw')
expect(text).toContain('a value JSON cannot represent')
// None of the three dispatched, none logged.
expect(calls).toEqual([])
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
ctx.tools.register(defineTool({
name: 'mutator',
description: 'Mutates its own args object.',
parameters: { list: { type: 'array', required: true } },
execute(args) {
args.list.push('injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
},
}))
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.mutator!({ list: ['original'] })
return { logs: [] }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.arguments).toEqual({ list: ['original'] })
})
it('exposes a tool named __proto__ as an ordinary own binding', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
name: '__proto__',
description: 'A prototype-colliding tool name.',
parameters: {},
execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) },
}))
runtime.behavior = async (request) => {
const functions = request.bindings[0]!.functions
expect(Object.getPrototypeOf(functions)).toBeNull()
const value = await functions['__proto__']!({})
return { logs: [], value }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' })
})
it('renders a non-string completion value inspect-style', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } })
const result = await runCode(ctx, 'program')
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
})
it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
runtime.behavior = (request) => {
// The fake honors the seam contract for an already-aborted signal.
if (request.signal?.aborted) return Promise.resolve({ logs: [], error: { kind: 'abort' as const, message: String(request.signal.reason) } })
return Promise.resolve({ logs: [], value: 'unreachable' })
}
const controller = new AbortController()
controller.abort('too-late')
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(calls).toEqual([])
})
it('rejects a binding invoked after the run is over without dispatching it', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const controller = new AbortController()
runtime.behavior = async (request) => {
controller.abort('cancelled-mid-run')
const message = await request.bindings[0]!.functions.echo!({ value: 'x' })
.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error))
return { logs: [], value: message }
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text).toContain('not dispatched')
expect(calls).toEqual([])
})
it('a tool/code-dispatch event never derives a model message', () => {
const session = new Session(SessionId('code-mode-derive'))
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('tool/code-dispatch', {
parentCallId: CallId('p1'),
subCallId: CallId('p1:code:1'),
name: 'echo',
arguments: { value: 'x' },
isError: false,
resultSummary: 'echo:x',
})
const derived = session.deriveMessages()
expect(derived).toHaveLength(1)
expect(derived[0]?.role).toBe('user')
})
it('defaults to native mode under direct construction with no config', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRegistry(ctx)
expect(registry.get(RUN_CODE_NAME)).toBeUndefined()
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
})
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
describe('jsonSchemaToTs', () => {
it('maps the defineTool DSL subset', () => {
const cases: [unknown, string][] = [
[{ type: 'string' }, 'string'],
[{ type: 'number' }, 'number'],
[{ type: 'boolean' }, 'boolean'],
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
[{ type: 'array', items: { type: 'number' } }, 'number[]'],
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
[{ type: 'array' }, 'unknown[]'],
[{ type: 'object' }, 'Record<string, unknown>'],
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
]
for (const [schema, expected] of cases) {
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
}
})
it('renders objects with required/optional keys, nested shapes, and per-property docs', () => {
const schema = schemaSpecToJsonSchema({
path: { type: 'string', required: true, description: 'Absolute file path' },
limit: { type: 'number' },
opts: {
type: 'object',
properties: { deep: { type: 'boolean', required: true } },
},
})
expect(jsonSchemaToTs(schema)).toBe([
'{',
' /** Absolute file path */',
' path: string;',
' limit?: number;',
' opts?: {',
' deep: boolean;',
' };',
'}',
].join('\n'))
})
it('is total: unsupported or hostile constructs degrade to unknown, never throw', () => {
const cases: unknown[] = [
undefined,
null,
42,
'string-schema',
{},
{ type: 'integer' },
{ type: 'null' },
{ oneOf: [{ type: 'string' }] },
{ $ref: '#/defs/x' },
{ type: 'object', properties: 7 },
{ type: 'object', properties: { bad: { $ref: 'x' } } },
{ type: 'string', enum: [1, 2] },
{ type: 'string', enum: [] },
]
for (const schema of cases) {
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
}
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
// A non-string-only enum degrades to plain string; an empty one too.
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
// A hostile required list only accepts string members.
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;')
// A property VALUE that is not an object degrades to unknown (and can
// carry no description).
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;')
})
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
const rendered = jsonSchemaToTs({
type: 'object',
properties: { glob: { type: 'string', description: 'a pattern like packages/*/tool-*/ over here' } },
})
expect(rendered).not.toContain('tool-*/ over')
expect(rendered).toContain(String.raw`tool-*\/ over`)
})
})
describe('renderToolsSdk', () => {
const bash: ToolSchema = {
name: 'bash',
description: 'Run a shell command.',
parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
}
const exotic: ToolSchema = {
name: 'my-mcp.tool',
description: 'Exotic name.',
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
}
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
const text = renderToolsSdk([exotic, bash])
expect(text).toContain('declare const tools: {')
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
expect(text).toContain('"my-mcp.tool"(args:')
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
expect(text).toContain('): Promise<string>;')
expect(text).toContain('/** Run a shell command. */')
// The fixed instruction lines the model relies on.
expect(text).toContain('erasable syntax only')
expect(text).toContain('rejects with an `Error`')
expect(text).toContain('sequentially, even under `Promise.all`')
expect(text).toContain('JSON-serializable')
})
it('is deterministic: same tool set, byte-identical text regardless of input order', () => {
expect(renderToolsSdk([bash, exotic])).toBe(renderToolsSdk([exotic, bash]))
// Equal names sort stably (the comparator's equal arm).
expect(renderToolsSdk([bash, bash])).toBe(renderToolsSdk([bash, bash]))
})
it('renders an empty declaration for an empty tool set', () => {
expect(renderToolsSdk([])).toContain('declare const tools: {}')
})
})
+6
View File
@@ -8,6 +8,12 @@
"src"
],
"references": [
{
"path": "../../core/session"
},
{
"path": "../../code-runtime/code-runtime"
},
{
"path": "../../../vendor/cosmokit"
},
+1 -1
View File
@@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny``PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny``PreToolDecision.deny`; `ask``PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering |
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
| `SubagentStop` | `subagent/end` (emit) | observe-only |
+1 -1
View File
@@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block``PreToolDecision.deny` (no `allow`/`ask`) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
+4 -2
View File
@@ -6,7 +6,7 @@ Three layers, importable separately:
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo).
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), and the composable `scrubRequestHeaders` (header bulk → `{{system}}`/`{{tools}}`, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record-mode fixture write-back, the per-suite header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin, non-pinning fixtures header-scrubbed). Must be called at vitest collection time.
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record-mode fixture write-back, the per-header-class pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, pinning fixtures well-formed, non-pinning fixtures header-scrubbed). Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
@@ -26,11 +26,13 @@ defineAcpSnapshotSuite({
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
},
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
scenarios: SCENARIOS, // exactly one entry sets pinsHeader
scenarios: SCENARIOS, // exactly one entry per header class sets pinsHeader
mode: process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay',
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug).
+10 -1
View File
@@ -166,6 +166,15 @@ export interface RunOptions {
* start from an empty workspace.
*/
workspaceDir?: string
/**
* Alternate LIVE config path for the boot (absolute), overriding
* {@link AgentUnderTest.configPath} for this run. A scenario needing a
* differently-composed tree (the Code Mode scenarios) ships an overlay
* whose basename still ends in `cordis.yml`, so the bin's replay swap
* resolves the sibling `*cordis.snapshot.yml` the same way it does for
* the default.
*/
configPath?: string
}
/**
@@ -209,7 +218,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
child = spawn(
process.execPath,
['--import', tsxLoader, opts.agent.binScript, opts.agent.configPath],
['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath],
{ cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
)
+105 -39
View File
@@ -11,13 +11,14 @@
* before comparing).
*
* Request-header content (the composed system prompt + tool schemas riding on
* `request/header` events) is pinned by exactly ONE scenario per suite — the
* one with `pinsHeader` — and scrubbed to `{{system}}`/`{{tools}}` tokens in
* every other fixture and compare, so a prompt or tool-schema edit churns one
* committed line instead of every fixture. A per-run uniformity guard keeps
* the single pin sound: every live header must equal the pinned one, and no
* header-delta may appear outside the pinning scenario (see the
* pinned-header RFC,
* `request/header` events) is pinned by exactly ONE scenario per HEADER CLASS
* — scenarios that boot the same config compose the same header; each class's
* `pinsHeader` scenario commits it verbatim — and scrubbed to
* `{{system}}`/`{{tools}}` tokens in every other fixture and compare, so a
* prompt or tool-schema edit churns one committed line per class instead of
* every fixture. A per-run uniformity guard keeps each pin sound: every live
* header must equal its class's pinned one, and no header-delta may appear
* outside a pinning scenario (see the pinned-header RFC,
* docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md).
*
* `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the
@@ -80,18 +81,38 @@ export interface Scenario {
* Whether THIS scenario's fixtures keep the full request-header content (the
* composed system prompt and tool schema list on `request/header` /
* `request/header-delta` events) and compare it verbatim. Exactly one
* scenario per suite pins it; every other scenario stores and compares that
* content as `{{system}}`/`{{tools}}` tokens ({@link scrubRequestHeaders}),
* scenario per HEADER CLASS ({@link headerClass}) pins it; every other
* scenario of that class stores and compares that content as
* `{{system}}`/`{{tools}}` tokens ({@link scrubRequestHeaders}),
* so a system prompt or tool-schema change shows up as ONE committed-fixture
* diff, not one per scenario. One pin suffices because header composition is
* suite-uniform (parent, spawn child, and fork child all compose the same
* prompt-modulo-cwd and the same tools) — and that premise is ASSERTED, not
* assumed: every non-pinning run's live headers must equal the pinned
* fixture's (normalized), so a session-dependent header (say, a restricted
* subagent toolset) fails loud until it gets its own pinning scenario.
* diff per class, not one per scenario. One pin per class suffices because
* header composition is class-uniform (parent, spawn child, and fork child
* all compose the same prompt-modulo-cwd and the same tools) — and that
* premise is ASSERTED, not assumed: every non-pinning run's live headers
* must equal its class's pinned fixture's (normalized), so a
* session-dependent header (say, a restricted subagent toolset) fails loud
* until it gets its own pinning scenario.
* Defaults to false.
*/
pinsHeader?: boolean
/**
* Which header-composition class this scenario belongs to. Scenarios that
* boot the same config compose the same header; each class has exactly one
* {@link pinsHeader} scenario, and the uniformity guard compares every
* other member against ITS class's pin. Defaults to `'default'`; a
* scenario booting an alternate config ({@link configPath}) whose tool
* list or prompt sections differ by construction carries its own class.
*/
headerClass?: string
/**
* Alternate LIVE config path (absolute) this scenario boots instead of
* {@link AgentUnderTest.configPath} — an overlay composing a different
* tree (its basename must still end in `cordis.yml` so the bin's replay
* swap finds the sibling `*cordis.snapshot.yml`). A scenario whose
* overlay changes the composed header also needs its own
* {@link headerClass}.
*/
configPath?: string
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
@@ -183,10 +204,11 @@ export function headerDeltaCount(rawLog: string): number {
/**
* Register the suite: one `describe` per scenario (the golden/log compares and
* the header-uniformity guard) plus the fixture guard block (no orphan
* scenario dirs, required files present, exactly one pin, non-pinning fixtures
* header-scrubbed). Must run at vitest collection time — it calls
* `describe`/`it`. Throws immediately if no scenario pins the header (the
* uniformity guard would have nothing to compare against).
* scenario dirs, required files present, exactly one pin per header class,
* pinning fixtures well-formed, non-pinning fixtures header-scrubbed). Must
* run at vitest collection time — it calls `describe`/`it`. Throws
* immediately if any header class lacks a pinning scenario or carries two
* (the uniformity guard needs exactly one comparison anchor per class).
*
* @param options The agent, snapshots directory, scenario table, and mode.
*/
@@ -194,9 +216,23 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const { agent, snapshotsDir, scenarios, mode } = options
const RECORDING = mode === 'record'
/** The suite's single header-pinning scenario. Guarded here (and by a meta-test) so the pin cannot silently vanish. */
const pinningScenario = scenarios.find(s => s.pinsHeader === true)
if (pinningScenario === undefined) throw new Error('acp-snapshot: no scenario pins the request-header content')
/** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */
const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default'
/** Each header class's single pinning scenario. Guarded here (and by meta-tests) so a pin cannot silently vanish or split. */
const pinningByClass = new Map<string, Scenario>()
for (const scenario of scenarios) {
if (scenario.pinsHeader !== true) continue
const cls = classOf(scenario)
const existing = pinningByClass.get(cls)
if (existing) throw new Error(`acp-snapshot: header class "${cls}" pinned by both ${existing.name} and ${scenario.name}`)
pinningByClass.set(cls, scenario)
}
for (const scenario of scenarios) {
if (!pinningByClass.has(classOf(scenario))) {
throw new Error(`acp-snapshot: no scenario pins the request-header content of class "${classOf(scenario)}" (needed by ${scenario.name})`)
}
}
for (const scenario of scenarios) {
describe(`snapshot: ${scenario.name}`, () => {
@@ -217,6 +253,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// replays from its own script. In RECORD they are harvested, not read.
...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {},
...existsSync(workspaceDir) ? { workspaceDir } : {},
// A scenario booting an overlay tree passes its own live config; the
// bin's replay swap derives the sibling `*cordis.snapshot.yml` from it.
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},
})
// Scrub every volatile id the run produced: the ACP server-issued session
@@ -277,19 +316,22 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
}
// Header-uniformity guard: the single pin is sound only while every
// session in the suite composes the SAME header and keeps it for the
// whole run. Assert both halves live. (1) Every request/header the run
// produced (parent, spawn child, fork child, initial or resume) must
// equal the pinned fixture's header after each side is normalized
// against its own volatile values. (2) No request/header-delta may
// appear at all — a mid-run header change diverges from the pin by
// construction, and its content would be invisible under the scrub. If
// either fails, either the header changed (update the pin: re-record or
// hand-edit the pinning scenario's fixture) or composition became
// session-dependent by design (give the divergent shape its own
// pinning scenario).
// Header-uniformity guard: a class's single pin is sound only while
// every session in that class composes the SAME header and keeps it
// for the whole run. Assert both halves live. (1) Every
// request/header the run produced (parent, spawn child, fork child,
// initial or resume) must equal the CLASS's pinned fixture's header
// after each side is normalized against its own volatile values.
// (2) No request/header-delta may appear at all — a mid-run header
// change diverges from the pin by construction, and its content
// would be invisible under the scrub. If either fails, either the
// header changed (update the pin: re-record or hand-edit the pinning
// scenario's fixture) or composition became session-dependent by
// design (give the divergent shape its own pinning scenario and
// class).
if (scenario.pinsHeader !== true) {
/* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
const pinnedFixture = await readFile(join(snapshotsDir, pinningScenario.name, 'session.jsonl'), 'utf8')
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`)
@@ -347,11 +389,35 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
})
it('exactly one scenario pins the request-header content', () => {
// Zero pins would drop the prompt/schema surface from the suite entirely;
// two would split it. One pin per suite is the design (pinned-header RFC);
// WHICH scenario pins is the scenario table's reviewable choice.
expect(scenarios.filter(s => s.pinsHeader === true).map(s => s.name)).toEqual([pinningScenario.name])
it('exactly one scenario pins the request-header content of each header class', () => {
// Zero pins would drop a class's prompt/schema surface from the suite
// entirely; two would split it. One pin per class is the design
// (pinned-header RFC); WHICH scenario pins is the scenario table's
// reviewable choice.
const pins = new Map<string, string[]>()
for (const scenario of scenarios.filter(s => s.pinsHeader === true)) {
const cls = classOf(scenario)
pins.set(cls, [...pins.get(cls) ?? [], scenario.name])
}
expect(Object.fromEntries([...pins].map(([cls, names]) => [cls, names.length]))).toEqual(
Object.fromEntries([...pinningByClass.keys()].map(cls => [cls, 1])))
for (const scenario of scenarios) {
expect(pinningByClass.has(classOf(scenario)), `class "${classOf(scenario)}" (scenario ${scenario.name}) has a pin`).toBe(true)
}
})
it('every pinning fixture carries exactly one request/header and no deltas', async () => {
// The live uniformity guard runs only in NON-pinning scenarios, so a
// class made of just its pinning scenario would otherwise accept a
// re-recorded pin with several headers or a mid-run header-delta —
// shapes the pin design cannot represent. Assert the committed pins
// directly.
for (const scenario of pinningByClass.values()) {
const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8')
const headers = normalizedHeaders(fixture, fixtureContext(fixture))
expect(headers.length, `${scenario.name}: a pinning fixture must carry exactly one request/header`).toBe(1)
expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry no request/header-delta`).toBe(0)
}
})
it('committed fixtures carry request-header content ONLY in the pinning scenario', async () => {
@@ -34,12 +34,18 @@ const AGENT = {
const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url))
const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url))
// The replay suite doubles as the header-CLASS coverage: every scenario names
// the same explicit class (the record suite exercises the 'default' fallback),
// and plain-turn boots through a per-scenario configPath override (the same
// dummy path the agent default carries — the plumbing, not the composition,
// is what this suite can exercise; the real overlay boot is the acp-agent
// example's code-mode scenarios).
const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1 },
{ name: 'no-model', hasModelTurn: false, recorded: false },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false },
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true },
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'main' },
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath },
{ name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' },
]
const RECORD_SCENARIOS: Scenario[] = [
@@ -70,7 +76,7 @@ describe('defineAcpSnapshotSuite: record mode', () => {
})
describe('defineAcpSnapshotSuite: registration contract', () => {
it('throws when no scenario pins the request-header content', () => {
it("throws when a scenario's header class has no pinning scenario", () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
@@ -78,7 +84,33 @@ describe('defineAcpSnapshotSuite: registration contract', () => {
scenarios: [{ name: 'pinless', hasModelTurn: true, recorded: true }],
mode: 'replay',
})
}).toThrow(/no scenario pins/)
}).toThrow(/no scenario pins the request-header content of class "default"/)
// A pinned class does not cover a DIFFERENT class's members.
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{ name: 'pinned', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'classless-orphan', hasModelTurn: true, recorded: true, headerClass: 'other' },
],
mode: 'replay',
})
}).toThrow(/class "other" \(needed by classless-orphan\)/)
})
it('throws when two scenarios pin the same header class', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [
{ name: 'first-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'second-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
],
mode: 'replay',
})
}).toThrow(/header class "default" pinned by both first-pin and second-pin/)
})
})
+2
View File
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
@@ -46,6 +47,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
+7 -1
View File
@@ -34,6 +34,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -45,7 +46,8 @@ export const name = 'acp-agent'
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
* `persistenceRoot` is the JSONL backend's directory.
* `tools` is the tool registry's config (its presentation `mode`, forwarded
* through agent-core); `persistenceRoot` is the JSONL backend's directory.
*/
export interface Config {
/** Model name for ACP-created agents (must have a registered adapter). */
@@ -54,6 +56,8 @@ export interface Config {
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
}
@@ -65,6 +69,7 @@ export const Config: z<Config> = z.object({
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
persistenceRoot: z.string().default('./.sessions'),
})
@@ -79,6 +84,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
+2
View File
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -54,6 +55,7 @@
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
+5
View File
@@ -43,6 +43,7 @@ import ConsoleExporter from '@cordisjs/plugin-logger-console'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -66,6 +67,8 @@ export interface Config {
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -85,6 +88,7 @@ export const Config: z<Config> = z.object({
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
resumeSessionId: z.string(),
@@ -102,6 +106,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
agents: [{
id: AgentId('main'),
model: config.model,
+16
View File
@@ -353,13 +353,23 @@ importers:
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/core/tools:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../agent
'@deepseek-ai/dsh-code-runtime':
specifier: workspace:^
version: link:../../code-runtime/code-runtime
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../system-prompt
@@ -1088,6 +1098,9 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
@@ -1145,6 +1158,9 @@ importers:
'@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:^
version: link:../tool-ask-user
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
+29
View File
@@ -0,0 +1,29 @@
/**
* Boot the Code Mode demo under the UI named on the command line:
* `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the
* point — the UI is just the surface it happens to wear: each UI boots its
* base example through that example's `code-mode.cordis.yml` overlay
* (include ./cordis.yml, flip `tools.mode` to `code`, insert the
* worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env
* works). Anything else on the command line is a misconfiguration and
* fails loud with usage.
*/
import { spawn } from 'node:child_process'
// Each UI's node invocation, verbatim what its base demo script runs plus
// the overlay config (the stdio bin keeps --expose-internals for the cordis
// Loader's HMR path).
const UIS = new Map([
['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'repl'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [repl|acp]')
process.exit(2)
}
const child = spawn(process.execPath, args, { stdio: 'inherit' })
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })
+4 -4
View File
@@ -166,8 +166,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Code-execution seam',
mode: 'seam',
implementations: ['code-runtime-worker'],
consumers: [],
note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer).',
consumers: ['tools'],
note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode).',
},
{
key: 'fs',
@@ -669,7 +669,7 @@ function renderToolPipeline(): string {
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
' toolBody["Registered tool execute() body"]',
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
' context["Buffered additionalContext<br/>context/message after all tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
@@ -691,7 +691,7 @@ function renderToolPipeline(): string {
' toolResult --> presentResult',
'```',
'',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
'',
...maintenanceFooter(maintenance),
].join('\n')
+23 -2
View File
@@ -38,7 +38,7 @@ import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -89,6 +89,13 @@ interface ToolPackage {
/** Plug the injected seams + the tool plugin onto a context that already
* carries `systemPrompt` + `tools`. */
mount: (ctx: Context) => Promise<void>
/**
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
* model-facing tool (`run_code`, registered under a non-native `mode`), so
* ITS catalog entry boots the registry in the mode that surfaces it;
* every other entry uses the default (native) registry.
*/
toolsConfig?: ToolsConfig
/**
* A deployment note rendered after the package's tools, for a fact that
* booting the package alone cannot show. The registered tool NAME can be a
@@ -118,6 +125,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
},
{
pkg: '@deepseek-ai/dsh-tools',
dir: 'tools',
source: 'packages/core/tools/src/code-mode.ts',
requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'],
writes: ['tool/call', 'one tool/code-dispatch per bridged sub-call', 'tool/result'],
// The registry's OWN tool: run_code exists only under a non-native mode
// (the registry registers it in its constructor; the code runtime is read
// at assembly/execution time, so the schema harvest needs none mounted).
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
@@ -274,7 +295,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
// fiber) — the repo's "dispose must reach quiescence" rule.
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {})
await entry.mount(ctx)
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
catalog.push({