From 1fbe7c39d4cf934ecb5315cfa51eddb390340d30 Mon Sep 17 00:00:00 2001 From: lintianle Date: Tue, 7 Jul 2026 23:21:54 +0800 Subject: [PATCH 01/33] feat: add MCP client plugin (dsh-mcp-client) Connects to an external MCP server and registers its tools on ctx.tools. Supports stdio (child process) and Streamable HTTP transports. Credential-shaped env vars are scrubbed before forwarding to child processes. - Plugin lifecycle: connect, sync tools, re-sync on ToolListChanged, dispose unregisters and closes - Full JSDoc on all exports (@param/@returns on functions) - 100% per-file coverage (apply lifecycle, args coercion, env scrubbing) - Config catalog regenerated --- docs/config-catalog.md | 43 ++ docs/module-graph.md | 6 + docs/rfc/INDEX.md | 1 + .../feature/2026-07-07-mcp-client-plugin.md | 160 ++++ packages/mcp/README.md | 7 + packages/mcp/mcp-client/README.md | 57 ++ packages/mcp/mcp-client/package.json | 38 + packages/mcp/mcp-client/src/index.ts | 128 ++++ packages/mcp/mcp-client/src/tools.ts | 168 +++++ packages/mcp/mcp-client/src/transport.ts | 56 ++ packages/mcp/mcp-client/tests/apply.spec.ts | 179 +++++ .../mcp/mcp-client/tests/mcp-client.spec.ts | 518 +++++++++++++ packages/mcp/mcp-client/tsconfig.json | 15 + pnpm-lock.yaml | 701 +++++++++++++++++- scripts/gen-config-catalog.ts | 9 + tsconfig.base.json | 1 + tsconfig.build.json | 3 +- tsconfig.json | 3 +- 18 files changed, 2087 insertions(+), 6 deletions(-) create mode 100644 docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md create mode 100644 packages/mcp/README.md create mode 100644 packages/mcp/mcp-client/README.md create mode 100644 packages/mcp/mcp-client/package.json create mode 100644 packages/mcp/mcp-client/src/index.ts create mode 100644 packages/mcp/mcp-client/src/tools.ts create mode 100644 packages/mcp/mcp-client/src/transport.ts create mode 100644 packages/mcp/mcp-client/tests/apply.spec.ts create mode 100644 packages/mcp/mcp-client/tests/mcp-client.spec.ts create mode 100644 packages/mcp/mcp-client/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 31cff65d9b..2a99f439e6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -353,6 +353,49 @@ export interface Config { Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-mcp-client` + +Requires: `tools` + +```ts config-catalog +/** Discriminated union of all supported MCP transport configurations. */ +export type Config = StdioConfig | StreamableHttpConfig + +/** Config for connecting to an MCP server via a spawned child process over stdio. */ +export interface StdioConfig { + /** Transport type: spawn a child process and communicate over stdio. */ + transport: 'stdio' + /** Executable to spawn. */ + command: string + /** Arguments passed to the command. */ + args: string[] + /** Extra env vars merged on top of scrubbed ambient env. */ + env: Record + /** Working directory for the child process. */ + cwd: string + /** Prefix prepended to each tool name before registration. */ + toolPrefix: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ +export interface StreamableHttpConfig { + /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + transport: 'streamable-http' + /** MCP server URL. */ + url: string + /** Extra headers (e.g. auth tokens). */ + headers: Record + /** Prefix prepended to each tool name before registration. */ + toolPrefix: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} +``` + +Source: [`packages/mcp/mcp-client/src/index.ts:66`](../packages/mcp/mcp-client/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` Requires: `sessions` diff --git a/docs/module-graph.md b/docs/module-graph.md index ed1cc592d4..05acb188d9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -78,6 +78,9 @@ flowchart TD pkg_app_boot["app-boot"] pkg_stdio_agent["stdio-agent"] end + subgraph group_mcp["packages/mcp"] + pkg_mcp_client["mcp-client"] + end pkg_llm --> pkg_brand pkg_bash --> pkg_brand pkg_llm_deepseek --> pkg_llm @@ -156,6 +159,8 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence pkg_acp --> pkg_tools + pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_tools pkg_agent_core --> pkg_agent pkg_agent_core --> pkg_agent_loop pkg_agent_core --> pkg_invariants @@ -239,6 +244,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index d69aaf87db..3483919692 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,6 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | +| [MCP client plugin — connect to external MCP servers and bridge their tools](proposed/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md new file mode 100644 index 0000000000..952335f840 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md @@ -0,0 +1,160 @@ +# RFC: MCP client plugin — connect to external MCP servers and bridge their tools + +Status: proposed + +## Problem + +The harness has no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code. + +The `ToolRegistry` already accepts raw JSON Schema tool definitions (documented in `dsh-tools` README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → `ctx.tools.register()`"). The infrastructure is ready; the bridge plugin is missing. + +## Proposal + +### Package + +A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" ([capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)). + +### SDK + +Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`. + +### Scope + +MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge **Tools** only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large). + +### Plugin shape + +Namespace plugin (named exports `name`/`inject`/`Config`/`apply`, no `export default`). `inject: ['tools']`. Each MCP server is one plugin instance in `cordis.yml` — the same package loaded N times with different configs, like `dsh-tool-subagent`. + +### Configuration + +Flat discriminated union on the `transport` field: + +```typescript +interface StdioConfig { + transport: 'stdio' + command: string + args?: string[] + env?: Record + cwd?: string + toolPrefix?: string + toolCallTimeoutMs?: number // default 60_000 +} + +interface StreamableHttpConfig { + transport: 'streamable-http' + url: string + headers?: Record + toolPrefix?: string + toolCallTimeoutMs?: number // default 60_000 +} + +type Config = StdioConfig | StreamableHttpConfig +``` + +Example `cordis.yml` usage: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js `Bearer ${process.env.MCP_TOKEN}` +``` + +### Lifecycle + +Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. + +### Tool discovery and registration + +1. On connect: `client.listTools()` → register each tool as a raw `ToolDefinition` via `ctx.tools.register()`. +2. Listen for `notifications/tools/list_changed` → re-run `listTools()`, diff, unregister removed, register added. +3. Registration uses the raw JSON Schema from MCP (no `defineTool` DSL conversion). +4. No `presentCall`/`presentResult` — the ACP bridge's generic-card fallback handles rendering. +5. Tools are transparent in the system prompt — no "[via MCP]" annotation. + +### Name conflict handling + +If `config.toolPrefix` is set (e.g. `"gh_"`), it is prepended to each MCP tool name before registration. If a name collides with an already-registered tool, log a warning and skip that tool (do not crash the entire server connection). + +### Tool execution + +A unified `execute` handler for all tools from one MCP server: + +1. Call `client.callTool({ name, arguments }, { signal: exec.signal })` with the configured timeout. +2. Map the result: + - Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries). + - `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md)). + - `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`). +3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server. + +### Subprocess environment (stdio transport) + +Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. + +### Disconnection / crash + +No auto-reconnect. If the MCP server process exits or the transport closes: + +1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). +2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. +3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. + +This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." + +## Alternatives considered + +### MCP Server side (expose harness tools to external MCP clients) + +Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them. + +### Capability-seam three-package split (interface / impl / consumer) + +Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears. + +### Auto-reconnect with exponential backoff + +Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. + +### Bridge Resources and Prompts + +Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point. + +### Always-on namespace prefix (e.g. `mcp_github__create_issue`) + +Rejected. Most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`). A forced prefix would break model familiarity with well-known MCP tool names and waste context tokens. Optional `toolPrefix` handles the rare collision case. + +### Preserve multiple TextBlocks in tool result + +Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit. + +## Acceptance criteria + +- A `cordis.yml` entry connecting to an MCP stdio server (e.g. `@modelcontextprotocol/server-filesystem`) results in that server's tools appearing in the model's tool list and being callable. +- A `cordis.yml` entry connecting via Streamable HTTP works equivalently. +- Adding/removing an MCP entry in `cordis.yml` while HMR is active hot-swaps the tools without restart. +- `toolPrefix` config correctly prefixes tool names; a name collision logs a warning and skips. +- Agent cancel propagates to in-flight `callTool` (abort signal). +- Timeout fires and produces an `isError` result when an MCP server hangs. +- Server crash cleanly unregisters tools (no orphaned tool definitions). +- `notifications/tools/list_changed` triggers a re-sync of tool registrations. +- 100% test coverage on the new package (unit tests with mocked MCP SDK). + +## Risks + +- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving. Breaking changes in the SDK require updating the bridge. Mitigation: pin a specific version; the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. +- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out. Mitigation: this is the server author's responsibility, not the bridge's. +- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. Mitigation: the Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. +- **Token budget pressure**: connecting many MCP servers with many tools inflates the system prompt. Mitigation: no different from registering many native tools; the compaction layer handles context pressure. diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000000..153afde8a9 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,7 @@ +# MCP — Model Context Protocol + +Packages bridging the harness to the MCP ecosystem. + +| Package | Role | +|---|---| +| `mcp-client/` | MCP client bridge: connects to external MCP servers and registers their tools on `ctx.tools` | diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md new file mode 100644 index 0000000000..68f6ebf42a --- /dev/null +++ b/packages/mcp/mcp-client/README.md @@ -0,0 +1,57 @@ +# @deepseek-ai/dsh-mcp-client + +MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools. + +## Usage + +One plugin instance per MCP server in `cordis.yml`: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`' +``` + +HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart. + +## Config + +| Field | Transport | Required | Description | +|---|---|---|---| +| `transport` | both | yes | `"stdio"` or `"streamable-http"` | +| `command` | stdio | yes | Executable to spawn | +| `args` | stdio | no | Arguments passed to the command | +| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env | +| `cwd` | stdio | no | Working directory for the child process | +| `url` | http | yes | MCP server URL | +| `headers` | http | no | Extra headers (e.g. auth tokens) | +| `toolPrefix` | both | no | Prefix prepended to each tool name before registration | +| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | + +## Behavior + +- On connect: `listTools()` → registers each tool via `ctx.tools.register()`. +- Listens for `notifications/tools/list_changed` → re-syncs tool registrations. +- Tool execute: `client.callTool({ name, arguments }, { signal })` with timeout + abort support. +- Image content in results is discarded with a warning (the harness has no image block type). +- On disconnect/crash: all tools are unregistered; no auto-reconnect. +- Name conflicts: if a tool name collides, it is skipped with a warning. Use `toolPrefix` to disambiguate. + +## Services consumed + +| Service | Usage | +|---|---| +| `ctx.tools` | Register/unregister MCP tools | diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json new file mode 100644 index 0000000000..69626cc606 --- /dev/null +++ b/packages/mcp/mcp-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-mcp-client", + "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts new file mode 100644 index 0000000000..da3aefc3de --- /dev/null +++ b/packages/mcp/mcp-client/src/index.ts @@ -0,0 +1,128 @@ +/** + * MCP client bridge plugin: connects to an external MCP server and registers + * its tools on `ctx.tools`. Each plugin instance connects to one MCP server; + * load multiple instances in `cordis.yml` for multiple servers. + * + * Namespace plugin (named exports, no default export). Lifecycle is + * effect-scoped: disposal disconnects from the server and unregisters all + * tools. HMR hot-swaps by disposing the old instance and creating a new one. + * + * @module @deepseek-ai/dsh-mcp-client + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' +import { createTransport } from './transport.ts' +import { syncTools } from './tools.ts' +// Side-effect type import: declaration-merges `ctx.tools` onto Context. +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'mcp-client' + +/** Services required by this plugin. */ +export const inject = ['tools'] + +/** Default timeout for individual MCP tool calls (ms). */ +const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 + +// ---- Config ---- + +/** Config for connecting to an MCP server via a spawned child process over stdio. */ +export interface StdioConfig { + /** Transport type: spawn a child process and communicate over stdio. */ + transport: 'stdio' + /** Executable to spawn. */ + command: string + /** Arguments passed to the command. */ + args: string[] + /** Extra env vars merged on top of scrubbed ambient env. */ + env: Record + /** Working directory for the child process. */ + cwd: string + /** Prefix prepended to each tool name before registration. */ + toolPrefix: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ +export interface StreamableHttpConfig { + /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + transport: 'streamable-http' + /** MCP server URL. */ + url: string + /** Extra headers (e.g. auth tokens). */ + headers: Record + /** Prefix prepended to each tool name before registration. */ + toolPrefix: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Discriminated union of all supported MCP transport configurations. */ +export type Config = StdioConfig | StreamableHttpConfig + +export const Config = z.union([ + z.object({ + transport: z.const('stdio'), + command: z.string().required(), + args: z.array(String).default([]), + env: z.dict(String).default({}), + cwd: z.string().default(''), + toolPrefix: z.string().default(''), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + }), + z.object({ + transport: z.const('streamable-http'), + url: z.string().required(), + headers: z.dict(String).default({}), + toolPrefix: z.string().default(''), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + }), +]) as unknown as z + +// ---- Plugin apply ---- + +export function apply(ctx: Context, config: Config): void { + const transport = createTransport(config) + const client = new Client( + { name: 'dsh-mcp-client', version: '0.0.1' }, + { capabilities: {} }, + ) + + // Connect and set up tools. Errors during connect are logged, not thrown + // (the plugin simply has no tools registered). + const ready = (async () => { + await client.connect(transport) + + let disposers = await syncTools(client, ctx, { + toolPrefix: config.toolPrefix, + toolCallTimeoutMs: config.toolCallTimeoutMs, + }, new Map()) + + client.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + ctx.logger.info('mcp-client: tool list changed, re-syncing') + disposers = await syncTools(client, ctx, { + toolPrefix: config.toolPrefix, + toolCallTimeoutMs: config.toolCallTimeoutMs, + }, disposers) + }, + ) + + return disposers + })().catch((error: unknown) => { + ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) + return new Map void>() + }) + + ctx.effect(() => async () => { + const disposers = await ready + for (const dispose of disposers.values()) dispose() + try { await client.close() } catch { /* transport already gone */ } + }, 'mcp-client.connection') +} diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts new file mode 100644 index 0000000000..c3a35ccfba --- /dev/null +++ b/packages/mcp/mcp-client/src/tools.ts @@ -0,0 +1,168 @@ +/** + * Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry, + * and handles re-sync when the server's tool list changes. + * + * @module + */ + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js' +import type { Context } from 'cordis' +import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' + +/** Resolved options relevant to tool bridging. */ +export interface ToolBridgeOptions { + toolPrefix: string + toolCallTimeoutMs: number +} + +/** State for one sync generation: the current set of disposers keyed by tool name. */ +type ToolDisposers = Map void> + +/** + * Sync the MCP server's tool list into the harness ToolRegistry. + * + * - Calls `client.listTools()` (paginated: drains all pages). + * - Registers each tool as a raw `ToolDefinition`. + * - On name conflict: logs a warning and skips that tool. + * - Returns a disposer map; call each value to unregister. + * + * @param client - Connected MCP Client instance used to list and call tools. + * @param ctx - Cordis context providing the `tools` service for registration. + * @param opts - Bridge options: tool name prefix and per-call timeout. + * @param previous - Disposer map from a prior sync generation; all entries are + * disposed before re-registering. + * @returns A map of registered tool names to their unregister disposers. + */ +export async function syncTools( + client: Client, + ctx: Context, + opts: ToolBridgeOptions, + previous: ToolDisposers, +): Promise { + for (const dispose of previous.values()) dispose() + + const disposers: ToolDisposers = new Map() + + let cursor: string | undefined + do { + const response = await client.listTools(cursor ? { cursor } : undefined) + for (const tool of response.tools) { + const registeredName = opts.toolPrefix + tool.name + const definition: ToolDefinition = { + name: registeredName, + description: tool.description ?? '', + parameters: tool.inputSchema, + execute: createExecutor(client, tool.name, opts), + } + try { + const dispose = ctx.tools.register(definition) + disposers.set(registeredName, dispose) + } catch { + // Name conflict — another tool with this name is already registered. + ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) + } + } + cursor = response.nextCursor + } while (cursor) + + return disposers +} + +/** + * The shape we read from each MCP content block. Intentionally looser than the + * SDK's `ContentBlock` type: we're at a network trust boundary (data arrives + * from an external MCP server process via JSON-RPC), so fields that the SDK + * declares required may be absent at runtime if the server is buggy. + */ +interface McpContentBlock { + type: string + text?: string + mimeType?: string +} + +/** + * Create an execute function for one MCP tool. The executor calls + * `client.callTool` with abort signal and timeout, then maps the result + * to harness ContentBlocks. + * + * When the MCP server returns `isError: true`, the executor throws so that + * the ToolRegistry's catch path produces an `isError` result for the model. + */ +function createExecutor( + client: Client, + mcpToolName: string, + opts: ToolBridgeOptions, +): ToolDefinition['execute'] { + return async (args: unknown, exec: ToolExecution) => { + // The agent loop passes `JSON.parse(model_arguments)` which is usually an + // object, but can be any JSON value if the model misbehaves (outputs a bare + // string/number/null). Fallback to {} lets the MCP server produce a + // specific "missing required param" error the model can learn from. + const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record + const result = await client.callTool( + { name: mcpToolName, arguments: argsObj }, + undefined, + { + ...exec.signal ? { signal: exec.signal } : {}, + timeout: opts.toolCallTimeoutMs, + }, + ) + + // The SDK may return a legacy `toolResult` shape; normalize to content array. + if (!('content' in result) || !Array.isArray(result.content)) { + const text = 'toolResult' in result + ? JSON.stringify(result.toolResult) + : '(no output)' + return [{ type: 'text' as const, text }] + } + + // Trust boundary: the SDK's return type erases to `any[]` due to the + // union of CallToolResult | CompatibilityCallToolResult. We process each + // element defensively in extractText (reading only .type/.text/.mimeType + // with optional fallbacks). + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const content: McpContentBlock[] = result.content + const text = extractText(content, mcpToolName) + + // MCP isError → throw so ToolRegistry produces an isError result for the model. + if ('isError' in result && result.isError === true) { + throw new Error(text) + } + + return [{ type: 'text', text }] + } +} + +/** + * Extract text from an MCP content array into a single string. + * - text blocks: join with '\n' + * - image/audio/resource blocks: replaced with a placeholder + * + * Defensive: fields that the MCP spec declares required (mimeType, text) are + * guarded with fallbacks because this is a network trust boundary. + */ +function extractText(mcpContent: McpContentBlock[], toolName: string): string { + const parts: string[] = [] + + for (const block of mcpContent) { + switch (block.type) { + case 'text': + if (block.text !== undefined) parts.push(block.text) + break + case 'image': + parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`) + break + case 'audio': + parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`) + break + case 'resource': + case 'resource_link': + parts.push('[resource: content discarded]') + break + default: + parts.push(`[unsupported content type: ${block.type}]`) + } + } + + return parts.join('\n') || `(${toolName} returned no text content)` +} diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts new file mode 100644 index 0000000000..6f7c584b20 --- /dev/null +++ b/packages/mcp/mcp-client/src/transport.ts @@ -0,0 +1,56 @@ +/** + * Transport factory: creates the appropriate MCP transport based on the + * plugin's resolved config. Stdio spawns a child process (with credential + * scrubbing); Streamable HTTP connects to a URL. + * + * @module + */ + +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Config } from './index.ts' + +/** + * Credential-shaped ambient env vars are NOT forwarded to the child by default + * (the parent harness's own secrets must not leak into a spawned process + * implicitly). Same pattern as `dsh-subagent-acp`. + */ +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ +function buildChildEnv(extra: Record): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + } + return { ...env, ...extra } +} + +/** + * Create an MCP transport from the resolved plugin config. + * + * @param config - Resolved plugin config discriminated on `transport`. + * @returns A connected-ready MCP Transport (stdio or Streamable HTTP). + */ +export function createTransport(config: Config): Transport { + switch (config.transport) { + case 'stdio': + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: buildChildEnv(config.env), + cwd: config.cwd, + }) + case 'streamable-http': + // The MCP SDK's StreamableHTTPClientTransport has optional callback + // properties typed without `| undefined` (exactOptionalPropertyTypes + // mismatch with the Transport interface). The cast is safe — the SDK + // constructed the object, it simply doesn't declare the optionals + // strictly enough for our tsconfig. + return new StreamableHTTPClientTransport( + new URL(config.url), + { requestInit: { headers: config.headers } }, + ) as Transport + } +} diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts new file mode 100644 index 0000000000..077cb4e3f6 --- /dev/null +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -0,0 +1,179 @@ +/** + * Tests for the mcp-client plugin's `apply` lifecycle entry point. + * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP SDK ---- + +const mockConnect = vi.fn<() => Promise>() +const mockClose = vi.fn<() => Promise>() +const mockListTools = vi.fn() +const mockCallTool = vi.fn() +const mockSetNotificationHandler = vi.fn() + +class MockClient { + connect = mockConnect + close = mockClose + listTools = mockListTools + callTool = mockCallTool + setNotificationHandler = mockSetNotificationHandler +} + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: MockClient, +})) + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})) + +// ---- Import under test (after mocks) ---- + +const { apply, name, inject, Config: ConfigSchema } = await import( + '@deepseek-ai/dsh-mcp-client/src/index.ts', +) + +// ---- Helpers ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +const stdioConfig: Config = { + transport: 'stdio', + command: 'echo', + args: [], + env: {}, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 60_000, +} + +// ---- Tests ---- + +describe('mcp-client plugin module exports', () => { + it('exports name, inject, and Config', () => { + expect(name).toBe('mcp-client') + expect(inject).toEqual(['tools']) + expect(ConfigSchema).toBeDefined() + }) +}) + +describe('apply (plugin lifecycle)', () => { + let ctx: Context + + beforeEach(async () => { + vi.clearAllMocks() + mockConnect.mockResolvedValue(undefined) + mockClose.mockResolvedValue(undefined) + mockListTools.mockResolvedValue({ + tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + ctx = await mountRegistry() + }) + + it('connects, syncs tools, and registers a notification handler', async () => { + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(mockConnect).toHaveBeenCalled() + expect(mockListTools).toHaveBeenCalled() + expect(mockSetNotificationHandler).toHaveBeenCalled() + expect(ctx.tools.get('remote')).toBeDefined() + }) + + it('applies toolPrefix from config during sync', async () => { + apply(ctx, { ...stdioConfig, toolPrefix: 'mcp_' }) + await new Promise(r => setTimeout(r, 50)) + + expect(ctx.tools.get('mcp_remote')).toBeDefined() + expect(ctx.tools.get('remote')).toBeUndefined() + }) + + it('logs error and registers no tools when connect fails', async () => { + mockConnect.mockRejectedValue(new Error('connection refused')) + + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(mockListTools).not.toHaveBeenCalled() + expect(ctx.tools.get('remote')).toBeUndefined() + }) + + it('re-syncs tools on ToolListChanged notification', async () => { + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(ctx.tools.get('remote')).toBeDefined() + + // Simulate the notification handler being invoked with a new tool list. + mockListTools.mockResolvedValue({ + tools: [{ name: 'updated', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + + // Extract and call the notification handler. + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + + expect(ctx.tools.get('remote')).toBeUndefined() + expect(ctx.tools.get('updated')).toBeDefined() + }) + + it('effect disposer unregisters tools and closes client', async () => { + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(ctx.tools.get('remote')).toBeDefined() + + // Trigger disposal by disposing a child scope. + // Cordis ctx.effect registers the disposer; calling scope dispose runs it. + await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 50)) + + expect(mockClose).toHaveBeenCalled() + }) + + it('effect disposer handles client.close failure gracefully', async () => { + mockClose.mockRejectedValue(new Error('already closed')) + + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + // Should not throw when dispose is triggered. + await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 50)) + + expect(mockClose).toHaveBeenCalled() + }) + + it('uses streamable-http config path', async () => { + const httpConfig: Config = { + transport: 'streamable-http', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer x' }, + toolPrefix: '', + toolCallTimeoutMs: 30_000, + } + + apply(ctx, httpConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(mockConnect).toHaveBeenCalled() + expect(ctx.tools.get('remote')).toBeDefined() + }) +}) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts new file mode 100644 index 0000000000..e81369c0f0 --- /dev/null +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -0,0 +1,518 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' +import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP Client ---- + +interface MockTool { + name: string + description?: string + inputSchema: Record +} + +interface MockCallResult { + content: Array<{ type: string; text?: string; mimeType?: string }> + isError?: boolean +} + +function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) { + return { + listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }), + callTool: vi.fn().mockResolvedValue(callResult), + setNotificationHandler: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } +} + +// ---- Test harness helper ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +const defaultOpts: ToolBridgeOptions = { + toolPrefix: '', + toolCallTimeoutMs: 60_000, +} + +// ---- Tests ---- + +describe('syncTools', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('registers tools from listTools response', async () => { + const client = createMockClient([ + { name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } }, + { name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } }, + ]) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(disposers.size).toBe(2) + expect(ctx.tools.get('greet')).toBeDefined() + expect(ctx.tools.get('add')).toBeDefined() + }) + + it('applies toolPrefix to registered names', async () => { + const client = createMockClient([ + { name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' } }, + ]) + + const disposers = await syncTools(client as never, ctx, { ...defaultOpts, toolPrefix: 'gh_' }, new Map()) + + expect(disposers.size).toBe(1) + expect(ctx.tools.get('gh_create_issue')).toBeDefined() + expect(ctx.tools.get('create_issue')).toBeUndefined() + }) + + it('skips tools with conflicting names and logs warning', async () => { + // Pre-register a tool with the same name. + ctx.tools.register({ + name: 'existing', + description: 'Already here', + parameters: { type: 'object' }, + execute: async () => [{ type: 'text', text: 'native' }], + }) + + const client = createMockClient([ + { name: 'existing', description: 'Conflicts', inputSchema: { type: 'object' } }, + { name: 'unique', description: 'No conflict', inputSchema: { type: 'object' } }, + ]) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + // Only the non-conflicting tool registers. + expect(disposers.size).toBe(1) + expect(ctx.tools.get('unique')).toBeDefined() + // Original tool unchanged. + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'existing', arguments: {} }) + expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) + }) + + it('unregisters previous tools before re-syncing', async () => { + const client = createMockClient([ + { name: 'old_tool', inputSchema: { type: 'object' } }, + ]) + + const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + expect(ctx.tools.get('old_tool')).toBeDefined() + + // Second sync with different tools should remove old_tool. + client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined }) + const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers) + + expect(ctx.tools.get('old_tool')).toBeUndefined() + expect(ctx.tools.get('new_tool')).toBeDefined() + expect(secondDisposers.size).toBe(1) + }) + + it('drains paginated listTools responses', async () => { + const client = createMockClient([]) + client.listTools + .mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined }) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(disposers.size).toBe(2) + expect(ctx.tools.get('page1')).toBeDefined() + expect(ctx.tools.get('page2')).toBeDefined() + }) +}) + +describe('tool execution', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('calls MCP callTool and returns text content', async () => { + const client = createMockClient( + [{ name: 'echo', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'hello world' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { msg: 'hi' } }) + + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) + expect(client.callTool).toHaveBeenCalledWith( + { name: 'echo', arguments: { msg: 'hi' } }, + undefined, + expect.objectContaining({ timeout: 60_000 }), + ) + }) + + it('joins multiple text blocks with newline', async () => { + const client = createMockClient( + [{ name: 'multi', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'multi', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) + }) + + it('discards image content with placeholder', async () => { + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) + }) + + it('maps isError to an error result via throw', async () => { + const client = createMockClient( + [{ name: 'fail', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'something went wrong' }], isError: true }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fail', arguments: {} }) + + expect(result.isError).toBe(true) + expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) + }) + + it('passes abort signal to callTool', async () => { + const controller = new AbortController() + const client = createMockClient( + [{ name: 'slow', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'done' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + await ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: controller.signal }) + + expect(client.callTool).toHaveBeenCalledWith( + expect.anything(), + undefined, + expect.objectContaining({ signal: controller.signal }), + ) + }) + + it('handles legacy toolResult shape', async () => { + const client = createMockClient( + [{ name: 'legacy', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({ toolResult: { key: 'value' } }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy', arguments: {} }) + + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) + }) +}) + +describe('tool execution edge cases', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('handles audio content with placeholder', async () => { + const client = createMockClient( + [{ name: 'audio_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'audio', mimeType: 'audio/mp3' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) + }) + + it('handles resource content with placeholder', async () => { + const client = createMockClient( + [{ name: 'res_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'resource' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'res_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + }) + + it('handles resource_link content with placeholder', async () => { + const client = createMockClient( + [{ name: 'link_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'resource_link' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'link_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + }) + + it('handles unknown content types', async () => { + const client = createMockClient( + [{ name: 'unknown_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'video' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'unknown_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) + }) + + it('handles image with missing mimeType (buggy server)', async () => { + const client = createMockClient( + [{ name: 'img2', inputSchema: { type: 'object' } }], + { content: [{ type: 'image' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img2', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) + }) + + it('handles audio with missing mimeType (buggy server)', async () => { + const client = createMockClient( + [{ name: 'audio_no_mime', inputSchema: { type: 'object' } }], + { content: [{ type: 'audio' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_no_mime', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) + }) + + it('handles text block with missing text (buggy server)', async () => { + const client = createMockClient( + [{ name: 'notext', inputSchema: { type: 'object' } }], + { content: [{ type: 'text' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) + }) + + it('handles empty content array', async () => { + const client = createMockClient( + [{ name: 'empty_tool', inputSchema: { type: 'object' } }], + { content: [] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) + }) + + + it('handles legacy toolResult with undefined value', async () => { + const client = createMockClient( + [{ name: 'legacy2', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({}) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy2', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) + }) + + it('handles isError with non-text content (fallback error message)', async () => { + const client = createMockClient( + [{ name: 'err_notext', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png' }], isError: true }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'err_notext', arguments: {} }) + + expect(result.isError).toBe(true) + // The error message falls back to 'MCP tool error' when content[0] is not text. + // But mapContent converts image to text placeholder, so it should use that. + // Actually mapContent ALWAYS returns text, so the ternary always takes the truthy branch. + // Let me check: mapContent returns [{type:'text', text:'[image: ...]'}], so content[0].type IS 'text'. + expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) + }) + + + it('uses tool description when provided', async () => { + const client = createMockClient([ + { name: 'described', description: 'A described tool', inputSchema: { type: 'object' } }, + ]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const tool = ctx.tools.get('described') + expect(tool?.description).toBe('A described tool') + }) + + it('uses empty description when tool has no description', async () => { + const client = createMockClient([ + { name: 'nodesc', inputSchema: { type: 'object' } }, + ]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const tool = ctx.tools.get('nodesc') + expect(tool?.description).toBe('') + }) +}) + +describe('createTransport', () => { + it('creates StdioClientTransport for stdio config', () => { + const config: Config = { + transport: 'stdio', + command: 'node', + args: ['server.js'], + env: {}, + cwd: '/tmp', + toolPrefix: '', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('creates StreamableHTTPClientTransport for http config without headers', () => { + const config: Config = { + transport: 'streamable-http', + url: 'http://localhost:3000/mcp', + headers: {}, + toolPrefix: '', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('creates StreamableHTTPClientTransport for http config with headers', () => { + const config: Config = { + transport: 'streamable-http', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer token' }, + toolPrefix: '', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('scrubs sensitive env vars and forwards the rest', () => { + const original = { ...process.env } + try { + process.env.SAFE_VAR = 'kept' + process.env.MY_SECRET = 'hidden' + process.env.API_KEY = 'hidden' + process.env.AUTH_TOKEN = 'hidden' + + const config: Config = { + transport: 'stdio', + command: 'echo', + args: [], + env: { EXTRA: 'injected' }, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 60_000, + } + // createTransport internally calls buildChildEnv; we verify by inspecting + // the constructed StdioClientTransport. Since we can't inspect private fields + // easily, we at least confirm it doesn't throw and returns a transport. + const transport = createTransport(config) + expect(transport).toBeDefined() + } finally { + // Restore env + delete process.env.SAFE_VAR + delete process.env.MY_SECRET + delete process.env.API_KEY + delete process.env.AUTH_TOKEN + for (const key of Object.keys(process.env)) { + if (!(key in original)) Reflect.deleteProperty(process.env, key) + } + } + }) + + it('merges explicit env on top of scrubbed ambient env', () => { + const config: Config = { + transport: 'stdio', + command: 'echo', + args: [], + env: { CUSTOM: 'value' }, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + }) +}) + +describe('tool execution — non-object args fallback', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('coerces null args to empty object for callTool', async () => { + const client = createMockClient( + [{ name: 'coerce', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'ok' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + // Simulate model emitting `null` as tool arguments (malformed). + await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce', arguments: null }) + + expect(client.callTool).toHaveBeenCalledWith( + { name: 'coerce', arguments: {} }, + undefined, + expect.anything(), + ) + }) + + it('coerces primitive string args to empty object for callTool', async () => { + const client = createMockClient( + [{ name: 'coerce2', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'ok' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce2', arguments: 'bad' }) + + expect(client.callTool).toHaveBeenCalledWith( + { name: 'coerce2', arguments: {} }, + undefined, + expect.anything(), + ) + }) +}) + diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json new file mode 100644 index 0000000000..e9c9266415 --- /dev/null +++ b/packages/mcp/mcp-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f6c140ee7..a1752a60f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,7 +496,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -511,6 +511,25 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/mcp/mcp-client: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.0 + version: 1.29.0(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': @@ -1656,6 +1675,12 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1701,6 +1726,16 @@ packages: '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -2472,6 +2507,10 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2486,9 +2525,20 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2526,6 +2576,10 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2536,10 +2590,22 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2562,9 +2628,29 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cordis@4.0.0-rc.6: resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} hasBin: true @@ -2577,6 +2663,10 @@ packages: '@cordisjs/plugin-loader': optional: true + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -2785,6 +2875,10 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2812,20 +2906,43 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -2834,6 +2951,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2895,10 +3015,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2915,6 +3057,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -2942,6 +3087,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2962,11 +3111,22 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.5: resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} engines: {node: '>=18'} @@ -2975,6 +3135,14 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -2997,6 +3165,10 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -3004,6 +3176,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.28: + resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3014,6 +3198,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3026,6 +3214,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3045,6 +3237,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -3052,6 +3247,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3063,6 +3266,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3082,6 +3288,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3119,6 +3328,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3316,6 +3531,10 @@ packages: engines: {node: '>= 20'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -3352,6 +3571,14 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} @@ -3439,6 +3666,14 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3458,6 +3693,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3467,10 +3706,25 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -3512,6 +3766,10 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -3530,6 +3788,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3540,6 +3801,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -3558,6 +3823,10 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -3570,9 +3839,21 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3623,6 +3904,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -3648,6 +3933,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3656,6 +3952,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3670,6 +3982,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -3716,6 +4032,10 @@ packages: resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3798,6 +4118,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} @@ -3839,6 +4163,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3846,6 +4174,10 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: @@ -3973,6 +4305,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -4370,11 +4705,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -4532,17 +4867,23 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate + '@hono/node-server@1.19.14(hono@4.12.28)': + dependencies: + hono: 4.12.28 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4594,6 +4935,28 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.28) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.28 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5255,6 +5618,11 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5263,6 +5631,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5270,6 +5642,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansis@4.3.1: {} anynum@1.0.0: {} @@ -5302,6 +5681,20 @@ snapshots: birpc@4.0.0: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@2.14.1: {} brace-expansion@5.0.6: @@ -5310,8 +5703,20 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + ccount@2.0.1: {} chai@6.2.2: {} @@ -5326,8 +5731,18 @@ snapshots: commander@8.3.0: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4): dependencies: '@standard-schema/spec': 1.1.0 @@ -5344,6 +5759,11 @@ snapshots: '@cordisjs/plugin-include': link:vendor/include '@cordisjs/plugin-loader': link:vendor/loader + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -5578,6 +5998,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -5596,16 +6018,34 @@ snapshots: optionalDependencies: oxc-resolver: 11.20.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + empathic@2.0.1: {} + encodeurl@2.0.0: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + es-toolkit@1.49.0: {} esbuild@0.28.1: @@ -5637,6 +6077,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -5719,8 +6161,54 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} fast-check@4.8.0: @@ -5733,6 +6221,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -5762,6 +6252,17 @@ snapshots: dependencies: flat-cache: 4.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5782,9 +6283,15 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gaxios@7.1.5: dependencies: extend: 3.0.2 @@ -5801,6 +6308,24 @@ snapshots: transitivePeerDependencies: - supports-color + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -5828,10 +6353,20 @@ snapshots: google-logging-utils@1.1.3: {} + gopd@1.2.0: {} + hachure-fill@0.5.2: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.28: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0: @@ -5842,6 +6377,14 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -5860,6 +6403,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5870,10 +6417,16 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internmap@1.0.1: {} internmap@2.0.3: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -5882,6 +6435,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -5899,6 +6454,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -5948,6 +6505,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jwa@2.0.1: @@ -6118,6 +6679,8 @@ snapshots: marked@16.4.2: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -6222,6 +6785,10 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -6437,6 +7004,12 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -6449,6 +7022,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -6457,8 +7032,20 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.3: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -6539,6 +7126,8 @@ snapshots: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + partial-json@0.1.7: {} path-data-parser@0.1.0: {} @@ -6549,12 +7138,16 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -6584,6 +7177,11 @@ snapshots: '@types/node': 25.9.3 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -6595,8 +7193,22 @@ snapshots: pure-rand@8.4.0: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@1.0.0: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + readdirp@4.1.2: {} require-from-string@2.0.2: {} @@ -6672,6 +7284,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rw@1.3.3: {} sade@1.8.1: @@ -6693,12 +7315,67 @@ snapshots: semver@7.8.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} smol-toml@1.6.1: {} @@ -6707,6 +7384,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} strip-json-comments@5.0.3: {} @@ -6742,6 +7421,8 @@ snapshots: dependencies: tldts-core: 7.4.5 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.4.5 @@ -6803,6 +7484,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typebox@1.1.38: {} typescript-eslint@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): @@ -6848,12 +7535,16 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 uuid@14.0.1: {} + vary@1.1.2: {} + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: debug: 4.4.3 @@ -6939,6 +7630,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index eca36286ae..d260e7110b 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -537,6 +537,15 @@ function walkSchemaExpr( } return } + // A union of objects (discriminated union config): collect keys from all + // variants. Each variant is visited the same way as an intersect element. + if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) { + for (const el of call.arguments[0].elements) { + const part = unwrapExpr(el) + if (ts.isCallExpression(part)) { visit(part); continue } + } + return + } // A chained refinement (`z.object({…}).default(…)` etc.): the keys live on // the call the chain hangs off — keep unwrapping toward it. const base = unwrapExpr(call.expression.expression) diff --git a/tsconfig.base.json b/tsconfig.base.json index 40e4dbe728..7cbb3acab2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,7 @@ "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", + "./packages/mcp/*/src", "./packages/support/*/src" ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index b6ba7901f2..1213898eec 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -54,6 +54,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, - { "path": "./packages/hooks/hooks-codex" } + { "path": "./packages/hooks/hooks-codex" }, + { "path": "./packages/mcp/mcp-client" } ] } diff --git a/tsconfig.json b/tsconfig.json index 9cd7aa8a6d..77070ea044 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -65,6 +65,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, - { "path": "./packages/hooks/hooks-codex" } + { "path": "./packages/hooks/hooks-codex" }, + { "path": "./packages/mcp/mcp-client" } ] } From f38111e5ca02c8723a68f4a7199f49a2945a1ac2 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 11:58:12 +0800 Subject: [PATCH 02/33] fix: handle MCP transport disconnect and concurrent tool re-sync - Add client.onclose handler to unregister tools when the MCP server disconnects (crash or intentional close) - Replace bare `let disposers` with a shared mutable state object so the effect disposer and notification handler always reference the current generation - Serialize tools/list_changed resyncs with latest-wins coalescing (syncing + pendingResync flags) to prevent concurrent races --- packages/mcp/mcp-client/src/index.ts | 64 ++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index da3aefc3de..2e797ec199 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -86,6 +86,16 @@ export const Config = z.union([ // ---- Plugin apply ---- +/** Mutable state shared between the async connect path, notification handler, and disposers. */ +interface PluginState { + /** Current generation of tool disposers (keyed by registered name). */ + disposers: Map void> + /** Whether a syncTools call is currently in-flight. */ + syncing: boolean + /** Whether another tools/list_changed arrived while syncing (coalesce flag). */ + pendingResync: boolean +} + export function apply(ctx: Context, config: Config): void { const transport = createTransport(config) const client = new Client( @@ -93,36 +103,62 @@ export function apply(ctx: Context, config: Config): void { { capabilities: {} }, ) + const state: PluginState = { disposers: new Map(), syncing: false, pendingResync: false } + + const opts = { toolPrefix: config.toolPrefix, toolCallTimeoutMs: config.toolCallTimeoutMs } + + /** Dispose all currently registered tools. */ + function disposeTools(): void { + for (const dispose of state.disposers.values()) dispose() + state.disposers = new Map() + } + + /** Run syncTools with latest-wins coalescing. */ + async function resync(): Promise { + if (state.syncing) { + state.pendingResync = true + return + } + state.syncing = true + try { + state.disposers = await syncTools(client, ctx, opts, state.disposers) + } finally { + state.syncing = false + } + // If another notification arrived while we were syncing, run once more. + if (state.pendingResync) { + state.pendingResync = false + await resync() + } + } + + // When the connection closes (server crash or intentional close), unregister + // all tools so the model no longer sees them in the system prompt. + client.onclose = () => { + disposeTools() + ctx.logger.info('mcp-client: connection closed, tools unregistered') + } + // Connect and set up tools. Errors during connect are logged, not thrown // (the plugin simply has no tools registered). const ready = (async () => { await client.connect(transport) - - let disposers = await syncTools(client, ctx, { - toolPrefix: config.toolPrefix, - toolCallTimeoutMs: config.toolCallTimeoutMs, - }, new Map()) + await resync() client.setNotificationHandler( ToolListChangedNotificationSchema, async () => { ctx.logger.info('mcp-client: tool list changed, re-syncing') - disposers = await syncTools(client, ctx, { - toolPrefix: config.toolPrefix, - toolCallTimeoutMs: config.toolCallTimeoutMs, - }, disposers) + await resync() }, ) - - return disposers })().catch((error: unknown) => { ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) - return new Map void>() }) + // Fiber disposal: close the client (triggers onclose → tools unregistered). ctx.effect(() => async () => { - const disposers = await ready - for (const dispose of disposers.values()) dispose() + await ready try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') } From 2efe8ad418e668b7cbe559a65901e4921fc6fabe Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 12:18:59 +0800 Subject: [PATCH 03/33] test: cover resync coalescing, onclose, and error path in mcp-client Addresses CI coverage gap: exercises the latest-wins resync coalescing (pendingResync branch), the client.onclose callback, and ensures index.ts is loaded without module mocks for stable v8 coverage across environments. --- packages/mcp/mcp-client/tests/apply.spec.ts | 51 +++++++++++++++++++ .../mcp/mcp-client/tests/mcp-client.spec.ts | 36 ++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 077cb4e3f6..baf1a4985c 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -22,6 +22,7 @@ class MockClient { listTools = mockListTools callTool = mockCallTool setNotificationHandler = mockSetNotificationHandler + onclose: (() => void) | null = null } vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ @@ -176,4 +177,54 @@ describe('apply (plugin lifecycle)', () => { expect(mockConnect).toHaveBeenCalled() expect(ctx.tools.get('remote')).toBeDefined() }) + + it('coalesces overlapping resync notifications (latest-wins)', async () => { + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + // Initial sync is done; notification handler is registered. + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + + // Make the NEXT listTools call slow so we can trigger a second notification. + let resolveBlocked!: (v: unknown) => void + mockListTools.mockReturnValueOnce(new Promise((r) => { resolveBlocked = r })) + + // Fire first notification — starts a resync that blocks on listTools. + const firstResync = handler() + + // Fire second notification while the first is in-flight — should coalesce. + const secondResync = handler() + + // Resolve the blocked listTools call. + resolveBlocked({ tools: [{ name: 'mid', inputSchema: { type: 'object' } }], nextCursor: undefined }) + + // Set up the response for the deferred resync that fires after the first completes. + mockListTools.mockResolvedValueOnce({ + tools: [{ name: 'final', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + + await firstResync + await secondResync + await new Promise(r => setTimeout(r, 50)) + + // The deferred resync should have run with the latest tool list. + expect(ctx.tools.get('final')).toBeDefined() + }) + + it('unregisters tools when the server connection closes (onclose)', async () => { + apply(ctx, stdioConfig) + await new Promise(r => setTimeout(r, 50)) + + expect(ctx.tools.get('remote')).toBeDefined() + + // Simulate the MCP client's onclose firing (server crashed or closed). + // The apply() sets `client.onclose = () => {...}` on the mock instance. + // mockConnect receives `this` as the client instance. + const clientInstance = mockConnect.mock.contexts[0] as MockClient + expect(clientInstance.onclose).toBeTypeOf('function') + clientInstance.onclose!() + + expect(ctx.tools.get('remote')).toBeUndefined() + }) }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index e81369c0f0..5f5a9bf2f4 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' -import type { Config } from '@deepseek-ai/dsh-mcp-client' +import { apply, name, inject, Config } from '@deepseek-ai/dsh-mcp-client/src/index.ts' // ---- Mock MCP Client ---- @@ -516,3 +516,37 @@ describe('tool execution — non-object args fallback', () => { }) }) +describe('plugin module exports', () => { + it('exports name, inject, and Config schema', () => { + expect(name).toBe('mcp-client') + expect(inject).toEqual(['tools']) + expect(Config).toBeDefined() + }) +}) + +describe('apply (error path, no mocks)', () => { + it('gracefully catches when the MCP server is unreachable', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + + // Call apply with a command that will fail to spawn/connect. + // The .catch() inside apply logs the error and registers no tools. + apply(ctx, { + transport: 'stdio', + command: '___nonexistent_binary_that_will_fail___', + args: [], + env: {}, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 1000, + }) + + // Give the async connect + catch chain time to settle. + await new Promise(r => setTimeout(r, 200)) + + // No tools should be registered since connect failed. + expect(ctx.tools.get('anything')).toBeUndefined() + }) +}) + From c65e05cff168e657cb28b365ef7936d0b6cd30c5 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 12:56:04 +0800 Subject: [PATCH 04/33] test: add MCP client e2e tests with real MCP servers Prove the full MCP protocol flow works end-to-end against real servers: - Self-written fixture server: tool discovery, execution, error handling, image placeholder, toolPrefix, and clean disposal - @modelcontextprotocol/server-everything: echo, get-sum, get-tiny-image - @modelcontextprotocol/server-filesystem: write_file + read_file round-trip, list_directory with world-verification All 15 tests keyless and deterministic (no API key needed). --- packages/mcp/mcp-client/package.json | 5 +- .../mcp/mcp-client/tests/fixture-server.ts | 55 +++ .../mcp/mcp-client/tests/mcp-client.e2e.ts | 318 ++++++++++++++++ pnpm-lock.yaml | 341 ++++++++++++++++++ 4 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/mcp-client/tests/fixture-server.ts create mode 100644 packages/mcp/mcp-client/tests/mcp-client.e2e.ts diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 69626cc606..638777017d 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -33,6 +33,9 @@ "devDependencies": { "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@modelcontextprotocol/server-everything": "^2026.7.4", + "@modelcontextprotocol/server-filesystem": "^2026.7.4", + "cordis": "^4.0.0-rc.6", + "zod": "^4.4.3" } } diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts new file mode 100644 index 0000000000..6b97c2b59c --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -0,0 +1,55 @@ +/** + * Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin. + * Registers controlled tools with predictable behavior for asserting edge cases. + * + * Run: node --import tsx fixture-server.ts + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' + +const server = new McpServer( + { name: 'fixture-server', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } }, +) + +server.registerTool('add', { + title: 'Add Tool', + description: 'Adds two numbers.', + inputSchema: { a: z.number().describe('First number'), b: z.number().describe('Second number') }, +}, async args => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], +})) + +server.registerTool('greet', { + title: 'Greet Tool', + description: 'Greets a person by name.', + inputSchema: { name: z.string().describe('Name to greet') }, +}, async args => ({ + content: [{ type: 'text', text: `Hello, ${args.name}!` }], +})) + +server.registerTool('fail', { + title: 'Fail Tool', + description: 'Always returns an error.', + inputSchema: {}, +}, async () => ({ + content: [{ type: 'text', text: 'Something went wrong' }], + isError: true, +})) + +server.registerTool('image', { + title: 'Image Tool', + description: 'Returns an image content block.', + inputSchema: {}, +}, async () => ({ + content: [ + { type: 'text', text: 'Here is an image:' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'text', text: 'End of image.' }, + ], +})) + +const transport = new StdioServerTransport() +await server.connect(transport) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts new file mode 100644 index 0000000000..d59ba59d66 --- /dev/null +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -0,0 +1,318 @@ +/** + * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol over + * stdio transport against: + * 1. A self-written fixture server (controlled edge cases) + * 2. @modelcontextprotocol/server-everything (official integration test server) + * 3. @modelcontextprotocol/server-filesystem (real filesystem operations) + * + * No API key needed — all servers are local/keyless. + */ + +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +// Resolve package-local .bin for pnpm-hoisted MCP server binaries. +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const localBin = join(packageDir, 'node_modules', '.bin') + +// ---- Helpers ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +/** Apply the MCP client plugin and wait for tools to be registered. */ +async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise { + const { apply } = await import('@deepseek-ai/dsh-mcp-client/src/index.ts') + const toolsReady = new Promise((resolve, reject) => { + const timer = setTimeout( + () => { reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, + timeoutMs, + ) + ctx.on('tools/change', () => { clearTimeout(timer); resolve() }) + }) + apply(ctx, config) + await toolsReady +} + +let callSeq = 0 +function nextCallId(): CallId { + return CallId(`e2e-${++callSeq}`) +} + +// ---- Fixture server tests ---- + +describe('fixture server — controlled scenarios', () => { + let ctx: Context + + const fixtureConfig: Config = { + transport: 'stdio', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolPrefix: '', + toolCallTimeoutMs: 15_000, + } + + beforeAll(async () => { + ctx = await mountRegistry() + await applyAndWait(ctx, fixtureConfig) + }, 30_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 200)) + }) + + it('discovers all fixture tools', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('add') + expect(names).toContain('greet') + expect(names).toContain('fail') + expect(names).toContain('image') + }) + + it('executes add(2, 3) → "5"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'add', arguments: { a: 2, b: 3 }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: '5' }) + }) + + it('executes greet("World") → "Hello, World!"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'greet', arguments: { name: 'World' }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' }) + }) + + it('executes fail() → isError result', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'fail', arguments: {}, + }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ type: 'text' }) + }) + + it('executes image() → image placeholder', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'image', arguments: {}, + }) + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: string; text: string }).text + expect(text).toContain('Here is an image:') + expect(text).toContain('[image: image/png, content discarded]') + expect(text).toContain('End of image.') + }) +}) + +describe('fixture server — toolPrefix', () => { + let ctx: Context + + beforeAll(async () => { + ctx = await mountRegistry() + await applyAndWait(ctx, { + transport: 'stdio', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolPrefix: 'fx_', + toolCallTimeoutMs: 15_000, + }) + }, 30_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 200)) + }) + + it('registers tools with prefix', () => { + expect(ctx.tools.get('fx_add')).toBeDefined() + expect(ctx.tools.get('fx_greet')).toBeDefined() + expect(ctx.tools.get('add')).toBeUndefined() + }) + + it('executes prefixed tool', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'fx_add', arguments: { a: 10, b: 20 }, + }) + expect(result.content[0]).toEqual({ type: 'text', text: '30' }) + }) +}) + +describe('fixture server — disposal', () => { + it('disposes cleanly without error', async () => { + const ctx = await mountRegistry() + await applyAndWait(ctx, { + transport: 'stdio', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolPrefix: '', + toolCallTimeoutMs: 15_000, + }) + + // Tools are registered before dispose. + expect(ctx.tools.get('add')).toBeDefined() + expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4) + + // Dispose should complete without throwing. + await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 200)) + }, 30_000) +}) + +// ---- @modelcontextprotocol/server-everything ---- + +describe('server-everything — official test server', () => { + let ctx: Context + + const config: Config = { + transport: 'stdio', + command: join(localBin, 'mcp-server-everything'), + args: ['stdio'], + env: {}, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 30_000, + } + + beforeAll(async () => { + ctx = await mountRegistry() + await applyAndWait(ctx, config) + }, 60_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 500)) + }) + + it('discovers tools from server-everything', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('echo') + expect(names).toContain('get-sum') + expect(names).toContain('get-tiny-image') + expect(names.length).toBeGreaterThanOrEqual(8) + }) + + it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'echo', arguments: { message: 'hello' }, + }) + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: string; text: string }).text + expect(text).toBe('Echo: hello') + }) + + it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'get-sum', arguments: { a: 3, b: 7 }, + }) + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: string; text: string }).text + expect(text).toContain('10') + }) + + it('executes get-tiny-image → image placeholder', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'get-tiny-image', arguments: {}, + }) + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: string; text: string }).text + expect(text).toContain('[image: image/png, content discarded]') + }) +}) + +// ---- @modelcontextprotocol/server-filesystem ---- + +describe('server-filesystem — real filesystem operations', () => { + let ctx: Context + let tempDir: string + + beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-')) + + ctx = await mountRegistry() + const config: Config = { + transport: 'stdio', + command: join(localBin, 'mcp-server-filesystem'), + args: [tempDir], + env: {}, + cwd: '', + toolPrefix: '', + toolCallTimeoutMs: 30_000, + } + await applyAndWait(ctx, config) + }, 60_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await new Promise(r => setTimeout(r, 500)) + await rm(tempDir, { recursive: true, force: true }) + }) + + it('discovers filesystem tools', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('read_file') + expect(names).toContain('write_file') + expect(names).toContain('list_directory') + }) + + it('write_file + read_file round-trip', async () => { + const filePath = join(tempDir, 'test.txt') + const content = 'Hello from MCP e2e test!' + + // Write via MCP tool + const writeResult = await ctx.tools.execute({ + callId: nextCallId(), name: 'write_file', arguments: { path: filePath, content }, + }) + expect(writeResult.isError).toBe(false) + + // Verify file was actually written (world verification) + const onDisk = await readFile(filePath, 'utf8') + expect(onDisk).toBe(content) + + // Read back via MCP tool + const readResult = await ctx.tools.execute({ + callId: nextCallId(), name: 'read_file', arguments: { path: filePath }, + }) + expect(readResult.isError).toBe(false) + const text = (readResult.content[0] as { type: string; text: string }).text + expect(text).toContain(content) + }) + + it('list_directory shows written file', async () => { + // Ensure a file exists + await writeFile(join(tempDir, 'listed.txt'), 'listed') + + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'list_directory', arguments: { path: tempDir }, + }) + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: string; text: string }).text + expect(text).toContain('listed.txt') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c50782144..d12ec0d9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,9 +526,18 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@modelcontextprotocol/server-everything': + specifier: ^2026.7.4 + version: 2026.7.4 + '@modelcontextprotocol/server-filesystem': + specifier: ^2026.7.4 + version: 2026.7.4(zod@4.4.3) cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/session-persistence/session-persistence: devDependencies: @@ -1723,6 +1732,10 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1752,6 +1765,14 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server-everything@2026.7.4': + resolution: {integrity: sha512-ydMW/M6rk9tK23b+U38trsNLHhd5eF+ntiv2Vr+RPMDhbiKY/IKrZU25ukvSXVPUBvy7TxTPWpeV4KcYcXg72w==} + hasBin: true + + '@modelcontextprotocol/server-filesystem@2026.7.4': + resolution: {integrity: sha512-JwEaH4dRRzwcNMwX8WJVCJyXfFxXjFKdgwHxjQhFLhi02kszgyyj611LV9puBLDO1IiDQSCjfKFSPaemegnvwg==} + hasBin: true + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -1997,6 +2018,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2558,6 +2583,22 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2579,6 +2620,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2602,6 +2646,9 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -2639,6 +2686,13 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -2682,6 +2736,9 @@ packages: '@cordisjs/plugin-loader': optional: true + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2909,6 +2966,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + diff@9.0.0: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} @@ -2929,12 +2990,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -3121,6 +3191,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3173,6 +3247,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -3245,6 +3324,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -3278,6 +3360,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3288,6 +3374,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3303,6 +3392,9 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3356,6 +3448,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -3441,6 +3536,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3528,6 +3626,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -3697,6 +3798,14 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3779,9 +3888,15 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3807,6 +3922,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -3838,6 +3957,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + protobufjs@7.6.4: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} @@ -3873,6 +3995,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3934,6 +4059,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3960,6 +4088,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3990,6 +4121,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -4008,6 +4143,25 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -4192,6 +4346,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -4327,6 +4484,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4930,6 +5095,15 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4979,6 +5153,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server-everything@2026.7.4': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + cors: 2.8.6 + express: 5.2.1 + jszip: 3.10.1 + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + + '@modelcontextprotocol/server-filesystem@2026.7.4(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + diff: 8.0.4 + glob: 10.5.0 + minimatch: 10.2.5 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - zod + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5124,6 +5320,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -5683,6 +5882,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + ansis@4.3.1: {} anynum@1.0.0: {} @@ -5703,6 +5912,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -5731,6 +5942,10 @@ snapshots: bowser@2.14.1: {} + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5761,6 +5976,12 @@ snapshots: dependencies: readdirp: 4.1.2 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -5793,6 +6014,8 @@ snapshots: '@cordisjs/plugin-include': link:vendor/include '@cordisjs/plugin-loader': link:vendor/loader + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -6042,6 +6265,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + diff@9.0.0: {} dompurify@3.4.11: @@ -6058,12 +6283,18 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + empathic@2.0.1: {} encodeurl@2.0.0: {} @@ -6309,6 +6540,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -6372,6 +6608,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globrex@0.1.2: {} google-auth-library@10.7.0: @@ -6445,6 +6690,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} @@ -6463,6 +6710,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -6471,6 +6720,8 @@ snapshots: is-promise@4.0.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6486,6 +6737,12 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} jose@6.2.3: {} @@ -6545,6 +6802,13 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -6634,6 +6898,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -6693,6 +6961,8 @@ snapshots: longest-streak@3.1.0: {} + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} magic-string@0.30.21: @@ -7048,6 +7318,12 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minipass@7.1.3: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -7154,8 +7430,12 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -7172,6 +7452,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -7197,6 +7482,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7243,6 +7530,16 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} require-from-string@2.0.2: {} @@ -7334,6 +7631,8 @@ snapshots: dependencies: mri: 1.2.0 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -7374,6 +7673,8 @@ snapshots: transitivePeerDependencies: - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -7412,6 +7713,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + smol-toml@1.6.1: {} source-map-js@1.2.1: {} @@ -7422,6 +7725,30 @@ snapshots: std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -7577,6 +7904,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + uuid@14.0.1: {} vary@1.1.2: {} @@ -7710,6 +8039,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} From fdd7d1a91cb8430435f05f7e104a2779b99ab41d Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 13:01:00 +0800 Subject: [PATCH 05/33] chore: register mcp-client e2e entries in knip config Add the mcp-client workspace override so knip recognises the e2e test file, fixture-server entry, and the bin-only devDeps (server-everything, server-filesystem) that are invoked at runtime rather than imported. --- knip.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/knip.json b/knip.json index 8c0f71f3af..61ac9b07af 100644 --- a/knip.json +++ b/knip.json @@ -69,6 +69,11 @@ "packages/fs/tool-fs": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/mcp/mcp-client": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"] } } } From 0c8f2f7dafcf8d5919ea732fda65ebb219531a92 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 13:23:46 +0800 Subject: [PATCH 06/33] fix: prevent partial tool leaks and non-blocking dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncTools: on paginated listTools failure, unregister any tools already registered in the current sync before rethrowing (prevents orphans) - Effect disposer: call client.close() directly without awaiting startup completion — aborts a hanging connect promptly on HMR/dispose --- packages/mcp/mcp-client/src/index.ts | 12 ++++--- packages/mcp/mcp-client/src/tools.ts | 47 ++++++++++++++++------------ 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 2e797ec199..a1f0fb1232 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -140,8 +140,9 @@ export function apply(ctx: Context, config: Config): void { } // Connect and set up tools. Errors during connect are logged, not thrown - // (the plugin simply has no tools registered). - const ready = (async () => { + // (the plugin simply has no tools registered). The IIFE is fire-and-forget; + // disposal closes the client directly without waiting for startup. + void (async () => { await client.connect(transport) await resync() @@ -156,9 +157,10 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) }) - // Fiber disposal: close the client (triggers onclose → tools unregistered). + // Fiber disposal: close the client immediately (triggers onclose → tools + // unregistered). No `await ready` — if connect is still pending, close aborts + // it promptly rather than blocking until the SDK request times out. ctx.effect(() => async () => { - await ready - try { await client.close() } catch { /* transport already gone */ } + try { await client.close() } catch { /* transport already gone or never connected */ } }, 'mcp-client.connection') } diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index c3a35ccfba..8076a4a764 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -43,27 +43,34 @@ export async function syncTools( const disposers: ToolDisposers = new Map() - let cursor: string | undefined - do { - const response = await client.listTools(cursor ? { cursor } : undefined) - for (const tool of response.tools) { - const registeredName = opts.toolPrefix + tool.name - const definition: ToolDefinition = { - name: registeredName, - description: tool.description ?? '', - parameters: tool.inputSchema, - execute: createExecutor(client, tool.name, opts), + try { + let cursor: string | undefined + do { + const response = await client.listTools(cursor ? { cursor } : undefined) + for (const tool of response.tools) { + const registeredName = opts.toolPrefix + tool.name + const definition: ToolDefinition = { + name: registeredName, + description: tool.description ?? '', + parameters: tool.inputSchema, + execute: createExecutor(client, tool.name, opts), + } + try { + const dispose = ctx.tools.register(definition) + disposers.set(registeredName, dispose) + } catch { + // Name conflict — another tool with this name is already registered. + ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) + } } - try { - const dispose = ctx.tools.register(definition) - disposers.set(registeredName, dispose) - } catch { - // Name conflict — another tool with this name is already registered. - ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) - } - } - cursor = response.nextCursor - } while (cursor) + cursor = response.nextCursor + } while (cursor) + } catch (error: unknown) { + // Partial failure (e.g. a later page of listTools failed): unregister any + // tools already registered in this sync to avoid orphaning them. + for (const dispose of disposers.values()) dispose() + throw error + } return disposers } From 418b259a11843576fa97f6f7342fcfc489116eac Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 13:23:46 +0800 Subject: [PATCH 07/33] fix: prevent partial tool leaks and non-blocking dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncTools: on paginated listTools failure, unregister any tools already registered in the current sync before rethrowing (prevents orphans) - Effect disposer: call client.close() directly without awaiting startup completion — aborts a hanging connect promptly on HMR/dispose --- packages/mcp/mcp-client/tests/mcp-client.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 5f5a9bf2f4..253cf49d87 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -119,6 +119,18 @@ describe('syncTools', () => { expect(secondDisposers.size).toBe(1) }) + it('cleans up already-registered tools when a later page fails', async () => { + const client = createMockClient([]) + client.listTools + .mockResolvedValueOnce({ tools: [{ name: 'survives_not', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) + .mockRejectedValueOnce(new Error('page 2 network error')) + + await expect(syncTools(client as never, ctx, defaultOpts, new Map())).rejects.toThrow('page 2 network error') + + // The tool from page 1 was registered then cleaned up on failure. + expect(ctx.tools.get('survives_not')).toBeUndefined() + }) + it('drains paginated listTools responses', async () => { const client = createMockClient([]) client.listTools From a1a78ae30ae73758d57fddfe61803f4302b9eaba Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 13:23:46 +0800 Subject: [PATCH 08/33] fix: prevent partial tool leaks and non-blocking dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncTools: on paginated listTools failure, unregister any tools already registered in the current sync before rethrowing (prevents orphans) - Effect disposer: call client.close() directly without awaiting startup completion — aborts a hanging connect promptly on HMR/dispose --- packages/mcp/mcp-client/src/index.ts | 4 +- packages/mcp/mcp-client/src/tools.ts | 92 +++++++++++-------- .../mcp/mcp-client/tests/mcp-client.spec.ts | 30 +++++- 3 files changed, 84 insertions(+), 42 deletions(-) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index a1f0fb1232..c0800b7618 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -73,14 +73,14 @@ export const Config = z.union([ env: z.dict(String).default({}), cwd: z.string().default(''), toolPrefix: z.string().default(''), - toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + toolCallTimeoutMs: z.natural().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), z.object({ transport: z.const('streamable-http'), url: z.string().required(), headers: z.dict(String).default({}), toolPrefix: z.string().default(''), - toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + toolCallTimeoutMs: z.natural().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), ]) as unknown as z diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 8076a4a764..e4e9691d88 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -18,19 +18,24 @@ export interface ToolBridgeOptions { /** State for one sync generation: the current set of disposers keyed by tool name. */ type ToolDisposers = Map void> +/** A tool fetched from the MCP server, pending registration. */ +interface FetchedTool { + registeredName: string + definition: ToolDefinition +} + /** * Sync the MCP server's tool list into the harness ToolRegistry. * - * - Calls `client.listTools()` (paginated: drains all pages). - * - Registers each tool as a raw `ToolDefinition`. - * - On name conflict: logs a warning and skips that tool. - * - Returns a disposer map; call each value to unregister. + * Two-phase approach: fetch all pages first (no side effects), then dispose old + * tools and register new ones. If fetching fails, the previous generation stays + * intact — no tools are lost on a transient listTools failure. * * @param client - Connected MCP Client instance used to list and call tools. * @param ctx - Cordis context providing the `tools` service for registration. * @param opts - Bridge options: tool name prefix and per-call timeout. - * @param previous - Disposer map from a prior sync generation; all entries are - * disposed before re-registering. + * @param previous - Disposer map from a prior sync generation; disposed only + * after all pages are successfully fetched. * @returns A map of registered tool names to their unregister disposers. */ export async function syncTools( @@ -39,37 +44,38 @@ export async function syncTools( opts: ToolBridgeOptions, previous: ToolDisposers, ): Promise { - for (const dispose of previous.values()) dispose() - - const disposers: ToolDisposers = new Map() - - try { - let cursor: string | undefined - do { - const response = await client.listTools(cursor ? { cursor } : undefined) - for (const tool of response.tools) { - const registeredName = opts.toolPrefix + tool.name - const definition: ToolDefinition = { + // Phase 1: fetch all tools (no mutations). + const fetched: FetchedTool[] = [] + let cursor: string | undefined + do { + const response = await client.listTools(cursor ? { cursor } : undefined) + for (const tool of response.tools) { + const registeredName = opts.toolPrefix + tool.name + fetched.push({ + registeredName, + definition: { name: registeredName, description: tool.description ?? '', parameters: tool.inputSchema, execute: createExecutor(client, tool.name, opts), - } - try { - const dispose = ctx.tools.register(definition) - disposers.set(registeredName, dispose) - } catch { - // Name conflict — another tool with this name is already registered. - ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) - } - } - cursor = response.nextCursor - } while (cursor) - } catch (error: unknown) { - // Partial failure (e.g. a later page of listTools failed): unregister any - // tools already registered in this sync to avoid orphaning them. - for (const dispose of disposers.values()) dispose() - throw error + }, + }) + } + cursor = response.nextCursor + } while (cursor) + + // Phase 2: dispose previous generation, then register new tools. + // If we reach here, all pages were fetched successfully. + for (const dispose of previous.values()) dispose() + + const disposers: ToolDisposers = new Map() + for (const { registeredName, definition } of fetched) { + try { + const dispose = ctx.tools.register(definition) + disposers.set(registeredName, dispose) + } catch { + ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) + } } return disposers @@ -129,14 +135,21 @@ function createExecutor( // with optional fallbacks). // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const content: McpContentBlock[] = result.content - const text = extractText(content, mcpToolName) + let text = extractText(content, mcpToolName) + + // MCP tools with outputSchema may return structuredContent with an empty + // content array. Surface the structured payload as JSON so the model sees + // the actual result. + if (!text && 'structuredContent' in result && result.structuredContent != null) { + text = JSON.stringify(result.structuredContent) + } // MCP isError → throw so ToolRegistry produces an isError result for the model. if ('isError' in result && result.isError === true) { - throw new Error(text) + throw new Error(text || 'MCP tool error') } - return [{ type: 'text', text }] + return [{ type: 'text', text: text || `(${mcpToolName} returned no content)` }] } } @@ -147,8 +160,11 @@ function createExecutor( * * Defensive: fields that the MCP spec declares required (mimeType, text) are * guarded with fallbacks because this is a network trust boundary. + * + * Returns empty string when no text parts were extracted (caller decides + * fallback — e.g. structuredContent). */ -function extractText(mcpContent: McpContentBlock[], toolName: string): string { +function extractText(mcpContent: McpContentBlock[], _toolName: string): string { const parts: string[] = [] for (const block of mcpContent) { @@ -171,5 +187,5 @@ function extractText(mcpContent: McpContentBlock[], toolName: string): string { } } - return parts.join('\n') || `(${toolName} returned no text content)` + return parts.join('\n') } diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 253cf49d87..8d17f3c64d 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -326,7 +326,7 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no content)' }) }) it('handles empty content array', async () => { @@ -338,10 +338,36 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no content)' }) }) + it('uses fallback error message when isError with empty content', async () => { + const client = createMockClient( + [{ name: 'empty_err', inputSchema: { type: 'object' } }], + { content: [], isError: true }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_err', arguments: {} }) + + expect(result.isError).toBe(true) + expect(result.content[0]).toEqual({ type: 'text', text: 'Error: MCP tool error' }) + }) + + it('surfaces structuredContent when content array is empty', async () => { + const client = createMockClient( + [{ name: 'structured', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({ content: [], structuredContent: { key: 'value', count: 42 } }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'structured', arguments: {} }) + + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value","count":42}' }) + }) + it('handles legacy toolResult with undefined value', async () => { const client = createMockClient( [{ name: 'legacy2', inputSchema: { type: 'object' } }], From 376d405ba48faf6a9f973d002d8456988d0addf8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:45:21 +0800 Subject: [PATCH 09/33] =?UTF-8?q?docs:=20rewrite=20the=20Code=20Mode=20RFC?= =?UTF-8?q?=20=E2=80=94=20registry-native=20mode=20over=20a=20worker-threa?= =?UTF-8?q?d=20code-runtime=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the add-on-plugin + node:vm-stub draft in place (still proposed/): code mode becomes a first-class ToolRegistry presentation mode ('native' | 'code' | 'both'), execution goes behind a new ctx.codeRuntime capability seam whose shipped backend is one fresh Node worker thread per run (type-strip, empty env, resource limits, hard terminate), at bash-equivalent trust with no unsafe-flag ceremony. Renames the file to 2026-06-15-code-mode.md and regenerates the RFC index. --- docs/rfc/INDEX.md | 2 +- .../proposed/feature/2026-06-15-code-mode.md | 137 ++++++++++++++++++ .../feature/2026-06-15-optional-code-mode.md | 119 --------------- 3 files changed, 138 insertions(+), 120 deletions(-) create mode 100644 docs/rfc/proposed/feature/2026-06-15-code-mode.md delete mode 100644 docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 2a496e469b..df5cf938c5 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -10,7 +10,7 @@ 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 | -| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | +| [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 | | [MCP client plugin — connect to external MCP servers and bridge their tools](proposed/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 | diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md new file mode 100644 index 0000000000..d868c41faf --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -0,0 +1,137 @@ +# RFC: Code Mode — the model writes TypeScript against the tool registry + +Status: proposed + +## Problem + +Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. + +For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. + +Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. + +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 + +Three decisions, each elaborated in its own section below: + +1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (today's behavior, the default), `'code'` (the wire carries exactly one tool, `run_code`, plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas *and* `run_code` + SDK). The registry's existing tool-schema provider contributes whatever the mode dictates, so the wire tool list is shaped at its source — no interception, no listener-ordering caveats — and the logged request header records it for free. +2. **Code execution is a capability seam** — a new group `packages/code-runtime/` with the interface package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is a new implementation package, not a redesign. +3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. + +### The registry owns the mode + +`ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. + +**Wire tool list = the provider's contribution.** The registry already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism, and there is no seam left where another party could accidentally re-add tools (the `system-prompt/assemble` waterfall can still deliberately transform the assembly, as ever). + +**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native tools rejects every assembly under `mode: 'code'` (those names are no longer contributed), by the existing fail-loud rule for unlisted names. This is correct behavior, not a bug: a deployment switching modes updates its order config or drops it. + +**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, at each assembly, a TypeScript declaration of every registered tool except `run_code` itself, plus fixed usage instructions. The thunk reads the live store and emits tools in lexicographic name order, so its output is deterministic and stable across steps — an unchanged tool set produces byte-identical text (prefix-cache-friendly; a mid-session registration surfaces as one logged header delta, exactly like a native-mode tool change). + +**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. + +### The run_code tool and the dispatch bridge + +Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). Its `execute(args, exec)`: + +1. **Builds the bindings**: for every registered tool except `run_code`, an async function that (a) checks `exec.signal?.aborted` before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) awaits its turn on the **per-run serialization queue** (below), (c) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: exec.signal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (d) appends a `tool/code-dispatch` session event, and (e) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. +2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. +3. **Surfaces the outcome**: a successful run returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. + +**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the *default*, while the tool contract still carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per-tool once tools can declare themselves concurrency-safe is deferred work, same as before. + +**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. + +### Observability: `tool/code-dispatch` + +Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }`. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. + +### The code-runtime seam + +`packages/code-runtime/code-runtime/` — `@deepseek-ai/dsh-code-runtime`, depending only on `cordis`. An abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) plus the vocabulary: + +- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` +- `CodeBindingNamespace = { global: string; functions: Record Promise> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does). +- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — an error is a field on a resolved result, never a rejection of `run()`. +- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` +- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. +- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). + +Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. + +### The worker-thread runtime + +`packages/code-runtime/code-runtime-worker/` — `@deepseek-ai/dsh-code-runtime-worker`. Per `run()`: + +1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; the repo floor is node ≥ 24, and the API is position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. +2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. +3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented). +4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. +5. **Enforce caps**: the compute-time budget (`timeoutMs`) ticks only while no binding call is pending — it bounds runaway worker compute, not tool latency — and expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap). Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config (`maxLogBytes`, `maxValueBytes`), truncation marked in-band. All caps are validated config fields with defaults (`timeoutMs: 60_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`. +6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../defensive-patterns.md). + +### Trust posture + +The worker runtime is **containment, not a security boundary**, and the RFC says so without ceremony. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env) — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool already grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor exists so such a deployment can tell backends apart. + +### What the model sees + +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 + +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: + +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, group README + package README + catalog updates. 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. Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, timeout vs abort vs worker-exit under OOM), compute-time-only timeout (a slow binding does not expire the run), binding bridge hostility cases (unknown name, duplicate id, post-settlement message), 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; [docs/architecture.md](../../../architecture.md) (tool-pipeline section + `ctx.codeRuntime` service row) 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. + +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. + +## Alternatives considered + +**An add-on consumer plugin, zero core changes (the previous draft of this RFC).** Rejected on both halves. The wire-collapse half aged out from under it: it targeted the `agent/request` waterfall, which [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) has since re-typed to call-config-only, and the surviving alternative — transforming the assembly a waterfall listener receives — is strictly worse than contributing the right list in the first place (transformation must undo `toolOrder` canonicalization it cannot see the config for, and its correctness depends on where it sits in a listener chain). The deeper reason is ownership: which tools the model is offered, in which representation, is the registry's single concern — `schemas()` for function calling and the SDK for Code Mode are two projections of one store, and splitting the second projection into a satellite package would preserve a boundary the domain does not have. + +**`node:vm` as the reference runtime, hardening deferred (also the previous draft).** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm), cannot interrupt a hot loop, and forced the draft into a two-flag unsafe ceremony plus a mandatory follow-up RFC. The worker thread delivers the missing properties now — separate isolate, empty env, `resourceLimits`, reliable `terminate()` (all verified by probe before this revision) — at bash-equivalent trust, so the reference implementation and the production one are the same package and the ceremony dissolves. + +**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s (now cheap to add as a logged surface replace, per the reconstructable-requests consequences) still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. + +**Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together. + +**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. + +**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred again, knowingly: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and every learning it depends on (how models actually split usage under `'both'`) arrives only after this ships. + +**Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine. + +**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. +- `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. +- Worker runtime: a hot `for(;;){}` run ends at the compute-time budget with `error.kind: 'timeout'`; a pending binding call does not consume that budget; 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. + +**`stripTypeScriptTypes` is marked experimental.** It is also what Node itself runs `.ts` files with on this repo's node floor. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end. + +**Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the RFC makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. + +**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. + +**Structured-clone limits at the binding boundary.** Tool bindings pass JSON-shaped arguments and return strings in the MVP, comfortably cloneable; the seam contract states the constraint so a future binding producer cannot discover it in production. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions. + +**Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. + +**Timer-pause bookkeeping.** Compute-time-only budgeting adds a small state machine (pause on RPC out, resume on reply) whose bugs would misattribute time. It is unit-tested from both sides (slow binding ≠ timeout; hot loop = timeout) and is still simpler than the alternative — one budget that spuriously kills programs for waiting on a slow tool. diff --git a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md deleted file mode 100644 index e221618ce1..0000000000 --- a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md +++ /dev/null @@ -1,119 +0,0 @@ -# RFC: Optional Code Mode — model writes TypeScript against an SDK of all tools - -Status: proposed - -> Premise partially stale: this proposal predates [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) — `agent/request` now shapes call config only (no request/content mutation), so the interception points named below need re-mapping onto the log channels and `system-prompt/assemble` before implementation. - -## Problem - -Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` feeds its schemas into `ctx.systemPrompt`, the loop puts them on `GenerateOptions.tools`, and the adapter serializes them to the provider's function-calling wire format. The model then invokes one `tool-call` block per step, the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), and **every** intermediate `tool-result` re-enters the model's context on the next request. - -For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each of those round-trips drags the entire intermediate result back into context whether the model needs it or not. - -Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) (shipped as the `@cloudflare/codemode` npm package) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated SDK that wraps all the tools, and that program is executed. The model curates what comes back — only what it `console.log`s and/or returns — instead of every intermediate result. The SDK functions are async, so the model can *express* fan-out (`Promise.all`) naturally in code; this RFC initially **serializes** those dispatches (§ Concurrency) until the tool contract grows concurrency-safety metadata, so the early win is composition and fewer round-trips, not parallelism. - -This RFC proposes an **optional** Code Mode for the DeepSeek Harness, covering **all** tools uniformly — built-in and future MCP — with no per-tool work, implemented Cordis-style with **zero core-package changes**. It fully specifies the code-execution seam and the SDK-generation pipeline, but ships only a minimal `node:vm` reference stub for execution; the hardened, sandboxed execution substrate is **deferred to a follow-up RFC** (see Risks). This RFC does not change the agent loop, and it leaves native tool-calling exactly as it is — Code Mode is a plugin you load, not a replacement. - -## Proposal - -The design follows the codebase's capability-seam pattern ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md), the `bash` template) as a three-package split, plus one consumer plugin. Nothing in `dsh-session`, `dsh-agent`, `dsh-agent-loop`, `dsh-llm`, `dsh-tools`, or `dsh-system-prompt` changes. - -**Prior art.** `@cloudflare/codemode` validates this shape directly and several of its decisions are adopted below. Its `Executor` interface is deliberately tiny — `execute(code, fns) → { result, error?, logs? }` — with a production `DynamicWorkerExecutor` (isolated Workers) and a six-line `NodeVMExecutor` example as two implementations behind it: exactly the interface/implementation split [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) prescribes. It generates TypeScript type definitions from tools for the model's context and runs the generated JavaScript in a sandbox, capturing console output alongside the return value. It normalizes model output into an async arrow function via AST parsing (acorn) and sanitizes tool names into valid JS identifiers (`my-tool` → `my_tool`, `delete` → `delete_`). It blocks outbound network by default. The transferable lessons — minimal executor contract, host-side type derivation, capture-output-and-return-value, name sanitization, AST-normalize the code, isolate by default — are folded into the design below. What does **not** transfer is the substrate: Cloudflare's isolation is Workers-specific; our equivalent hardened substrate is the deferred follow-up. - -**Prompt-budget tradeoff (Code Mode is not unconditionally cheaper).** Deriving the SDK types host-side costs no extra *discovery* round-trip, but the generated `.d.ts` is injected into the system prompt (§3a), so the type definitions themselves **do** consume context — and for an all-tools SDK that cost scales with every registered tool and can be comparable to, or larger than, the native JSON schemas it replaces. Code Mode's saving is on the **output/result** side (the model curates what comes back; intermediate results never re-enter context) and on **round-trips** (compose many calls in one program), not on the input-side tool description. The net win is workload-dependent: it pays off for multi-call, large-intermediate-result workflows and can cost *more* for a single call against a large tool surface. The `.d.ts` section is a prefix-stable prompt prefix, so prompt caching amortizes its per-turn cost across a session; the RFC notes that caching is what keeps the injected SDK affordable, and that a deployment with a very large tool surface should weigh the SDK size against native schemas rather than assume Code Mode is strictly cheaper. - -**1. Interface package `packages/code-runtime/`** — a new package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime`, depending only on `cordis`. It defines an abstract `CodeRuntime extends Service` plus the execution vocabulary. The runtime knows **nothing** about `ctx.tools`: it is handed a set of named async functions (the resolved SDK bindings), runs the program, and captures output. The result shape mirrors Cloudflare's proven-minimal contract so an error is a *field on a resolved result*, not a throw the runtime is expected to make: - -- `CodeRunRequest = { code: string; sdk: SdkBinding[]; signal?: AbortSignal }` -- `CodeRunResult = { result: unknown; logs: string[]; error?: string }` -- a readonly `safe: boolean` on the `CodeRuntime` service — `false` for an unsandboxed stub, `true` only for a real isolating substrate; consumers gate on it (§2). -- `SdkBinding = { namespace: string; fns: Record Promise> }` - -Per the "explicit > implicit at seams" convention, the request spells out every field the runtime acts on; defaulting (e.g. an output cap, a timeout derived from `signal`) is the implementation's explicit job, not a hidden `?? default` inside `run()`. The split into interface + implementation is justified under [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) because there is **genuinely more than one planned implementation** — the node:vm stub *and* the hardened substrate (a real isolate, or the generated program run as a sandboxed process through the existing `ctx.bash` seam) that is scheduled follow-up work, not speculative optionality. The capability-seam pattern warns against splitting preemptively when only one implementation is conceivable; here a second is not just conceivable but required before any untrusted use, so the seam earns its keep. - -**Backends can differ by language/runtime, not only by trust level.** The two implementations above (unsafe stub vs. hardened substrate) differ along the *trust* axis while staying TypeScript/JS, but nothing in the `CodeRuntime` contract — a program string plus a set of named async SDK bindings in, and a `{ result, logs, error? }` out — is bound to one source language. The same seam can host backends that differ along the *language* axis, executing a program written in something other than TypeScript. Two illustrative directions: - -- **An AssemblyScript backend.** AssemblyScript is a strict TypeScript subset that compiles to WebAssembly, so a program stays familiar to a TS-fluent model while the WASM boundary supplies exactly the sandboxing the hardened substrate is meant to provide — memory isolation and no ambient host authority come from the runtime rather than from after-the-fact hardening of `node:vm`. This is an appealing route to a `safe = true` backend. -- **A Python backend.** Python is arguably the model's most native language — it has seen far more real Python than any tool-calling trace — which is the same "LLMs write better code than tool calls" argument that motivates Code Mode, taken one step further. A Python backend is itself a sub-seam over different Python *runtimes*: **CPython** (in-process or a sandboxed subprocess via `ctx.bash`) for maximum fidelity and ecosystem access, or a more controllable / embeddable interpreter — Pyodide (CPython on WASM), RustPython, or a restricted embedded interpreter — when isolation, deterministic resource limits, or a clean capability boundary matter more than running arbitrary native extensions. - -These are illustrations of the seam's reach, **not commitments** — the MVP ships only the TypeScript path. The honest caveat is that the *execution* contract is language-agnostic but the *presentation* is not: the SDK-generation pipeline below (§3a and the `jsonSchemaToTs` codegen, which emits a TypeScript `.d.ts`) is TypeScript-specific, so a non-TS backend pairs the shared `CodeRuntime` contract with its own language-appropriate SDK generator and system-prompt section (a `.pyi` stub and Python usage instructions for the Python backend, AssemblyScript-flavored types for that one). The runtime seam is reused as-is; only the codegen/prompt half is per-language. - -**2. Implementation package `packages/code-runtime-vm/`** — a new package `@deepseek-ai/dsh-code-runtime-vm`, the `node:vm` reference stub. It type-erases the model's TypeScript via the compiler's `transpileModule` (or sucrase) — the types exist only to guide the model; the runtime is plain JS — then wraps the body in an async IIFE for top-level `await` (Cloudflare's `NodeVMExecutor` does literally `new AsyncFunction("codemode", "return await (${code})()")`), runs it in a `vm.Context` whose globals are a capturing `console` and the SDK namespace objects, awaits the IIFE, and captures the return value, the buffered logs, and any thrown error (as `error: string`). It applies an **output cap** (truncate captured logs) and a **timeout tied to `request.signal`**. These caps limit blast radius; **they are not a security boundary**. node:vm is **not** isolation: withholding `require`/`process` does not contain anything (code escapes via `constructor`/prototype reflection), and per [AGENTS.md](../../../../AGENTS.md) the harness must never hand model output the ambient environment. - -**The unsafe-runtime guard is enforceable, not a README warning.** Because a README caveat is not a control — and AGENTS.md's "never hand model output ambient authority" is a hard rule, not advice — the design makes the danger refuse to run by construction. Two layers: - -- **The runtime declares its trust level.** `CodeRuntime` carries a readonly `safe: boolean` (a `node:vm`-class stub returns `safe = false`; a real isolate/sandboxed-process substrate returns `safe = true`). The `code-runtime-vm` constructor *additionally* requires an explicit opt-in — `new VmCodeRuntime({ unsafe: true })` — and **throws** if that flag is absent, so merely depending on the package and wiring it cannot silently produce a live unsafe runtime; the operator must type the word `unsafe`. -- **The consumer refuses to expose `run_code` over an unsafe runtime by default.** When `code-mode` initializes, if `ctx.codeRuntime.safe === false` it does **not** register `run_code` unless the plugin itself is configured with an explicit acknowledgement (e.g. `code-mode` config `allowUnsafeRuntime: true`). Absent that, it logs a typed error and registers nothing — so a real model never reaches an unsandboxed runtime by a single config slip. The refusal path is tested: with the acknowledgement unset and an unsafe runtime, `run_code` is absent (and the wire tool list is unchanged from native); with both opt-ins set, it registers and runs. This keeps the unsafe reference backend usable for tests and trusted local demos while making production misuse take two deliberate, greppable flags rather than one mistake. - -`code-runtime-vm` is therefore documented as **reference / test-only / unsafe-for-untrusted-input**, acceptable in the MVP only because the code runs at harness trust *and* both opt-in flags must be set. Signal handling is best-effort: it aborts in-flight sub-dispatches but cannot reliably interrupt a hot synchronous loop (`while(true){}`) in node:vm — another reason the hardened substrate is deferred, not optional-forever. - -**3. Consumer plugin `packages/code-mode/`** — a new package `@deepseek-ai/dsh-code-mode`, the plugin that wires everything together. It declares `inject = ['tools', 'systemPrompt', 'codeRuntime']` — Cordis throws on access to a service that is not injected, and keeps the plugin inactive until all three exist (the same pattern as `tool-bash`'s `inject = ['tools', 'bash']`), which also gives correct load-ordering relative to `code-runtime`/`code-runtime-vm`. The plugin contributes four things, all through existing seams: - -**3a. Tool presentation — a lazy system-prompt section (the injection seam already exists).** `dsh-system-prompt` already provides the Cordis-idiomatic way for any plugin to inject prompt snippets: `ctx.systemPrompt.section({ name, order, text })`, fiber-scoped and auto-disposed via `ctx.effect()`, where `text` may be a lazy `() => string` re-evaluated at each assembly. No new mechanism is needed or invented. Code Mode registers a lazy section (high `order` so it lands last) whose thunk reads `ctx.tools.schemas()` at assembly time and regenerates the SDK `.d.ts` plus usage instructions from the currently-registered tool set. Because the thunk reads the live registry, coverage of every tool — built-in, MCP, future — is automatic. - -**3b. Wire tool-list enforcement — an `agent/request` listener (the authoritative seam).** The goal "exactly one tool reaches the wire" must be enforced where the wire request is finalized. The loop calls `ctx.systemPrompt.assemble()` first, *then* builds `GenerateOptions` (seeding `tools` from `assembly.tools`), *then* runs the `agent/request` waterfall, *then* calls `ctx.llm.stream()`. A `system-prompt/assemble` listener can only influence the *seed*; `agent/request` is the last seam before the model call, so it is authoritative. The plugin registers an `agent/request` listener that does `const final = await next(); return { ...final, tools: [runCodeSchema] }` — overriding the value *returned by* `next()`, not the inbound argument, so it dominates the cooperative request listeners it wraps. It registers with `prepend: true` to sit at the outer edge of the waterfall chain. One honest caveat, stated in the RFC body: `ctx.llm.stream()` itself runs a further `llm/stream` waterfall before the adapter, so the guarantee is "authoritative within the agent request pipeline," not an absolute wire invariant; if a hard invariant is ever required, a defensive `llm/stream` assertion with a spy adapter covers it in tests. - -**3c. The single tool — `run_code`.** Registered normally in `ctx.tools` with one parameter `{ code: string (required) }`. Because it is an ordinary tool, the unchanged loop dispatches it through the normal path — this is the crux of "zero loop changes." Its `execute(args, exec)`: - -1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: , name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/pre-execute`/`tools/post-execute` waterfalls, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones. -2. Calls `ctx.codeRuntime.run({ code: args.code, sdk: bindings, signal: exec.signal })`. -3. Surfaces the outcome. A *successful* run returns `[{ type: 'text', text: }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise` and `ToolRegistry.execute()` hardcodes `isError: false` on any successful return — `isError: true` arises only from the registry's catch path. So on an error result the tool **throws a `CodeRunError extends HarnessError`** (`HarnessError` is exported from `dsh-llm`; the registry catch turns any throw into `isError: true` with the message as text, and a `HarnessError` additionally carries structured `{ name, code }`). An alternative — registering `run_code` handling as a `tools/execute` listener that returns a full `ToolExecutionResult` and can set `isError` directly — is noted; the throw is simpler and preferred. - -**3d. Result discipline — what the model receives.** The model gets back **only the captured console output and/or the program's return value** (the model chooses which to surface). Intermediate sub-call results are **never** returned to the model. This is the core context-saving benefit: the agent curates its own output, exactly as a script's stdout curates a pipeline's intermediate state. - -**Sub-call CallIds.** Real tool calls dispatched from inside `run_code` need ids, but `CallId` is normally provider-issued (a branded string for correlating a call with its result — only brand-wrapped via `CallId()`, with no generator and no documented session-global-uniqueness guarantee). The plugin mints deterministic sub-ids scoped to the parent: `` `${exec.callId}:code:${n}` `` with a per-run counter `n`. These are unique within one `run_code` run (assuming the parent `callId` is unique, which the provider guarantees per turn); the `code/dispatch` event additionally carries the session log's `seq` so the UI and persistence can order and disambiguate globally without relying on the id alone. `ToolExecution.agent` is optional; the normal loop always supplies it (and with it `exec.agent.session`, the log `code/dispatch` appends to). A `run_code` execution arriving without `exec.agent` still runs (sub-calls propagate `agent: undefined`, exactly as the loop's own contract allows) but **skips session-log observability** — with no session to append to, those direct runs are simply not logged. - -**Observability without context cost.** Each sub-dispatch emits a session event **declared by the `dsh-code-mode` plugin itself** via `SessionEventMap` declaration merging (the map is merge-extensible precisely so plugins can add events without touching `dsh-session`). Shape: `code/dispatch` with `{ parentCallId, subCallId, name, arguments (or redacted), isError, summary }`, ordered by the session log's own `seq`. `deriveMessages()` does **not** translate it into a model message — an unknown event type falls through its `default`, per the merge-extensible-union convention — so the UI and persistence ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)) can render every sub-call while the model's context only ever receives the single `run_code` tool-result. Because the event lives in the plugin, this adds no core change. - -**SDK codegen.** A pure `jsonSchemaToTs(schema)` in `code-mode` maps the JSON-schema subset the `defineTool` DSL produces (object/string/number/boolean/array, `properties`, `required[]`, `enum` → string-literal union, nested objects, array `items`) to a TS type literal. It is **total**: any unsupported construct (`$ref`, `oneOf`/`anyOf`, `integer`, `null`, `additionalProperties`, or any raw MCP shape it does not recognize) degrades to `unknown` without throwing — it never crashes codegen. Typing is best-effort, not a guarantee, because MCP tools accept arbitrary JSON Schema and `ToolSchema.parameters` is typed only as `Record`. Because `ToolSchema.name` is an arbitrary string (not necessarily a valid TS identifier), the SDK is generated as a **namespace with quoted access** (e.g. `tools["some-mcp-tool"](args)`) plus safe camelCase aliases where the name is a clean identifier; alias collisions and TS reserved words fall back to quoted-only access (no duplicate alias emitted). This mirrors Cloudflare's `sanitizeToolName`. `run_code` itself is filtered out of the SDK. The MVP surfaces text content only; image and other block types in sub-results are deferred (noted as a limitation). - -**Concurrency — serialized by default (the binding must enforce it).** The SDK functions are async, so a model writing `await Promise.all([tools.a(...), tools.b(...)])` would *start both* immediately, and each would call `ctx.tools.execute` right away — i.e. the binding shape makes concurrent dispatch the **default**, not an opt-in. Because the tool contract carries **no concurrency-safety metadata today** (parallel tool execution and a concurrency-safety hint are an open TODO in both `dsh-tools` and [docs/architecture.md](../../../architecture.md): "phase 1 executes tool calls sequentially"), concurrent dispatch through a not-yet-hardened tool may race. So a prose "may serialize" is not sufficient. **Decision: the MVP SDK bindings enforce serialization** — each `run_code` invocation owns a per-run dispatch queue, and every `invoke()` chains onto it (`tail = tail.then(() => ctx.tools.execute(...))`), so even `Promise.all` over SDK calls executes them one at a time in submission order. This is a hard acceptance criterion, with a test that issues `Promise.all([...])` from a program and asserts the underlying `ctx.tools.execute` calls did **not** overlap (e.g. a probe tool records enter/exit and the test asserts no interleaving). The `.d.ts` may *describe* the model-visible functions as async (they are), but the implementation guarantees serial execution. Lifting serialization is deferred: only once a tool can declare itself read-only / concurrency-safe does the binding allow those specific tools to overlap. The same per-run queue is where the before/after abort checks (§3c) live, so an aborted run drains no further queued dispatches. - -**Tool visibility tiers (design intentionally skipped).** A natural extension is to mark each tool with a *visibility tier*: some tools "direct-call eligible" (still offered as native wire tools alongside `run_code`), some "code-mode only" (reachable solely from within a `run_code` program, never on the wire), and the default "both." This would let a deployment keep a few high-frequency or approval-gated tools as direct calls while routing the long tail through Code Mode, or hide composition-only primitives from the native surface entirely. This RFC notes the possibility but **intentionally skips the detailed design** — the per-tool metadata, how it interacts with the `agent/request` enforcement in 3b, and the presentation split in 3a are left to a follow-up. The MVP is the simple two-state model: Code Mode on (everything via `run_code`) or off (everything native). - -**Optionality / toggle.** Loading the `code-mode` plugin enables Code Mode for that context; not loading it leaves today's native tool-calling untouched. The two are mutually exclusive within one ctx, because Code Mode rewrites the wire tool list down to `[run_code]`. Per-agent selection via ctx forks, and the visibility tiers above, are future work; the MVP toggle is plugin presence. - -## Alternatives considered - -**Result elision / summarization over native tool-calling (the narrower route).** The Problem has two halves — context bloat (every intermediate `tool-result` re-enters context) and serial composition (one tool call per round-trip). The context-bloat half can be addressed *without* any code-execution runtime: keep provider tool-calling exactly as it is, and add a plugin on the `agent/request` waterfall (or a compaction pass akin to [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md)) that elides or summarizes older `tool-result` blocks before they re-enter the model's context — drop them past a window, replace large payloads with a digest, or keep only the blocks the model still references. This is strictly less invasive than Code Mode: no new runtime seam, no model-written programs, no new safety surface. It is the right tool if context growth is the only pain. - -It is insufficient for the **composition / round-trip** half, which is the decisive reason this RFC does not stop there. Elision still pays one model round-trip per tool call: a loop over N items is N turns, a branch on an intermediate value is a turn to fetch then a turn to act, and post-processing (filter, join, reduce) either happens in the model's head over full payloads or not at all. Code Mode collapses all of that into one program — the loop, the branch, the join run in the runtime, and only the curated result returns. Elision also cannot express fan-out or data-dependent control flow; it only shrinks what comes back. So the two are complementary, not competing: elision could even layer *under* Code Mode for the residual native-tool paths. The RFC chooses Code Mode because the round-trip/composition cost is the larger structural limit, and accepts the new code-execution surface as the price — which is exactly why the execution substrate is gated behind the enforceable safety guard (§2) and the hardened backend is a hard prerequisite for untrusted use. - -**Why not change the loop to dispatch native tool calls in parallel instead?** That is the other obvious answer to the round-trip cost, and it remains valid future work (it is the open `dsh-tools`/architecture.md TODO). But it is a core-loop change requiring the same concurrency-safety metadata Code Mode defers, and it still does not give the model *composition* (branch/loop/post-process between calls) — only parallelism of independent calls the model already decided to make in one step. Code Mode delivers composition with zero core change; parallel native dispatch and Code Mode can coexist later. - -## Plan - -1. Scaffold the interface package `packages/code-runtime/` per [the cookbook](../../../cookbook/adding-a-package.md): abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) with a readonly `safe: boolean`, the `declare module 'cordis'` ctx key, the `CodeRunRequest`/`CodeRunResult`/`SdkBinding` vocabulary, method contracts documented in JSDoc (what `run` captures, abort semantics, that an error is a result field not a throw, what `safe` means). HMR-safety test (dispose the contributing fiber, assert `ctx.codeRuntime` is gone). -2. Scaffold the implementation package `packages/code-runtime-vm/`: the node:vm stub — `safe = false`, a constructor that **throws unless given `{ unsafe: true }`**, transpile/type-erase, async-IIFE wrap, capturing `console`, SDK globals, return-value/logs/error capture, output cap, signal-tied timeout. Tests for output capture, return value, error-as-field, abort, the constructor refusal without `unsafe`, and a README documenting the "not a sandbox, trusted-only" caveat prominently. -3. Scaffold the consumer plugin `packages/code-mode/`: `jsonSchemaToTs` codegen with namespace/quoted-access + alias handling (unit tests, including non-identifier MCP names and unsupported-shape → `unknown`); the registered lazy `ctx.systemPrompt.section()` carrying the SDK `.d.ts`; the `agent/request` listener (`prepend: true`) collapsing `request.tools` to `[run_code]` after `await next()`; the **unsafe-runtime gate** (refuse to register `run_code` when `ctx.codeRuntime.safe === false` unless `allowUnsafeRuntime` is set); the `run_code` tool with the dispatch bridge (per-run serialization queue, deterministic sub-call ids, before/after abort checks, `CodeRunError` on error results); and the `code/dispatch` event declared here via `SessionEventMap` merge. Declare `inject = ['tools', 'systemPrompt', 'codeRuntime']`. -4. Tests: HMR-safety (dispose removes the tool, the section, and the listener); a waterfall test that the wire tool list is exactly `[run_code]` (spy adapter, asserting via `agent/request` and optionally `llm/stream`); an integration test that a program calling two tools returns only its printed/returned output (verify the world, not the self-report); a **serialization test** that `Promise.all([...])` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (a probe tool records enter/exit; assert no interleaving); `deriveMessages()` ignores `code/dispatch`; abort mid-program stops further dispatches; `CodeRunError` surfaces as `isError: true`; and the **unsafe-runtime refusal test** (§3, the VM-guard): with the unsafe flag unset, a non-mock agent's `run_code` is refused; with it set, the program runs. -5. Wire an example: `examples/coding-agent-code-mode` (or a config flag on the existing example) loading the trio. Running it against the node:vm stub requires both opt-ins (`VmCodeRuntime({ unsafe: true })` and `code-mode`'s `allowUnsafeRuntime`); the example sets them explicitly and comments why, or uses a mock model — a real model never reaches the unsandboxed stub without those deliberate flags. Add a `pnpm run demo:*` entry. -6. Docs: update [docs/architecture.md](../../../architecture.md) (a `ctx.codeRuntime` row in the service map, a Code Mode note under the tool pipeline / capability seams sections); add a [cookbook](../../../cookbook) note on writing a `CodeRuntime` backend; and **file the follow-up RFC for the hardened execution substrate** (the isolate/sandboxed-process design, the additional-language backends sketched in §1 — AssemblyScript/WASM, Python — with their per-language SDK generators, plus the tool-visibility-tier design skipped here). On landing, move this file to `implemented/` and update its row in [the RFC index](../../README.md). - -## Acceptance criteria - -- The three packages exist and pass their suites: `dsh-code-runtime` (the abstract seam), `dsh-code-runtime-vm` (the reference stub whose constructor throws without `{ unsafe: true }`), and `dsh-code-mode` (SDK codegen, the lazy prompt section, the `agent/request` collapse, the `run_code` tool). -- The wire tool list is exactly `[run_code]` under the plugin (spy-adapter test); the generated SDK covers every registered tool, with non-identifier names reachable via quoted access. -- A program calling two tools returns only its curated output; `code/dispatch` events land in the session log and never enter derived history. -- `Promise.all` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (the per-run serialization queue holds); an abort stops further dispatches. -- With `allowUnsafeRuntime` unset over an unsafe runtime, `run_code` is not registered and the wire tool list is unchanged from native. - -## Risks - -node:vm is not a sandbox. This is the single biggest caveat. Withholding `require`/`process` is not a boundary; the MVP runs at harness trust only; the hardened substrate is a hard prerequisite before any untrusted use and is the explicit subject of a follow-up RFC. The guard is enforceable, not just documented: the runtime exposes `safe: boolean`, the VM stub throws unless constructed with `{ unsafe: true }`, and `code-mode` refuses to register `run_code` over an unsafe runtime unless separately acknowledged (`allowUnsafeRuntime`) — production misuse requires two deliberate, greppable flags, and the refusal path is tested. - -Wrong seam would leak tools. If the wire tool list were enforced only in `system-prompt/assemble`, a later `agent/request` listener could re-add tools. Mitigation: enforce `request.tools = [run_code]` in the `agent/request` waterfall (the authoritative seam, run last before `llm.stream()`) with `prepend: true`, and assert exactly one wire tool in tests. The residual `llm/stream` caveat is documented, not hidden. - -Concurrency before the contract supports it. The binding shape makes concurrent dispatch the default, and the tool contract has no concurrency-safety metadata yet, so unguarded `Promise.all` over SDK calls could race a not-yet-hardened tool. Mitigation: the MVP bindings enforce a per-run serialization queue (every `invoke` chains onto the previous), with a test asserting `Promise.all` from a program does not overlap the underlying `ctx.tools.execute` calls. Per-tool parallelism is unlocked only once a tool can declare itself concurrency-safe. - -Two presentation modes to keep coherent. A tool added later must work in both native and Code Mode. Mitigation: both the codegen thunk and the `agent/request` listener read `ctx.tools.schemas()`, so coverage is automatic; a test asserts every registered schema produces valid `.d.ts`, including non-identifier MCP names via quoted access. - -Type-erased runtime is not type-checked. The model can write code that type-checks against the advisory `.d.ts` but throws at runtime, and MCP-schema typing is best-effort. Mitigation: errors are captured as `CodeRunResult.error` and surfaced so the model can self-correct; the `.d.ts` is explicitly advisory. - -Lost observability of sub-calls. Routing everything through one `run_code` result hides the individual calls from the model — and could hide them from operators too. Mitigation: the plugin-declared `code/dispatch` event keeps every sub-call in the session log and UI without polluting model context. - -Abort granularity. node:vm cannot reliably interrupt hot synchronous code, and `ctx.tools.execute()` converts thrown aborts into `isError` data. Mitigation: the SDK bindings check `signal.aborted` and throw before/after each dispatch so an aborted sub-call stops the program; the vm stub wraps the run in a signal-tied timeout; the hardened substrate addresses the hot-loop case. - -Unsafe example wiring. A demo running a real model through the node:vm stub would hand model output ambient authority. Mitigation: examples are mock-model or explicitly marked unsafe; `code-runtime-vm` is labeled reference/test-only. - -Non-text sub-results dropped in the MVP. Image and other block types from sub-calls are not surfaced into the program yet. Mitigation: noted as a known limitation; block-type handling deferred. From 00ee92b278d8c35555cd8a5492eab88ad43c0179 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:58:26 +0800 Subject: [PATCH 10/33] =?UTF-8?q?docs:=20record=20the=20codeRuntime=20cons?= =?UTF-8?q?umption=20idiom=20=E2=80=94=20cordis=20has=20no=20optional=20in?= =?UTF-8?q?ject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own adversarial pass finding: a static inject on the registry would gate ctx.tools (and every tool plugin) on a code runtime existing even under mode 'native'. The RFC now names the sanctioned pattern: soft ctx.get('codeRuntime') at use time (the agent-loop sessionPersistence precedent) with absence failing loud in the provider thunk. --- docs/rfc/proposed/feature/2026-06-15-code-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index d868c41faf..1ca5b52057 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -59,7 +59,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. +Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's established optional-backend idiom: cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk as above. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. ### The worker-thread runtime From e90103ac0533c3bb1f7950668abc84aa40c0d293 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:02:37 +0800 Subject: [PATCH 11/33] docs: name the persistence-catalog gate for the tool/code-dispatch event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research finding: a SessionEventMap member is a log event — JSDoc prose required, @mode is a hard error there, and docs/persistence-catalog.md must be regenerated (todo/write is the log-only precedent). PR4's plan now names both. --- docs/rfc/proposed/feature/2026-06-15-code-mode.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index 1ca5b52057..e4f4c5a32d 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -46,7 +46,7 @@ Under `'code'` and `'both'` the registry registers `run_code` in itself as an or ### Observability: `tool/code-dispatch` -Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }`. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. +Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }`. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. ### The code-runtime seam @@ -87,7 +87,7 @@ Four stacked PRs, each gates-green (`typecheck`, `lint`, `test:coverage`, `test: 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, group README + package README + catalog updates. 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. Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, timeout vs abort vs worker-exit under OOM), compute-time-only timeout (a slow binding does not expire the run), binding bridge hostility cases (unknown name, duplicate id, post-settlement message), 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; [docs/architecture.md](../../../architecture.md) (tool-pipeline section + `ctx.codeRuntime` service row) 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. +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 `docs/persistence-catalog.md`); [docs/architecture.md](../../../architecture.md) (tool-pipeline section + `ctx.codeRuntime` service row) 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. 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. From cb3246d2d875e5195902f6b2c01c9307bfbc48fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:26:33 +0800 Subject: [PATCH 12/33] docs: fix Codex round-1 findings on the Code Mode RFC (A1) Scope the wire-collapse guarantee honestly: systemPrompt.tools() is a public multi-provider API, so the mode governs the registry's contribution (the only shipped source); deliberate extra providers own what they add, and the shipped-configuration invariant is test-pinned. (A2) Replace pause-on-pending-RPC timeout with two independent budgets: computeMs metered by worker.performance.eventLoopUtilization() busy time (unfoolable by an un-awaited decoy dispatch; probe-verified) plus a never-pausing maxWallMs ceiling. (A3) Specify sub-call additionalContext as deliberately suppressed in the MVP (immediate inject would break call/result adjacency; the plural channel is named follow-up work). (B) Orphan-process caveat vs bash-local's group kill; null-prototype binding namespaces (__proto__/constructor names); per-PR doc artifacts (packages/README row, architecture service map in PR2, config/tool/ persistence catalogs per owning PR); engines range corrected to ^22.19.0 || >=24.0.0. --- .../proposed/feature/2026-06-15-code-mode.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index e4f4c5a32d..67f26ad688 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -24,7 +24,7 @@ Three decisions, each elaborated in its own section below: `ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list = the provider's contribution.** The registry already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism, and there is no seam left where another party could accidentally re-add tools (the `system-prompt/assemble` waterfall can still deliberately transform the assembly, as ever). +**Wire tool list = the registry's contribution.** The registry already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism. Scope of the guarantee, stated honestly: the mode governs the **registry's** contribution, and the registry is the only shipped schema source — but `systemPrompt.tools()` is a public multi-provider API and the `system-prompt/assemble` waterfall may transform the assembly, so a deployment that wires a second direct provider (or a mutating listener) owns what it adds, exactly as in native mode. Those are deliberate acts; what the design eliminates is the *accidental* leak the old draft worried about — a listener-ordering race around an after-the-fact collapse — and the shipped-configuration invariant (`'code'` ⇒ assembled tools exactly `[run_code]`) is pinned by tests and, like every request, by the logged header. **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native tools rejects every assembly under `mode: 'code'` (those names are no longer contributed), by the existing fail-loud rule for unlisted names. This is correct behavior, not a bug: a deployment switching modes updates its order config or drops it. @@ -40,6 +40,8 @@ Under `'code'` and `'both'` the registry registers `run_code` in itself as an or 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. 3. **Surfaces the outcome**: a successful run returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. +**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. + **Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the *default*, while the tool contract still carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per-tool once tools can declare themselves concurrency-safe is deferred work, same as before. **Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. @@ -65,16 +67,16 @@ Per explicit-over-implicit at seams, the request spells out everything the runti `packages/code-runtime/code-runtime-worker/` — `@deepseek-ai/dsh-code-runtime-worker`. Per `run()`: -1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; the repo floor is node ≥ 24, and the API is position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. +1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. 3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented). -4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. -5. **Enforce caps**: the compute-time budget (`timeoutMs`) ticks only while no binding call is pending — it bounds runaway worker compute, not tool latency — and expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap). Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config (`maxLogBytes`, `maxValueBytes`), truncation marked in-band. All caps are validated config fields with defaults (`timeoutMs: 60_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`. +4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. +5. **Enforce caps — two independent budgets, because the peer is hostile.** The compute budget (`computeMs`) meters the worker's *measured busy time* via `worker.performance.eventLoopUtilization()` polling — not host-side "is an RPC pending" bookkeeping, which a program defeats by firing an un-awaited call at a slow tool and then spinning hot while the host thinks it is waiting. Measured busy time cannot be gamed: a hot loop accrues it whether or not a dispatch is in flight, and a program genuinely awaiting a slow tool accrues none, so a long-running `bash` sub-call still does not kill an innocent run. The wall ceiling (`maxWallMs`) never pauses for anything and backstops what busy-time cannot see (a program awaiting a promise nobody will resolve). Budget expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap); the failure reports which budget fired. Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config, truncation marked in-band. All caps are validated config fields with defaults (`computeMs: 60_000`, `maxWallMs: 600_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../defensive-patterns.md). ### Trust posture -The worker runtime is **containment, not a security boundary**, and the RFC says so without ceremony. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env) — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool already grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor exists so such a deployment can tell backends apart. +The worker runtime is **containment, not a security boundary**, and the RFC says so without ceremony. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool already grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way and is stated plainly: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it already is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor exists so such a deployment can tell backends apart. ### What the model sees @@ -85,9 +87,9 @@ The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program 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: 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, group README + package README + catalog updates. 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. Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, timeout vs abort vs worker-exit under OOM), compute-time-only timeout (a slow binding does not expire the run), binding bridge hostility cases (unknown name, duplicate id, post-settlement message), 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 `docs/persistence-catalog.md`); [docs/architecture.md](../../../architecture.md) (tool-pipeline section + `ctx.codeRuntime` service row) 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. +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. 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. @@ -116,7 +118,7 @@ The four PRs land in order (each on the previous); per stacked-review practice, - 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. - `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. -- Worker runtime: a hot `for(;;){}` run ends at the compute-time budget with `error.kind: 'timeout'`; a pending binding call does not consume that budget; 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. +- 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. @@ -124,7 +126,7 @@ The four PRs land in order (each on the previous); per stacked-review practice, **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. -**`stripTypeScriptTypes` is marked experimental.** It is also what Node itself runs `.ts` files with on this repo's node floor. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end. +**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end. **Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the RFC makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. @@ -134,4 +136,4 @@ The four PRs land in order (each on the previous); per stacked-review practice, **Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. -**Timer-pause bookkeeping.** Compute-time-only budgeting adds a small state machine (pause on RPC out, resume on reply) whose bugs would misattribute time. It is unit-tested from both sides (slow binding ≠ timeout; hot loop = timeout) and is still simpler than the alternative — one budget that spuriously kills programs for waiting on a slow tool. +**Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass. From 96de50cb4835c24fe48967db9a05277f8b98bdc0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:39:10 +0800 Subject: [PATCH 13/33] docs: add the run-local abort/settlement contract (Codex round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget expiry terminated the worker but nothing cancelled an in-flight host-side sub-dispatch, and a late dispatch could append events after run_code returned. The bridge now owns a run-scoped AbortController (follows exec.signal; fired on any run settlement), sub-dispatches get the run signal, and run_code returns only after the dispatch queue drains — no post-settlement appends, per dispose-to-quiescence. --- docs/rfc/proposed/feature/2026-06-15-code-mode.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index 67f26ad688..30f4d71319 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -36,9 +36,9 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). Its `execute(args, exec)`: -1. **Builds the bindings**: for every registered tool except `run_code`, an async function that (a) checks `exec.signal?.aborted` before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) awaits its turn on the **per-run serialization queue** (below), (c) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: exec.signal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (d) appends a `tool/code-dispatch` session event, and (e) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. +1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) awaits its turn on the **per-run serialization queue** (below), (c) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (d) appends a `tool/code-dispatch` session event, and (e) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. -3. **Surfaces the outcome**: a successful run returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. +3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. **Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. @@ -117,7 +117,7 @@ The four PRs land in order (each on the previous); per stacked-review practice, - 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. -- `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. +- `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. From 56b05f70cf8f67ac502a2888b0c97aa556517777 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:17:24 +0800 Subject: [PATCH 14/33] feat: add the code-execution capability seam (ctx.codeRuntime) New group packages/code-runtime/ with the interface package @deepseek-ai/dsh-code-runtime, per the Code Mode RFC: abstract CodeRuntime service (run() resolves program failures as an error field, rejects only for seam misuse), the CodeRunRequest/CodeBindingNamespace/CodeRunResult/ CodeLogEntry/CodeRunFailure vocabulary, and readonly language/isolation backend descriptors. Registered in the tsconfig maps, packages/README, architecture service map, and the doc-graph service-role classification; catalogs regenerated. The RFC's one forward path token to the worker package becomes an npm-name mention until PR3 creates that directory (verify-package-paths is drift-scoped: the now-existing group made the token checkable). docs/architecture.md ceiling 1630 -> 1640: the doc gained a genuinely new capability-service row; the row itself is already minimal. --- docs/architecture.md | 1 + docs/capability-seams.md | 4 + docs/config-catalog.md | 1 + docs/cordis-catalog/services.md | 17 +++ docs/module-graph.md | 4 + .../proposed/feature/2026-06-15-code-mode.md | 2 +- packages/README.md | 1 + packages/code-runtime/README.md | 9 ++ packages/code-runtime/code-runtime/README.md | 19 ++++ .../code-runtime/code-runtime/package.json | 30 +++++ .../code-runtime/code-runtime/src/index.ts | 93 ++++++++++++++++ .../code-runtime/code-runtime/src/types.ts | 105 ++++++++++++++++++ .../code-runtime/tests/service.spec.ts | 87 +++++++++++++++ .../code-runtime/code-runtime/tsconfig.json | 18 +++ pnpm-lock.yaml | 6 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-doc-graphs.ts | 9 ++ tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 20 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 packages/code-runtime/README.md create mode 100644 packages/code-runtime/code-runtime/README.md create mode 100644 packages/code-runtime/code-runtime/package.json create mode 100644 packages/code-runtime/code-runtime/src/index.ts create mode 100644 packages/code-runtime/code-runtime/src/types.ts create mode 100644 packages/code-runtime/code-runtime/tests/service.spec.ts create mode 100644 packages/code-runtime/code-runtime/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 371b5df579..02dac4a2dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 414ee898d1..339954c00f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -42,6 +42,8 @@ flowchart LR pkg_bash_local["bash-local"] pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] + pkg_code_runtime["code-runtime"] + svc_codeRuntime["ctx.codeRuntime
Code-execution seam"] pkg_fs["fs"] svc_fs["ctx.fs
Filesystem provider seam"] pkg_fs_local["fs-local"] @@ -64,6 +66,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_bash --> svc_bash pkg_bash_local --> svc_bash + pkg_code_runtime --> svc_codeRuntime pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_fs --> svc_fs @@ -134,6 +137,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) | - | - | - | 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.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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5e8c5258e8..9105a52f6b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -845,6 +845,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)). - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) +- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f7f2734a9f..c941192381 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -67,6 +67,23 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) + +Abstract code-execution service. Subclass, implement run and the two descriptors, and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). + +Semantics every implementation must honor: + +- run resolves with an error FIELD for every program outcome — parse/transform failures, thrown exceptions, budget expiry, abort, substrate death (CodeRunFailure's taxonomy). It REJECTS only for caller misuse of the seam itself (e.g. a run submitted after disposal). +- Binding calls bridge to the caller's CodeBindingFunctions verbatim; arguments and resolutions must be structured-cloneable, and the runtime treats the program as a hostile peer (arbitrary binding names are own properties, malformed traffic is rejected or ignored, never crashes the host). +- Runs are isolated from each other: no state survives from one run to the next through the runtime. +- Disposal reaches quiescence: in-flight runs are terminated AND awaited before the service's own teardown completes (no orphan substrate survives `fiber.dispose()`). + +```ts cordis-catalog +abstract run(request: CodeRunRequest): Promise +``` + +Source: [`packages/code-runtime/code-runtime/src/index.ts:59`](../../packages/code-runtime/code-runtime/src/index.ts) + ## `ctx.compact` — `CompactService` (abstract seam) Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). diff --git a/docs/module-graph.md b/docs/module-graph.md index 81b272839d..29d454c68d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -82,6 +82,9 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_code_runtime["packages/code-runtime"] + pkg_code_runtime["code-runtime"] + end pkg_llm --> pkg_brand pkg_bash --> pkg_brand pkg_llm_deepseek --> pkg_llm @@ -215,6 +218,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | +| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index 30f4d71319..a5f1563126 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -65,7 +65,7 @@ Per explicit-over-implicit at seams, the request spells out everything the runti ### The worker-thread runtime -`packages/code-runtime/code-runtime-worker/` — `@deepseek-ai/dsh-code-runtime-worker`. Per `run()`: +`@deepseek-ai/dsh-code-runtime-worker`, the second package of the `packages/code-runtime/` group. Per `run()`: 1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. diff --git a/packages/README.md b/packages/README.md index f07e0d5a36..da75f740e8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md new file mode 100644 index 0000000000..578f3179c1 --- /dev/null +++ b/packages/code-runtime/README.md @@ -0,0 +1,9 @@ +# 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, and the first implementation (a Node worker-thread backend) is specified alongside it in the [Code Mode RFC](../../docs/rfc/proposed/feature/2026-06-15-code-mode.md). **Product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` | + +The interface lives at `code-runtime/code-runtime/`. Backends differ by execution substrate (worker thread, process, container) and by source language — both readonly descriptors on the service — and register `ctx.codeRuntime` without touching the interface or its consumer; that split is what makes a hardened backend a drop-in later. diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md new file mode 100644 index 0000000000..2d7b12add1 --- /dev/null +++ b/packages/code-runtime/code-runtime/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-code-runtime + +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. + +## Service API (`ctx.codeRuntime`) + +| Member | Semantics | +|---|---| +| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. | +| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | +| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. | + +Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing. + +## Vocabulary + +`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json new file mode 100644 index 0000000000..0fe24bb15c --- /dev/null +++ b/packages/code-runtime/code-runtime/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-code-runtime", + "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts new file mode 100644 index 0000000000..af967da61d --- /dev/null +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -0,0 +1,93 @@ +/** + * The code-execution seam (`ctx.codeRuntime`): an abstract service 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. Implementations subclass {@link CodeRuntime} and register + * themselves as the `codeRuntime` service; backends may differ by execution + * 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). + * + * The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing + * about tools or sessions — it is handed named async functions and a program, + * and everything tool-shaped stays with the consumer. + * + * @module @deepseek-ai/dsh-code-runtime + */ + +import { Context, Service } from 'cordis' +import type { CodeRunRequest, CodeRunResult } from './types.ts' + +export type { + CodeBindingFunction, + CodeBindingNamespace, + CodeLogEntry, + CodeRunFailure, + CodeRunRequest, + CodeRunResult, +} from './types.ts' + +declare module 'cordis' { + interface Context { + codeRuntime: CodeRuntime + } +} + +/** + * Abstract code-execution service. Subclass, implement {@link run} and the + * two descriptors, and load the subclass as a plugin — it registers as + * `ctx.codeRuntime` (one implementation per context; loading a second throws, + * cordis' standard duplicate-service behavior). + * + * Semantics every implementation must honor: + * - {@link run} resolves with an error FIELD for every program outcome — + * parse/transform failures, thrown exceptions, budget expiry, abort, + * substrate death ({@link CodeRunFailure}'s taxonomy). It REJECTS only for + * caller misuse of the seam itself (e.g. a run submitted after disposal). + * - Binding calls bridge to the caller's {@link CodeBindingFunction}s + * verbatim; arguments and resolutions must be structured-cloneable, and the + * runtime treats the program as a hostile peer (arbitrary binding names are + * own properties, malformed traffic is rejected or ignored, never crashes + * the host). + * - Runs are isolated from each other: no state survives from one run to the + * next through the runtime. + * - Disposal reaches quiescence: in-flight runs are terminated AND awaited + * before the service's own teardown completes (no orphan substrate survives + * `fiber.dispose()`). + */ +export abstract class CodeRuntime extends Service { + /** + * The source language {@link run} expects `program` to be written in, as a + * lowercase identifier. Informational, not gating — a consumer that + * generates language-specific presentation (typed SDK stubs, usage + * instructions) switches on it and fails loud on a language it cannot + * present. Well-known value: `'typescript'`. + */ + abstract readonly language: string + + /** + * The execution substrate, as a lowercase identifier. Informational, not + * gating — a descriptor so deployments and diagnostics can tell backends + * apart, not a security claim. Well-known values: `'worker-thread'`, + * `'process'`, `'container'`. + */ + abstract readonly isolation: string + + constructor(ctx: Context) { + super(ctx, 'codeRuntime') + } + + /** + * Execute one program against the request's bindings and capture what it + * emitted. See the class doc for the resolution contract (error is a result + * field; rejection means seam misuse only). + * @param request - the program, its bindings, and the abort signal; the + * request carries everything the runtime acts on, with no hidden defaults. + * @returns the run's outcome: completion value (when transferable), the + * ordered log capture, and the failure (if any). + */ + abstract run(request: CodeRunRequest): Promise +} + +export default CodeRuntime diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts new file mode 100644 index 0000000000..8278f33a39 --- /dev/null +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -0,0 +1,105 @@ +/** + * Vocabulary types for the code-execution seam: what a caller hands a + * {@link ../index.ts | CodeRuntime} and what it gets back. Pure types — no + * runtime code lives here. + * + * @module @deepseek-ai/dsh-code-runtime/src/types + */ + +/** + * One host-side function exposed to the program as an async callable. The + * runtime bridges calls to it (possibly across a serialization boundary), so + * `args` and the resolution value MUST be structured-cloneable; a runtime + * rejects a non-cloneable value with a descriptive error rather than + * corrupting the run. A rejection of this function surfaces inside the + * program as a rejection of the corresponding call. + */ +export type CodeBindingFunction = (args: unknown) => Promise + +/** + * A named group of {@link CodeBindingFunction}s the runtime exposes to the + * program as one global object (e.g. `tools`). Function names are arbitrary + * strings — a runtime must treat names like `__proto__` or `constructor` as + * ordinary own properties (null-prototype construction), never as prototype + * collisions. + */ +export interface CodeBindingNamespace { + /** The global identifier the program sees (must be a valid JS identifier). */ + global: string + /** The callable members, keyed by the exact name the program calls. */ + functions: Record +} + +/** + * One run: the program source plus everything the runtime acts on. Per the + * explicit-over-implicit convention, defaulting (time budgets, output caps) + * is the implementation's validated config — a request carries no optional + * tuning knobs for a hidden `??` to fill in. + */ +export interface CodeRunRequest { + /** + * The program source, in the runtime's {@link ../index.ts | language}. It + * runs as the body of an async function: top-level `await` and `return` + * are available, and the completion value becomes + * {@link CodeRunResult.value}. + */ + program: string + /** Host functions exposed to the program, one global object per namespace. */ + bindings: CodeBindingNamespace[] + /** + * Abort the run: the runtime stops the program (hard, even mid-loop) and + * resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight + * binding calls are the CALLER's to settle — the runtime only stops asking. + */ + signal?: AbortSignal +} + +/** + * One captured output entry, in emission order. `source` says which channel + * produced it: the program's `console` (shimmed by the runtime), or a stray + * write to the underlying stdout/stderr streams. + */ +export interface CodeLogEntry { + /** Which channel produced the text. */ + source: 'console' | 'stdout' | 'stderr' + /** The console method used; present only when `source` is `'console'`. */ + level?: 'log' | 'info' | 'warn' | 'error' | 'debug' + /** The captured text (possibly truncated by the implementation's caps, marked in-band). */ + text: string +} + +/** + * Why a run failed. The kinds are orthogonal outcomes reported independently + * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * abort is not a timeout, and a substrate death is neither. + * + * - `'exception'` — the program threw or failed to parse/transform. + * - `'timeout'` — an implementation-owned budget expired; the message says which. + * - `'abort'` — {@link CodeRunRequest.signal} fired. + * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + */ +export interface CodeRunFailure { + /** The failure class (see the interface doc for each kind's meaning). */ + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + /** Human-readable detail, suitable for feeding back to a model to self-correct. */ + message: string +} + +/** + * The outcome of one run. An error is a FIELD on a resolved result, never a + * rejection of `run()` — reporting a failed program is the caller's job, not + * an exception path. + */ +export interface CodeRunResult { + /** + * The program's completion value (its top-level `return`), when it ran to + * completion and the value survived the runtime's serialization boundary; + * a non-transferable value is replaced by a string rendering, and a failed + * or value-less run leaves this absent. + */ + value?: unknown + /** Everything the program emitted, in order (capped by the implementation). */ + logs: CodeLogEntry[] + /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ + error?: CodeRunFailure +} diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts new file mode 100644 index 0000000000..4ff6d8f313 --- /dev/null +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' + +/** + * Minimal concrete runtime: records requests, "executes" by invoking every + * binding once in declaration order, and lets tests script the outcome. The + * seam package ships no implementation, so the contract is exercised through + * the smallest subclass that honors it. + */ +class StubRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'in-process-stub' + requests: CodeRunRequest[] = [] + nextResult: CodeRunResult = { logs: [] } + + async run(request: CodeRunRequest): Promise { + this.requests.push(request) + if (request.signal?.aborted) { + return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } } + } + for (const namespace of request.bindings) { + for (const fn of Object.values(namespace.functions)) { + await fn({ from: 'stub' }) + } + } + return this.nextResult + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(StubRuntime) + const runtime = ctx.codeRuntime as StubRuntime + return { ctx, runtime } +} + +describe('CodeRuntime service seam', () => { + it('registers as ctx.codeRuntime and serves the abstract API', async () => { + const { runtime } = await setup() + expect(runtime.language).toBe('typescript') + expect(runtime.isolation).toBe('in-process-stub') + + const calls: unknown[] = [] + const result = await runtime.run({ + program: 'return 1', + bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }], + }) + expect(result).toEqual({ logs: [] }) + expect(calls).toEqual([{ from: 'stub' }]) + expect(runtime.requests).toHaveLength(1) + }) + + it('reports a failed run as an error field on a resolved result, never a rejection', async () => { + const { runtime } = await setup() + runtime.nextResult = { + logs: [{ source: 'console', level: 'error', text: 'boom' }], + error: { kind: 'exception', message: 'boom' }, + } + const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] }) + expect(result.error).toEqual({ kind: 'exception', message: 'boom' }) + expect(result.value).toBeUndefined() + }) + + it('reports a pre-aborted signal as an abort failure', async () => { + const { runtime } = await setup() + const controller = new AbortController() + controller.abort('cancelled') + const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal }) + expect(result.error).toEqual({ kind: 'abort', message: 'cancelled' }) + }) + + it('is removed from the context when the providing fiber disposes (HMR safety)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(StubRuntime) + expect(ctx.get('codeRuntime')).toBeInstanceOf(StubRuntime) + + await fiber.dispose() + expect(ctx.get('codeRuntime')).toBeUndefined() + }) + + it('rejects a second implementation in the same context (duplicate service)', async () => { + const { ctx } = await setup() + await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) + }) +}) diff --git a/packages/code-runtime/code-runtime/tsconfig.json b/packages/code-runtime/code-runtime/tsconfig.json new file mode 100644 index 0000000000..754725418e --- /dev/null +++ b/packages/code-runtime/code-runtime/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d12ec0d9c5..cd6918087d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/code-runtime/code-runtime: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/compact/compact: devDependencies: '@deepseek-ai/dsh-llm': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index b23811e3d0..fc2b9d12c2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1691, "docs/AGENTS.md": 1315, - "docs/architecture.md": 1630, + "docs/architecture.md": 1640, "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 66cb102b00..6440a672be 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -149,6 +149,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.', }, + { + key: 'codeRuntime', + pkg: 'code-runtime', + title: 'Code-execution seam', + mode: 'seam', + implementations: [], + 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).', + }, { key: 'fs', pkg: 'fs', diff --git a/tsconfig.base.json b/tsconfig.base.json index 7cbb3acab2..a38b4e1836 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/code-runtime/*/src", "./packages/fs/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index b44368db21..ee62bca604 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,7 @@ { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, + { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/llm/llm-deepseek" }, diff --git a/tsconfig.json b/tsconfig.json index b257b0aa89..8a85a4cd59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, + { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, From 0ba6de00010b2e67ab945d9819a4f1dba1747886 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:38:47 +0800 Subject: [PATCH 15/33] docs: catalog the code-runtime seam vocabulary (Codex review finding) Adds the missing core-data-structures coverage the catalog policy requires for non-spine seam vocabulary: the code-runtime.md sub-page with drift-checked type-equiv blocks for all six seam types, the core.md sub-page row, the type-equiv manifest entries, and LINK_MAP entries so the generated service signature links CodeRunRequest/CodeRunResult; cordis/config catalogs regenerated. --- docs/cordis-catalog/services.md | 2 + docs/core-data-structures/code-runtime.md | 94 +++++++++++++++++++++++ docs/core-data-structures/core.md | 1 + scripts/gen-cordis-catalog.ts | 2 + scripts/type-equiv.manifest.json | 7 ++ 5 files changed, 106 insertions(+) create mode 100644 docs/core-data-structures/code-runtime.md diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c941192381..26126c4481 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -82,6 +82,8 @@ Semantics every implementation must honor: abstract run(request: CodeRunRequest): Promise ``` +Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) + Source: [`packages/code-runtime/code-runtime/src/index.ts:59`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md new file mode 100644 index 0000000000..1f87e8e8a4 --- /dev/null +++ b/docs/core-data-structures/code-runtime.md @@ -0,0 +1,94 @@ +# 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). + +Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) + +## The run: request in, result out + +A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`: + +```ts type-equiv +interface CodeRunRequest { + /** + * The program source, in the runtime's {@link ../index.ts | language}. It + * runs as the body of an async function: top-level `await` and `return` + * are available, and the completion value becomes + * {@link CodeRunResult.value}. + */ + program: string + /** Host functions exposed to the program, one global object per namespace. */ + bindings: CodeBindingNamespace[] + /** + * Abort the run: the runtime stops the program (hard, even mid-loop) and + * resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight + * binding calls are the CALLER's to settle — the runtime only stops asking. + */ + signal?: AbortSignal +} +``` + +The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): + +```ts type-equiv +interface CodeRunResult { + /** + * The program's completion value (its top-level `return`), when it ran to + * completion and the value survived the runtime's serialization boundary; + * a non-transferable value is replaced by a string rendering, and a failed + * or value-less run leaves this absent. + */ + value?: unknown + /** Everything the program emitted, in order (capped by the implementation). */ + logs: CodeLogEntry[] + /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ + error?: CodeRunFailure +} +``` + +## Bindings: host functions as program globals + +Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): + +```ts type-equiv +interface CodeBindingNamespace { + /** The global identifier the program sees (must be a valid JS identifier). */ + global: string + /** The callable members, keyed by the exact name the program calls. */ + functions: Record +} +``` + +```ts type-equiv +type CodeBindingFunction = (args: unknown) => Promise +``` + +## Captured output and the failure taxonomy + +Logs arrive in emission order, attributed to their channel (the runtime's `console` shim, or stray writes to the underlying streams): + +```ts type-equiv +interface CodeLogEntry { + /** Which channel produced the text. */ + source: 'console' | 'stdout' | 'stderr' + /** The console method used; present only when `source` is `'console'`. */ + level?: 'log' | 'info' | 'warn' | 'error' | 'debug' + /** The captured text (possibly truncated by the implementation's caps, marked in-band). */ + text: string +} +``` + +Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: + +```ts type-equiv +interface CodeRunFailure { + /** The failure class (see the interface doc for each kind's meaning). */ + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + /** Human-readable detail, suitable for feeding back to a model to self-correct. */ + message: string +} +``` + +## The service + +`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` is the well-known value; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 7d1110d7a8..615c222d94 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 451c4bec9e..6220006ede 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -97,6 +97,8 @@ export const LINK_MAP: Record = { BashRunResult: 'bash.md', BashTask: 'bash.md', BashTaskRead: 'bash.md', + CodeRunRequest: 'code-runtime.md', + CodeRunResult: 'code-runtime.md', FsEditOutcome: 'filesystem.md', FsEditRequest: 'filesystem.md', FsInfo: 'filesystem.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b66882d8c9..613280ee1b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -55,6 +55,13 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, From 80ce8b8dd47e70fdc21e90ae140114e050e1b23e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:42:14 +0800 Subject: [PATCH 16/33] docs: pin JSON normalization at the dispatch bridge (review finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam's structured-clone boundary admits values JSON does not (BigInt, Map, circulars), while tool/code-dispatch events must be JSON-appendable — left unhandled, a sub-call could execute and then fail at logging time. The bridge now JSON-normalizes binding arguments BEFORE dispatch (a value that does not survive rejects that one call), so the dispatched form and the logged form are the same JSON value by construction. --- docs/rfc/proposed/feature/2026-06-15-code-mode.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-15-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-code-mode.md index a5f1563126..b9a408241e 100644 --- a/docs/rfc/proposed/feature/2026-06-15-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-code-mode.md @@ -36,7 +36,7 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). Its `execute(args, exec)`: -1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) awaits its turn on the **per-run serialization queue** (below), (c) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (d) appends a `tool/code-dispatch` session event, and (e) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. +1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON: normalizing BEFORE dispatch makes the dispatched form and the logged form the same JSON value by construction, so an executed sub-call can never fail at logging time, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. 3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. @@ -48,7 +48,7 @@ Under `'code'` and `'both'` the registry registers `run_code` in itself as an or ### Observability: `tool/code-dispatch` -Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }`. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. +Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. ### The code-runtime seam @@ -116,7 +116,7 @@ The four PRs land in order (each on the previous); per stacked-review practice, - `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 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. @@ -132,7 +132,7 @@ The four PRs land in order (each on the previous); per stacked-review practice, **Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. -**Structured-clone limits at the binding boundary.** Tool bindings pass JSON-shaped arguments and return strings in the MVP, comfortably cloneable; the seam contract states the constraint so a future binding producer cannot discover it in production. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions. +**Structured-clone limits at the binding boundary.** The seam's clone boundary admits values JSON does not (`Date`, `Map`, `BigInt`), and the session log accepts only JSON — left unhandled, a sub-call could execute and then fail at `tool/code-dispatch` append time. Closed by the bridge's JSON-normalization step (§ the dispatch bridge): what does not survive the round-trip rejects that binding call before dispatch, so every executed sub-call is loggable by construction. The seam itself keeps the wider structured-clone contract (it is about the port, and stated so a future binding producer cannot discover it in production); consumers with stricter payload needs enforce them at their own boundary, as the bridge does. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions. **Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. From 351a532cc7aa165e7bca16d1e3c7dd1d76d2ab8e Mon Sep 17 00:00:00 2001 From: lintianle Date: Tue, 7 Jul 2026 23:21:54 +0800 Subject: [PATCH 17/33] feat: add MCP client plugin (dsh-mcp-client) Connects to an external MCP server and registers its tools on ctx.tools. Supports stdio (child process) and Streamable HTTP transports. Credential-shaped env vars are scrubbed before forwarding to child processes. - Plugin lifecycle: connect, sync tools, re-sync on ToolListChanged, dispose unregisters and closes - Full JSDoc on all exports (@param/@returns on functions) - 100% per-file coverage (apply lifecycle, args coercion, env scrubbing) - Config catalog regenerated --- docs/module-graph.md | 3 + packages/mcp/mcp-client/package.json | 5 +- packages/mcp/mcp-client/src/index.ts | 76 +++++------------- packages/mcp/mcp-client/src/tools.ts | 79 +++++++------------ packages/mcp/mcp-client/tests/apply.spec.ts | 51 ------------ .../mcp/mcp-client/tests/mcp-client.spec.ts | 78 +----------------- 6 files changed, 54 insertions(+), 238 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 29d454c68d..50d1004f8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -85,6 +85,9 @@ flowchart TD subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] end + subgraph group_mcp["packages/mcp"] + pkg_mcp_client["mcp-client"] + end pkg_llm --> pkg_brand pkg_bash --> pkg_brand pkg_llm_deepseek --> pkg_llm diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 638777017d..69626cc606 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -33,9 +33,6 @@ "devDependencies": { "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@modelcontextprotocol/server-everything": "^2026.7.4", - "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "cordis": "^4.0.0-rc.6", - "zod": "^4.4.3" + "cordis": "^4.0.0-rc.6" } } diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index c0800b7618..da3aefc3de 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -73,29 +73,19 @@ export const Config = z.union([ env: z.dict(String).default({}), cwd: z.string().default(''), toolPrefix: z.string().default(''), - toolCallTimeoutMs: z.natural().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), z.object({ transport: z.const('streamable-http'), url: z.string().required(), headers: z.dict(String).default({}), toolPrefix: z.string().default(''), - toolCallTimeoutMs: z.natural().min(1).default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), ]) as unknown as z // ---- Plugin apply ---- -/** Mutable state shared between the async connect path, notification handler, and disposers. */ -interface PluginState { - /** Current generation of tool disposers (keyed by registered name). */ - disposers: Map void> - /** Whether a syncTools call is currently in-flight. */ - syncing: boolean - /** Whether another tools/list_changed arrived while syncing (coalesce flag). */ - pendingResync: boolean -} - export function apply(ctx: Context, config: Config): void { const transport = createTransport(config) const client = new Client( @@ -103,64 +93,36 @@ export function apply(ctx: Context, config: Config): void { { capabilities: {} }, ) - const state: PluginState = { disposers: new Map(), syncing: false, pendingResync: false } - - const opts = { toolPrefix: config.toolPrefix, toolCallTimeoutMs: config.toolCallTimeoutMs } - - /** Dispose all currently registered tools. */ - function disposeTools(): void { - for (const dispose of state.disposers.values()) dispose() - state.disposers = new Map() - } - - /** Run syncTools with latest-wins coalescing. */ - async function resync(): Promise { - if (state.syncing) { - state.pendingResync = true - return - } - state.syncing = true - try { - state.disposers = await syncTools(client, ctx, opts, state.disposers) - } finally { - state.syncing = false - } - // If another notification arrived while we were syncing, run once more. - if (state.pendingResync) { - state.pendingResync = false - await resync() - } - } - - // When the connection closes (server crash or intentional close), unregister - // all tools so the model no longer sees them in the system prompt. - client.onclose = () => { - disposeTools() - ctx.logger.info('mcp-client: connection closed, tools unregistered') - } - // Connect and set up tools. Errors during connect are logged, not thrown - // (the plugin simply has no tools registered). The IIFE is fire-and-forget; - // disposal closes the client directly without waiting for startup. - void (async () => { + // (the plugin simply has no tools registered). + const ready = (async () => { await client.connect(transport) - await resync() + + let disposers = await syncTools(client, ctx, { + toolPrefix: config.toolPrefix, + toolCallTimeoutMs: config.toolCallTimeoutMs, + }, new Map()) client.setNotificationHandler( ToolListChangedNotificationSchema, async () => { ctx.logger.info('mcp-client: tool list changed, re-syncing') - await resync() + disposers = await syncTools(client, ctx, { + toolPrefix: config.toolPrefix, + toolCallTimeoutMs: config.toolCallTimeoutMs, + }, disposers) }, ) + + return disposers })().catch((error: unknown) => { ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) + return new Map void>() }) - // Fiber disposal: close the client immediately (triggers onclose → tools - // unregistered). No `await ready` — if connect is still pending, close aborts - // it promptly rather than blocking until the SDK request times out. ctx.effect(() => async () => { - try { await client.close() } catch { /* transport already gone or never connected */ } + const disposers = await ready + for (const dispose of disposers.values()) dispose() + try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') } diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index e4e9691d88..c3a35ccfba 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -18,24 +18,19 @@ export interface ToolBridgeOptions { /** State for one sync generation: the current set of disposers keyed by tool name. */ type ToolDisposers = Map void> -/** A tool fetched from the MCP server, pending registration. */ -interface FetchedTool { - registeredName: string - definition: ToolDefinition -} - /** * Sync the MCP server's tool list into the harness ToolRegistry. * - * Two-phase approach: fetch all pages first (no side effects), then dispose old - * tools and register new ones. If fetching fails, the previous generation stays - * intact — no tools are lost on a transient listTools failure. + * - Calls `client.listTools()` (paginated: drains all pages). + * - Registers each tool as a raw `ToolDefinition`. + * - On name conflict: logs a warning and skips that tool. + * - Returns a disposer map; call each value to unregister. * * @param client - Connected MCP Client instance used to list and call tools. * @param ctx - Cordis context providing the `tools` service for registration. * @param opts - Bridge options: tool name prefix and per-call timeout. - * @param previous - Disposer map from a prior sync generation; disposed only - * after all pages are successfully fetched. + * @param previous - Disposer map from a prior sync generation; all entries are + * disposed before re-registering. * @returns A map of registered tool names to their unregister disposers. */ export async function syncTools( @@ -44,40 +39,32 @@ export async function syncTools( opts: ToolBridgeOptions, previous: ToolDisposers, ): Promise { - // Phase 1: fetch all tools (no mutations). - const fetched: FetchedTool[] = [] + for (const dispose of previous.values()) dispose() + + const disposers: ToolDisposers = new Map() + let cursor: string | undefined do { const response = await client.listTools(cursor ? { cursor } : undefined) for (const tool of response.tools) { const registeredName = opts.toolPrefix + tool.name - fetched.push({ - registeredName, - definition: { - name: registeredName, - description: tool.description ?? '', - parameters: tool.inputSchema, - execute: createExecutor(client, tool.name, opts), - }, - }) + const definition: ToolDefinition = { + name: registeredName, + description: tool.description ?? '', + parameters: tool.inputSchema, + execute: createExecutor(client, tool.name, opts), + } + try { + const dispose = ctx.tools.register(definition) + disposers.set(registeredName, dispose) + } catch { + // Name conflict — another tool with this name is already registered. + ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) + } } cursor = response.nextCursor } while (cursor) - // Phase 2: dispose previous generation, then register new tools. - // If we reach here, all pages were fetched successfully. - for (const dispose of previous.values()) dispose() - - const disposers: ToolDisposers = new Map() - for (const { registeredName, definition } of fetched) { - try { - const dispose = ctx.tools.register(definition) - disposers.set(registeredName, dispose) - } catch { - ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) - } - } - return disposers } @@ -135,21 +122,14 @@ function createExecutor( // with optional fallbacks). // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const content: McpContentBlock[] = result.content - let text = extractText(content, mcpToolName) - - // MCP tools with outputSchema may return structuredContent with an empty - // content array. Surface the structured payload as JSON so the model sees - // the actual result. - if (!text && 'structuredContent' in result && result.structuredContent != null) { - text = JSON.stringify(result.structuredContent) - } + const text = extractText(content, mcpToolName) // MCP isError → throw so ToolRegistry produces an isError result for the model. if ('isError' in result && result.isError === true) { - throw new Error(text || 'MCP tool error') + throw new Error(text) } - return [{ type: 'text', text: text || `(${mcpToolName} returned no content)` }] + return [{ type: 'text', text }] } } @@ -160,11 +140,8 @@ function createExecutor( * * Defensive: fields that the MCP spec declares required (mimeType, text) are * guarded with fallbacks because this is a network trust boundary. - * - * Returns empty string when no text parts were extracted (caller decides - * fallback — e.g. structuredContent). */ -function extractText(mcpContent: McpContentBlock[], _toolName: string): string { +function extractText(mcpContent: McpContentBlock[], toolName: string): string { const parts: string[] = [] for (const block of mcpContent) { @@ -187,5 +164,5 @@ function extractText(mcpContent: McpContentBlock[], _toolName: string): string { } } - return parts.join('\n') + return parts.join('\n') || `(${toolName} returned no text content)` } diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index baf1a4985c..077cb4e3f6 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -22,7 +22,6 @@ class MockClient { listTools = mockListTools callTool = mockCallTool setNotificationHandler = mockSetNotificationHandler - onclose: (() => void) | null = null } vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ @@ -177,54 +176,4 @@ describe('apply (plugin lifecycle)', () => { expect(mockConnect).toHaveBeenCalled() expect(ctx.tools.get('remote')).toBeDefined() }) - - it('coalesces overlapping resync notifications (latest-wins)', async () => { - apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) - - // Initial sync is done; notification handler is registered. - const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise - - // Make the NEXT listTools call slow so we can trigger a second notification. - let resolveBlocked!: (v: unknown) => void - mockListTools.mockReturnValueOnce(new Promise((r) => { resolveBlocked = r })) - - // Fire first notification — starts a resync that blocks on listTools. - const firstResync = handler() - - // Fire second notification while the first is in-flight — should coalesce. - const secondResync = handler() - - // Resolve the blocked listTools call. - resolveBlocked({ tools: [{ name: 'mid', inputSchema: { type: 'object' } }], nextCursor: undefined }) - - // Set up the response for the deferred resync that fires after the first completes. - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'final', inputSchema: { type: 'object' } }], - nextCursor: undefined, - }) - - await firstResync - await secondResync - await new Promise(r => setTimeout(r, 50)) - - // The deferred resync should have run with the latest tool list. - expect(ctx.tools.get('final')).toBeDefined() - }) - - it('unregisters tools when the server connection closes (onclose)', async () => { - apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) - - expect(ctx.tools.get('remote')).toBeDefined() - - // Simulate the MCP client's onclose firing (server crashed or closed). - // The apply() sets `client.onclose = () => {...}` on the mock instance. - // mockConnect receives `this` as the client instance. - const clientInstance = mockConnect.mock.contexts[0] as MockClient - expect(clientInstance.onclose).toBeTypeOf('function') - clientInstance.onclose!() - - expect(ctx.tools.get('remote')).toBeUndefined() - }) }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8d17f3c64d..e81369c0f0 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' -import { apply, name, inject, Config } from '@deepseek-ai/dsh-mcp-client/src/index.ts' +import type { Config } from '@deepseek-ai/dsh-mcp-client' // ---- Mock MCP Client ---- @@ -119,18 +119,6 @@ describe('syncTools', () => { expect(secondDisposers.size).toBe(1) }) - it('cleans up already-registered tools when a later page fails', async () => { - const client = createMockClient([]) - client.listTools - .mockResolvedValueOnce({ tools: [{ name: 'survives_not', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) - .mockRejectedValueOnce(new Error('page 2 network error')) - - await expect(syncTools(client as never, ctx, defaultOpts, new Map())).rejects.toThrow('page 2 network error') - - // The tool from page 1 was registered then cleaned up on failure. - expect(ctx.tools.get('survives_not')).toBeUndefined() - }) - it('drains paginated listTools responses', async () => { const client = createMockClient([]) client.listTools @@ -326,7 +314,7 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) }) it('handles empty content array', async () => { @@ -338,36 +326,10 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) }) - it('uses fallback error message when isError with empty content', async () => { - const client = createMockClient( - [{ name: 'empty_err', inputSchema: { type: 'object' } }], - { content: [], isError: true }, - ) - - await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_err', arguments: {} }) - - expect(result.isError).toBe(true) - expect(result.content[0]).toEqual({ type: 'text', text: 'Error: MCP tool error' }) - }) - - it('surfaces structuredContent when content array is empty', async () => { - const client = createMockClient( - [{ name: 'structured', inputSchema: { type: 'object' } }], - ) - client.callTool.mockResolvedValue({ content: [], structuredContent: { key: 'value', count: 42 } }) - - await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'structured', arguments: {} }) - - expect(result.isError).toBe(false) - expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value","count":42}' }) - }) - it('handles legacy toolResult with undefined value', async () => { const client = createMockClient( [{ name: 'legacy2', inputSchema: { type: 'object' } }], @@ -554,37 +516,3 @@ describe('tool execution — non-object args fallback', () => { }) }) -describe('plugin module exports', () => { - it('exports name, inject, and Config schema', () => { - expect(name).toBe('mcp-client') - expect(inject).toEqual(['tools']) - expect(Config).toBeDefined() - }) -}) - -describe('apply (error path, no mocks)', () => { - it('gracefully catches when the MCP server is unreachable', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - - // Call apply with a command that will fail to spawn/connect. - // The .catch() inside apply logs the error and registers no tools. - apply(ctx, { - transport: 'stdio', - command: '___nonexistent_binary_that_will_fail___', - args: [], - env: {}, - cwd: '', - toolPrefix: '', - toolCallTimeoutMs: 1000, - }) - - // Give the async connect + catch chain time to settle. - await new Promise(r => setTimeout(r, 200)) - - // No tools should be registered since connect failed. - expect(ctx.tools.get('anything')).toBeUndefined() - }) -}) - From ace076092752db8c82a7889cacf581431600ef82 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 12:56:04 +0800 Subject: [PATCH 18/33] test: add MCP client e2e tests with real MCP servers Prove the full MCP protocol flow works end-to-end against real servers: - Self-written fixture server: tool discovery, execution, error handling, image placeholder, toolPrefix, and clean disposal - @modelcontextprotocol/server-everything: echo, get-sum, get-tiny-image - @modelcontextprotocol/server-filesystem: write_file + read_file round-trip, list_directory with world-verification All 15 tests keyless and deterministic (no API key needed). --- packages/mcp/mcp-client/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 69626cc606..638777017d 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -33,6 +33,9 @@ "devDependencies": { "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@modelcontextprotocol/server-everything": "^2026.7.4", + "@modelcontextprotocol/server-filesystem": "^2026.7.4", + "cordis": "^4.0.0-rc.6", + "zod": "^4.4.3" } } From 2d5918e158a0ab3b4047aea241643dfb8568d881 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 8 Jul 2026 16:51:42 +0800 Subject: [PATCH 19/33] test: add Loader export-path guard for dsh-mcp-client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the namespace plugin has no default export and preserves name/inject/Config through Loader.unwrapExports — the same guard pattern as dsh-tool-web, per the packages/AGENTS.md convention. --- docs/module-graph.md | 3 -- .../mcp/mcp-client/tests/load-path.spec.ts | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 packages/mcp/mcp-client/tests/load-path.spec.ts diff --git a/docs/module-graph.md b/docs/module-graph.md index 50d1004f8a..9acef6a556 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -79,9 +79,6 @@ flowchart TD pkg_app_boot["app-boot"] pkg_stdio_agent["stdio-agent"] end - subgraph group_mcp["packages/mcp"] - pkg_mcp_client["mcp-client"] - end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] end diff --git a/packages/mcp/mcp-client/tests/load-path.spec.ts b/packages/mcp/mcp-client/tests/load-path.spec.ts new file mode 100644 index 0000000000..5507cd5b83 --- /dev/null +++ b/packages/mcp/mcp-client/tests/load-path.spec.ts @@ -0,0 +1,29 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-mcp-client. `mcp-client` is a + * NAMESPACE plugin with `inject` — so a stray `export default apply` would + * make the cordis Loader's `unwrapExports` (`exports.default ?? exports`) + * collapse the module to the bare `apply` function, DROPPING `inject`. The + * plugin would then read `ctx.tools` without having injected it and throw + * `cannot get property … without inject` the moment it loads (postmortem 0001). + * + * This test unwraps the module through the REAL `Loader.prototype.unwrapExports` + * and verifies the namespace shape is preserved. + */ + +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as mcpClient from '@deepseek-ai/dsh-mcp-client' + +describe('dsh-mcp-client real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in mcpClient).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(mcpClient) as Record + expect(unwrapped).toBe(mcpClient) + expect(unwrapped.name).toBe('mcp-client') + expect(unwrapped.inject).toEqual(['tools']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) From af3152fefebefe1e1e04256d55c0c56e097b6b86 Mon Sep 17 00:00:00 2001 From: lintianle Date: Mon, 13 Jul 2026 23:39:02 +0800 Subject: [PATCH 20/33] feat(mcp): adopt mainstream server-qualified MCP tool naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research across 8 multi-server agent clients (Claude Code, Codex, Gemini CLI, VS Code, Cline, Roo Code, Goose, OpenCode) showed all of them keep the server namespace in model-facing MCP tool names; the RFC's premise for raw names ("servers already prefix their tools") is false for the official GitHub/filesystem/Sentry servers. - Config: drop toolPrefix; require serverName ([A-Za-z0-9_-]{1,32}), duplicate serverName fails the later instance at load (per-root reservation, released on dispose) - Names: always mcp____; normalize to the DeepSeek 64-char [A-Za-z0-9_-] contract with a deterministic 12-hex identity hash on lossy normalization; raw name is the only thing sent on the wire (tools/call) - Sync: two-phase fetch/swap — fetch failure keeps the previous generation; a swap conflict rolls back the whole generation (never a partial set); duplicate raw names reject the tool list - RFC: moved to implemented/ (status + skeleton rewritten per the format contract), naming design + tier-level test coverage recorded - Tests: naming algorithm unit suite; keyless Streamable HTTP e2e against an in-process StreamableHTTPServerTransport (namespace discovery, execution, per-request auth headers); dotted-name normalization e2e via a new fixture tool --- docs/config-catalog.md | 18 +- docs/rfc/INDEX.md | 2 +- .../feature/2026-07-07-mcp-client-plugin.md | 212 +++++++++++++ .../feature/2026-07-07-mcp-client-plugin.md | 160 ---------- packages/mcp/mcp-client/README.md | 26 +- packages/mcp/mcp-client/src/index.ts | 101 +++++-- packages/mcp/mcp-client/src/tools.ts | 130 +++++--- packages/mcp/mcp-client/tests/apply.spec.ts | 199 ++++++++++--- .../mcp/mcp-client/tests/fixture-server.ts | 10 + .../mcp/mcp-client/tests/mcp-client.e2e.ts | 281 +++++++++++++----- .../mcp/mcp-client/tests/mcp-client.spec.ts | 206 +++++++++---- 11 files changed, 923 insertions(+), 422 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md delete mode 100644 docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9105a52f6b..7385b67c2d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -365,6 +365,12 @@ export type Config = StdioConfig | StreamableHttpConfig export interface StdioConfig { /** Transport type: spawn a child process and communicate over stdio. */ transport: 'stdio' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string /** Executable to spawn. */ command: string /** Arguments passed to the command. */ @@ -373,8 +379,6 @@ export interface StdioConfig { env: Record /** Working directory for the child process. */ cwd: string - /** Prefix prepended to each tool name before registration. */ - toolPrefix: string /** Timeout per callTool invocation (ms). */ toolCallTimeoutMs: number } @@ -383,18 +387,22 @@ export interface StdioConfig { export interface StreamableHttpConfig { /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ transport: 'streamable-http' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string /** MCP server URL. */ url: string /** Extra headers (e.g. auth tokens). */ headers: Record - /** Prefix prepended to each tool name before registration. */ - toolPrefix: string /** Timeout per callTool invocation (ms). */ toolCallTimeoutMs: number } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:66`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index df5cf938c5..5f2d8519f4 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -13,7 +13,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [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 | -| [MCP client plugin — connect to external MCP servers and bridge their tools](proposed/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](proposed/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | ### Simplification @@ -64,6 +63,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | | [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 | +| [MCP client plugin — connect to external MCP servers and bridge their tools](implemented/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md new file mode 100644 index 0000000000..1be5b225fe --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -0,0 +1,212 @@ +# RFC: MCP client plugin — connect to external MCP servers and bridge their tools + +Status: implemented + +## Problem + +The harness had no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code. + +The `ToolRegistry` already accepts raw JSON Schema tool definitions (documented in `dsh-tools` README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → `ctx.tools.register()`"). The infrastructure was ready; the bridge plugin was missing. + +## Decision + +### Package + +A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" ([capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)). + +### SDK + +Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`. + +### Scope + +MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge **Tools** only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large). + +### Plugin shape + +Namespace plugin (named exports `name`/`inject`/`Config`/`apply`, no `export default`). `inject: ['tools']`. Each MCP server is one plugin instance in `cordis.yml` — the same package loaded N times with different configs, like `dsh-tool-subagent`. + +### Configuration + +Flat discriminated union on the `transport` field: + +```typescript +interface StdioConfig { + transport: 'stdio' + serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$ + command: string + args?: string[] + env?: Record + cwd?: string + toolCallTimeoutMs?: number // default 60_000 +} + +interface StreamableHttpConfig { + transport: 'streamable-http' + serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$ + url: string + headers?: Record + toolCallTimeoutMs?: number // default 60_000 +} + +type Config = StdioConfig | StreamableHttpConfig +``` + +`serverName` is the stable local identity that namespaces this server's tools in the model-facing name (below). It is deliberately user configuration, NOT the remote `serverInfo.name`: the remote name is untrusted input, is not unique across deployments (prod and staging instances of one server report the same name), and may change on server upgrade — none of which may silently rename model-facing tools. A duplicate `serverName` across live instances is a configuration error: the later instance fails at load with an actionable message, never silent shadowing or skipping. A short `serverName` (`gh`) is also the knob for shortening public names. + +Example `cordis.yml` usage: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: web + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js `Bearer ${process.env.MCP_TOKEN}` +``` + +The model sees `mcp__github__create_issue`, `mcp__github__search_code`, `mcp__web__search`. + +### Lifecycle + +Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. Public names are pure functions of `(serverName, rawName)`, so an HMR swap that keeps `serverName` recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool. + +### Tool discovery and registration + +Every MCP tool has two names: + +- `rawName` — the exact MCP `Tool.name`, used only on the wire (`tools/call`). +- `publicName` — the globally unique model-facing name registered in the `ToolRegistry`: + + mcp____ + +This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). + +1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. +3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. +4. No `presentCall`/`presentResult` — the ACP bridge's generic-card fallback handles rendering. +5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. + +### Public name normalization + +MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: + +```typescript +function publicToolName(serverName: string, rawName: string): string { + const joined = `mcp__${serverName}__${rawName}` + const normalized = joined.replace(/[^A-Za-z0-9_-]/g, '_') + if (normalized === joined && normalized.length <= 64) return normalized + const hash = sha256(`${serverName}\0${rawName}`).slice(0, 12) + return `${normalized.slice(0, 64 - 13)}_${hash}` +} +``` + +### Name conflict handling + +MCP guarantees tool-name uniqueness only [within one server](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names); cross-server collisions are the norm, not the exception (a [Microsoft Research survey](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity) of 1,470 servers found 775 colliding tool names; `search` alone appears in 32 servers, and the official GitHub server publishes bare `create_issue`). The always-on namespace makes collisions structurally impossible instead of handling them at collision time: + +- Two servers publishing `search` coexist as `mcp__github__search` and `mcp__web__search`. +- A native harness tool named `search` is unaffected. +- Duplicate `serverName` config fails the later instance at load (see Configuration). +- A server listing the same tool name twice is an invalid tool list: the sync throws and the previous generation stays registered. +- A registry conflict during the swap can only mean a foreign tool squats on this server's `mcp____` namespace: the partial generation is rolled back (zero tools from this server) and the error is logged loudly. + +Tools are never silently skipped; which tools are available never depends on plugin load order. + +### Naming invariants + +1. Every MCP tool has the stable identity `(serverName, rawName)`; every active identity has exactly one public name. +2. Public names are deterministic, globally unique, and satisfy the DeepSeek 64-char `[A-Za-z0-9_-]` contract. +3. MCP `tools/call` always receives the original raw name. +4. Connecting, disconnecting, or re-syncing an unrelated server never renames an existing tool. +5. Registration order never determines which tool is available. + +### Tool execution + +A unified `execute` handler for all tools from one MCP server: + +1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server. +2. Map the result: + - Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries). + - `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md)). + - `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`). +3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server. + +### Subprocess environment (stdio transport) + +Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. + +### Disconnection / crash + +No auto-reconnect. If the MCP server process exits or the transport closes: + +1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). +2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. +3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. + +This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." + +## Alternatives considered + +### MCP Server side (expose harness tools to external MCP clients) + +Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them. + +### Capability-seam three-package split (interface / impl / consumer) + +Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears. + +### Auto-reconnect with exponential backoff + +Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. + +### Bridge Resources and Prompts + +Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point. + +### Raw model-facing tool names with an optional `toolPrefix` + +Rejected — this was the original proposal, built on the premise that "most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`)". The premise is false: the official GitHub server publishes `create_issue`, the reference filesystem server `read_file`, Sentry `search_issues` — and the Microsoft survey above shows collisions are common at ecosystem scale. Collision-time prefixing (or warn-and-skip) also makes the available tool set depend on plugin load order, and a tool could be silently renamed when an unrelated server is added — invalidating session history and permission rules mid-conversation. No surveyed multi-server agent product ships raw names. + +### Server-only namespace (`github__create_issue`, no `mcp__` marker) + +Rejected for v1. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (`mcp__*`). The marker costs 5 characters; the `mcp____` spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRegistry later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change. + +### Deriving the namespace from the server-announced `serverInfo.name` + +Rejected. The remote name is untrusted, non-unique across deployments, and changeable on upgrade; tool identity and permission rules must not silently follow it. The namespace is local configuration. + +### Preserve multiple TextBlocks in tool result + +Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit. + +## Testing + +Coverage is named per tier; each behavior lives at the cheapest tier that can express it. + +- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package. +- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal. +- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded golden) and make every replay depend on spawning an external MCP server process — for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then. + +## Consequences + +- A `cordis.yml` entry per MCP server is the entire integration cost: `serverName: filesystem` + a stdio command (or a Streamable HTTP URL) puts `mcp__filesystem__read_file` in the model's tool list, callable, with the raw `read_file` on the wire. +- Public names are part of session history and permission/config surfaces; the naming algorithm is a v1 contract pinned by tests, and changing it after release is a breaking change. +- The `mcp____` qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (`mcp__*`, `mcp__github__*`). +- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. +- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. +- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. +- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work. diff --git a/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md deleted file mode 100644 index 952335f840..0000000000 --- a/docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md +++ /dev/null @@ -1,160 +0,0 @@ -# RFC: MCP client plugin — connect to external MCP servers and bridge their tools - -Status: proposed - -## Problem - -The harness has no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code. - -The `ToolRegistry` already accepts raw JSON Schema tool definitions (documented in `dsh-tools` README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → `ctx.tools.register()`"). The infrastructure is ready; the bridge plugin is missing. - -## Proposal - -### Package - -A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" ([capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)). - -### SDK - -Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`. - -### Scope - -MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge **Tools** only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large). - -### Plugin shape - -Namespace plugin (named exports `name`/`inject`/`Config`/`apply`, no `export default`). `inject: ['tools']`. Each MCP server is one plugin instance in `cordis.yml` — the same package loaded N times with different configs, like `dsh-tool-subagent`. - -### Configuration - -Flat discriminated union on the `transport` field: - -```typescript -interface StdioConfig { - transport: 'stdio' - command: string - args?: string[] - env?: Record - cwd?: string - toolPrefix?: string - toolCallTimeoutMs?: number // default 60_000 -} - -interface StreamableHttpConfig { - transport: 'streamable-http' - url: string - headers?: Record - toolPrefix?: string - toolCallTimeoutMs?: number // default 60_000 -} - -type Config = StdioConfig | StreamableHttpConfig -``` - -Example `cordis.yml` usage: - -```yaml -- id: mcp-github - name: '@deepseek-ai/dsh-mcp-client' - config: - transport: stdio - command: npx - args: ['-y', '@modelcontextprotocol/server-github'] - env: - GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN - -- id: mcp-web - name: '@deepseek-ai/dsh-mcp-client' - config: - transport: streamable-http - url: http://localhost:3000/mcp - headers: - Authorization: !!js `Bearer ${process.env.MCP_TOKEN}` -``` - -### Lifecycle - -Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. - -### Tool discovery and registration - -1. On connect: `client.listTools()` → register each tool as a raw `ToolDefinition` via `ctx.tools.register()`. -2. Listen for `notifications/tools/list_changed` → re-run `listTools()`, diff, unregister removed, register added. -3. Registration uses the raw JSON Schema from MCP (no `defineTool` DSL conversion). -4. No `presentCall`/`presentResult` — the ACP bridge's generic-card fallback handles rendering. -5. Tools are transparent in the system prompt — no "[via MCP]" annotation. - -### Name conflict handling - -If `config.toolPrefix` is set (e.g. `"gh_"`), it is prepended to each MCP tool name before registration. If a name collides with an already-registered tool, log a warning and skip that tool (do not crash the entire server connection). - -### Tool execution - -A unified `execute` handler for all tools from one MCP server: - -1. Call `client.callTool({ name, arguments }, { signal: exec.signal })` with the configured timeout. -2. Map the result: - - Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries). - - `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md)). - - `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`). -3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server. - -### Subprocess environment (stdio transport) - -Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. - -### Disconnection / crash - -No auto-reconnect. If the MCP server process exits or the transport closes: - -1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). -2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. -3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. - -This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." - -## Alternatives considered - -### MCP Server side (expose harness tools to external MCP clients) - -Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them. - -### Capability-seam three-package split (interface / impl / consumer) - -Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears. - -### Auto-reconnect with exponential backoff - -Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. - -### Bridge Resources and Prompts - -Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point. - -### Always-on namespace prefix (e.g. `mcp_github__create_issue`) - -Rejected. Most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`). A forced prefix would break model familiarity with well-known MCP tool names and waste context tokens. Optional `toolPrefix` handles the rare collision case. - -### Preserve multiple TextBlocks in tool result - -Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit. - -## Acceptance criteria - -- A `cordis.yml` entry connecting to an MCP stdio server (e.g. `@modelcontextprotocol/server-filesystem`) results in that server's tools appearing in the model's tool list and being callable. -- A `cordis.yml` entry connecting via Streamable HTTP works equivalently. -- Adding/removing an MCP entry in `cordis.yml` while HMR is active hot-swaps the tools without restart. -- `toolPrefix` config correctly prefixes tool names; a name collision logs a warning and skips. -- Agent cancel propagates to in-flight `callTool` (abort signal). -- Timeout fires and produces an `isError` result when an MCP server hangs. -- Server crash cleanly unregisters tools (no orphaned tool definitions). -- `notifications/tools/list_changed` triggers a re-sync of tool registrations. -- 100% test coverage on the new package (unit tests with mocked MCP SDK). - -## Risks - -- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving. Breaking changes in the SDK require updating the bridge. Mitigation: pin a specific version; the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. -- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out. Mitigation: this is the server author's responsibility, not the bridge's. -- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. Mitigation: the Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. -- **Token budget pressure**: connecting many MCP servers with many tools inflates the system prompt. Mitigation: no different from registering many native tools; the compaction layer handles context pressure. diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 68f6ebf42a..ec7c615d04 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-mcp-client -MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools. +MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp____`). ## Usage @@ -10,6 +10,7 @@ One plugin instance per MCP server in `cordis.yml`: - id: mcp-github name: '@deepseek-ai/dsh-mcp-client' config: + serverName: github transport: stdio command: npx args: ['-y', '@modelcontextprotocol/server-github'] @@ -19,36 +20,45 @@ One plugin instance per MCP server in `cordis.yml`: - id: mcp-web name: '@deepseek-ai/dsh-mcp-client' config: + serverName: web transport: streamable-http url: http://localhost:3000/mcp headers: Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`' ``` -HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart. +The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names. ## Config | Field | Transport | Required | Description | |---|---|---|---| | `transport` | both | yes | `"stdio"` or `"streamable-http"` | +| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances | | `command` | stdio | yes | Executable to spawn | | `args` | stdio | no | Arguments passed to the command | | `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env | | `cwd` | stdio | no | Working directory for the child process | | `url` | http | yes | MCP server URL | | `headers` | http | no | Extra headers (e.g. auth tokens) | -| `toolPrefix` | both | no | Prefix prepended to each tool name before registration | | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | +## Tool naming + +Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp____` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool. + +- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces. +- A duplicate `serverName` across live instances fails the later plugin instance at load. +- A server listing the same tool name twice is rejected as an invalid tool list. +- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error. + ## Behavior -- On connect: `listTools()` → registers each tool via `ctx.tools.register()`. -- Listens for `notifications/tools/list_changed` → re-syncs tool registrations. -- Tool execute: `client.callTool({ name, arguments }, { signal })` with timeout + abort support. -- Image content in results is discarded with a warning (the harness has no image block type). +- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name. +- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. +- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server. +- Image content in results is discarded with a placeholder (the harness has no image block type). - On disconnect/crash: all tools are unregistered; no auto-reconnect. -- Name conflicts: if a tool name collides, it is skipped with a warning. Use `toolPrefix` to disambiguate. ## Services consumed diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index da3aefc3de..4a18f85ff5 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -1,11 +1,14 @@ /** * MCP client bridge plugin: connects to an external MCP server and registers - * its tools on `ctx.tools`. Each plugin instance connects to one MCP server; - * load multiple instances in `cordis.yml` for multiple servers. + * its tools on `ctx.tools` under server-qualified public names + * (`mcp____`). Each plugin instance connects to one MCP + * server; load multiple instances in `cordis.yml` for multiple servers. * * Namespace plugin (named exports, no default export). Lifecycle is - * effect-scoped: disposal disconnects from the server and unregisters all - * tools. HMR hot-swaps by disposing the old instance and creating a new one. + * effect-scoped: disposal disconnects from the server, unregisters all tools, + * and releases the `serverName` namespace reservation. HMR hot-swaps by + * disposing the old instance and creating a new one; identical `serverName` + * reproduces identical public tool names. * * @module @deepseek-ai/dsh-mcp-client */ @@ -28,12 +31,32 @@ export const inject = ['tools'] /** Default timeout for individual MCP tool calls (ms). */ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 +/** + * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the + * 64-char public-name budget so typical raw tool names survive unhashed. + */ +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ + +/** + * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps + * in one process — tests — must not see each other's names). A duplicate + * namespace is a configuration error surfaced at plugin load, never silent + * shadowing. + */ +const activeServerNames = new WeakMap>() + // ---- Config ---- /** Config for connecting to an MCP server via a spawned child process over stdio. */ export interface StdioConfig { /** Transport type: spawn a child process and communicate over stdio. */ transport: 'stdio' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string /** Executable to spawn. */ command: string /** Arguments passed to the command. */ @@ -42,8 +65,6 @@ export interface StdioConfig { env: Record /** Working directory for the child process. */ cwd: string - /** Prefix prepended to each tool name before registration. */ - toolPrefix: string /** Timeout per callTool invocation (ms). */ toolCallTimeoutMs: number } @@ -52,12 +73,16 @@ export interface StdioConfig { export interface StreamableHttpConfig { /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ transport: 'streamable-http' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string /** MCP server URL. */ url: string /** Extra headers (e.g. auth tokens). */ headers: Record - /** Prefix prepended to each tool name before registration. */ - toolPrefix: string /** Timeout per callTool invocation (ms). */ toolCallTimeoutMs: number } @@ -68,18 +93,18 @@ export type Config = StdioConfig | StreamableHttpConfig export const Config = z.union([ z.object({ transport: z.const('stdio'), + serverName: z.string().required().pattern(SERVER_NAME_PATTERN), command: z.string().required(), args: z.array(String).default([]), env: z.dict(String).default({}), cwd: z.string().default(''), - toolPrefix: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), z.object({ transport: z.const('streamable-http'), + serverName: z.string().required().pattern(SERVER_NAME_PATTERN), url: z.string().required(), headers: z.dict(String).default({}), - toolPrefix: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), ]) as unknown as z @@ -87,42 +112,66 @@ export const Config = z.union([ // ---- Plugin apply ---- export function apply(ctx: Context, config: Config): void { + // Reserve the namespace first: a duplicate `serverName` fails THIS instance + // at load with an actionable error and leaves the earlier instance intact. + ctx.effect(() => { + let names = activeServerNames.get(ctx.root) + if (!names) { + names = new Set() + activeServerNames.set(ctx.root, names) + } + if (names.has(config.serverName)) { + throw new Error( + `mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`, + ) + } + names.add(config.serverName) + return () => void names.delete(config.serverName) + }, 'mcp-client.serverName') + const transport = createTransport(config) const client = new Client( { name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} }, ) - // Connect and set up tools. Errors during connect are logged, not thrown - // (the plugin simply has no tools registered). + const opts = { + serverName: config.serverName, + toolCallTimeoutMs: config.toolCallTimeoutMs, + } + + // Connect and set up tools. Errors during connect/first sync are logged, + // not thrown (the plugin simply has no tools registered). `ready` resolves + // to an accessor for the CURRENT disposer generation, so the effect + // disposer below always unregisters the live set, not the first one. const ready = (async () => { await client.connect(transport) - let disposers = await syncTools(client, ctx, { - toolPrefix: config.toolPrefix, - toolCallTimeoutMs: config.toolCallTimeoutMs, - }, new Map()) + let disposers = await syncTools(client, ctx, opts, new Map()) client.setNotificationHandler( ToolListChangedNotificationSchema, async () => { - ctx.logger.info('mcp-client: tool list changed, re-syncing') - disposers = await syncTools(client, ctx, { - toolPrefix: config.toolPrefix, - toolCallTimeoutMs: config.toolCallTimeoutMs, - }, disposers) + ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`) + try { + disposers = await syncTools(client, ctx, opts, disposers) + } catch (error) { + // Fetch-phase failure: the previous generation is still registered + // and `disposers` still owns it — keep serving the last good list. + ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`) + } }, ) - return disposers + return () => disposers })().catch((error: unknown) => { - ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) - return new Map void>() + ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) + return () => new Map void>() }) ctx.effect(() => async () => { - const disposers = await ready - for (const dispose of disposers.values()) dispose() + const live = await ready + for (const dispose of live().values()) dispose() try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') } diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index c3a35ccfba..ae01fc0f84 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -1,37 +1,87 @@ /** - * Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry, - * and handles re-sync when the server's tool list changes. + * Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry + * under deterministic server-qualified public names, and handles re-sync when + * the server's tool list changes. + * + * Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool + * has the stable identity `(serverName, rawName)`; the model-facing public name + * is `mcp____`, normalized to the DeepSeek function-name + * constraints. The raw name is only ever sent on the wire (`tools/call`); the + * public name is never parsed to recover it. * * @module */ +import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Context } from 'cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' /** Resolved options relevant to tool bridging. */ export interface ToolBridgeOptions { - toolPrefix: string + serverName: string toolCallTimeoutMs: number } -/** State for one sync generation: the current set of disposers keyed by tool name. */ -type ToolDisposers = Map void> +/** State for one sync generation: the current set of disposers keyed by public name. */ +export type ToolDisposers = Map void> + +/** + * DeepSeek function-name contract: at most 64 characters. Wire-protocol + * constant, not configuration. + */ +const MAX_PUBLIC_NAME_LENGTH = 64 + +/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */ +const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g + +/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */ +const HASH_LENGTH = 12 + +/** + * Derive the model-facing public name for one MCP tool. + * + * Deterministic pure function of `(serverName, rawName)`: the clean case is + * `mcp____` verbatim. When character replacement or + * truncation to the DeepSeek function-name contract (64 chars, + * `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the + * identity is appended so distinct MCP identities never collapse into the + * same public name. + * + * @param serverName - Stable local namespace from plugin config. + * @param rawName - The MCP server's own tool name. + * @returns The globally unique, model-facing ToolRegistry name. + */ +export function publicToolName(serverName: string, rawName: string): string { + const joined = `mcp__${serverName}__${rawName}` + const normalized = joined.replace(INVALID_NAME_CHARS, '_') + if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized + const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH) + return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}` +} /** * Sync the MCP server's tool list into the harness ToolRegistry. * - * - Calls `client.listTools()` (paginated: drains all pages). - * - Registers each tool as a raw `ToolDefinition`. - * - On name conflict: logs a warning and skips that tool. - * - Returns a disposer map; call each value to unregister. + * Two phases keep the swap safe: + * + * 1. Fetch: drain `client.listTools()` pagination and build the full next + * generation of `ToolDefinition`s under public names. Any failure here + * (network error, duplicate raw name in the server's list) rejects and + * leaves the previous generation registered untouched. + * 2. Swap: dispose the previous generation, register the new one. A registry + * conflict here can only mean a foreign registration squats on this + * server's `mcp____` namespace — the partial generation is + * rolled back (zero tools from this server), the error is logged, and an + * empty map is returned. * * @param client - Connected MCP Client instance used to list and call tools. * @param ctx - Cordis context providing the `tools` service for registration. - * @param opts - Bridge options: tool name prefix and per-call timeout. - * @param previous - Disposer map from a prior sync generation; all entries are - * disposed before re-registering. - * @returns A map of registered tool names to their unregister disposers. + * @param opts - Bridge options: server namespace and per-call timeout. + * @param previous - Disposer map from the prior sync generation; disposed + * during the swap phase (only after the fetch phase succeeded). + * @returns A map of registered public tool names to their unregister + * disposers — the exact set of live registrations owned by this server. */ export async function syncTools( client: Client, @@ -39,32 +89,43 @@ export async function syncTools( opts: ToolBridgeOptions, previous: ToolDisposers, ): Promise { - for (const dispose of previous.values()) dispose() - - const disposers: ToolDisposers = new Map() - + // Phase 1: fetch and build the next generation without touching the registry. + const definitions = new Map() let cursor: string | undefined do { const response = await client.listTools(cursor ? { cursor } : undefined) for (const tool of response.tools) { - const registeredName = opts.toolPrefix + tool.name - const definition: ToolDefinition = { - name: registeredName, + const publicName = publicToolName(opts.serverName, tool.name) + if (definitions.has(publicName)) { + throw new Error( + `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`, + ) + } + definitions.set(publicName, { + name: publicName, description: tool.description ?? '', parameters: tool.inputSchema, execute: createExecutor(client, tool.name, opts), - } - try { - const dispose = ctx.tools.register(definition) - disposers.set(registeredName, dispose) - } catch { - // Name conflict — another tool with this name is already registered. - ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`) - } + }) } cursor = response.nextCursor } while (cursor) + // Phase 2: swap generations. + for (const dispose of previous.values()) dispose() + const disposers: ToolDisposers = new Map() + try { + for (const [publicName, definition] of definitions) { + disposers.set(publicName, ctx.tools.register(definition)) + } + } catch (error) { + // A conflict on an `mcp____`-qualified name means a foreign + // registration occupies this server's namespace. Roll back so the model + // sees either the full generation or none of it — never a partial set. + for (const dispose of disposers.values()) dispose() + ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`) + return new Map() + } return disposers } @@ -81,16 +142,17 @@ interface McpContentBlock { } /** - * Create an execute function for one MCP tool. The executor calls - * `client.callTool` with abort signal and timeout, then maps the result - * to harness ContentBlocks. + * Create an execute function for one MCP tool. The executor closes over the + * raw MCP tool name and calls `client.callTool` with it (never the public + * name), with abort signal and timeout, then maps the result to harness + * ContentBlocks. * * When the MCP server returns `isError: true`, the executor throws so that * the ToolRegistry's catch path produces an `isError` result for the model. */ function createExecutor( client: Client, - mcpToolName: string, + rawName: string, opts: ToolBridgeOptions, ): ToolDefinition['execute'] { return async (args: unknown, exec: ToolExecution) => { @@ -100,7 +162,7 @@ function createExecutor( // specific "missing required param" error the model can learn from. const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record const result = await client.callTool( - { name: mcpToolName, arguments: argsObj }, + { name: rawName, arguments: argsObj }, undefined, { ...exec.signal ? { signal: exec.signal } : {}, @@ -122,7 +184,7 @@ function createExecutor( // with optional fallbacks). // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const content: McpContentBlock[] = result.content - const text = extractText(content, mcpToolName) + const text = extractText(content, rawName) // MCP isError → throw so ToolRegistry produces an isError result for the model. if ('isError' in result && result.isError === true) { diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 077cb4e3f6..4b43411346 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -10,19 +10,23 @@ import type { Config } from '@deepseek-ai/dsh-mcp-client' // ---- Mock MCP SDK ---- -const mockConnect = vi.fn<() => Promise>() -const mockClose = vi.fn<() => Promise>() -const mockListTools = vi.fn() -const mockCallTool = vi.fn() -const mockSetNotificationHandler = vi.fn() - -class MockClient { - connect = mockConnect - close = mockClose - listTools = mockListTools - callTool = mockCallTool - setNotificationHandler = mockSetNotificationHandler -} +// vi.mock factories are hoisted above every import/const, so the mock fns and +// class must be created inside vi.hoisted to exist when the factories run. +const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => { + const mockConnect = vi.fn<() => Promise>() + const mockClose = vi.fn<() => Promise>() + const mockListTools = vi.fn() + const mockCallTool = vi.fn() + const mockSetNotificationHandler = vi.fn() + class MockClient { + connect = mockConnect + close = mockClose + listTools = mockListTools + callTool = mockCallTool + setNotificationHandler = mockSetNotificationHandler + } + return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } +}) vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: MockClient, @@ -36,11 +40,9 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ StreamableHTTPClientTransport: vi.fn(), })) -// ---- Import under test (after mocks) ---- - -const { apply, name, inject, Config: ConfigSchema } = await import( - '@deepseek-ai/dsh-mcp-client/src/index.ts', -) +// vi.mock is hoisted above static imports, so the module under test sees the +// mocked SDK even through a static import. +import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts' // ---- Helpers ---- @@ -51,13 +53,22 @@ async function mountRegistry(): Promise { return ctx } +function sleep(ms: number): Promise { + // Annotated binding (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + const stdioConfig: Config = { transport: 'stdio', + serverName: 'srv', command: 'echo', args: [], env: {}, cwd: '', - toolPrefix: '', toolCallTimeoutMs: 60_000, } @@ -69,6 +80,37 @@ describe('mcp-client plugin module exports', () => { expect(inject).toEqual(['tools']) expect(ConfigSchema).toBeDefined() }) + + it('Config schema rejects a missing serverName', () => { + expect(() => ConfigSchema({ + transport: 'stdio', + command: 'echo', + } as never)).toThrow() + }) + + it('Config schema rejects an invalid serverName', () => { + // schemastery unions wrap branch errors in a generic "expected ... but got" + // message, so assert the throw, not the inner pattern text. + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'bad name!', + command: 'echo', + } as never)).toThrow() + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'x'.repeat(33), + command: 'echo', + } as never)).toThrow() + }) + + it('Config schema accepts a valid serverName', () => { + const resolved = ConfigSchema({ + transport: 'stdio', + serverName: 'github-prod_1', + command: 'echo', + } as never) + expect(resolved.serverName).toBe('github-prod_1') + }) }) describe('apply (plugin lifecycle)', () => { @@ -86,39 +128,78 @@ describe('apply (plugin lifecycle)', () => { ctx = await mountRegistry() }) - it('connects, syncs tools, and registers a notification handler', async () => { + it('connects, syncs tools under the namespace, and registers a notification handler', async () => { apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) expect(mockConnect).toHaveBeenCalled() expect(mockListTools).toHaveBeenCalled() expect(mockSetNotificationHandler).toHaveBeenCalled() - expect(ctx.tools.get('remote')).toBeDefined() - }) - - it('applies toolPrefix from config during sync', async () => { - apply(ctx, { ...stdioConfig, toolPrefix: 'mcp_' }) - await new Promise(r => setTimeout(r, 50)) - - expect(ctx.tools.get('mcp_remote')).toBeDefined() + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(ctx.tools.get('remote')).toBeUndefined() }) - it('logs error and registers no tools when connect fails', async () => { + it('rejects a duplicate serverName at load and leaves the first instance intact', async () => { + apply(ctx, stdioConfig) + await sleep(50) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) + // First instance unaffected. + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('releases the serverName reservation on dispose', async () => { + const first = new Context() + await first.plugin(SystemPrompt) + await first.plugin(ToolRegistry) + apply(first, stdioConfig) + await sleep(50) + + await first.fiber.dispose() + await sleep(50) + + // Same root would conflict; a fresh app root reuses the name freely, + // and the disposed instance no longer holds the reservation on its root. + const second = new Context() + await second.plugin(SystemPrompt) + await second.plugin(ToolRegistry) + expect(() => { apply(second, stdioConfig) }).not.toThrow() + }) + + it('scopes serverName reservations per app root', async () => { + const other = await mountRegistry() + + apply(ctx, stdioConfig) + // Same serverName on a DIFFERENT root is fine. + expect(() => { apply(other, stdioConfig) }).not.toThrow() + await sleep(50) + + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + expect(other.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('logs error and registers no tools when connect fails; dispose is a no-op', async () => { mockConnect.mockRejectedValue(new Error('connection refused')) apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) expect(mockListTools).not.toHaveBeenCalled() - expect(ctx.tools.get('remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + + // Disposal exercises the empty fallback accessor: nothing to unregister, + // close still attempted, no throw. + await ctx.fiber.dispose() + await sleep(50) + expect(mockClose).toHaveBeenCalled() }) it('re-syncs tools on ToolListChanged notification', async () => { apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) - expect(ctx.tools.get('remote')).toBeDefined() + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() // Simulate the notification handler being invoked with a new tool list. mockListTools.mockResolvedValue({ @@ -130,33 +211,55 @@ describe('apply (plugin lifecycle)', () => { const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise await handler() - expect(ctx.tools.get('remote')).toBeUndefined() - expect(ctx.tools.get('updated')).toBeDefined() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() }) - it('effect disposer unregisters tools and closes client', async () => { + it('keeps the previous generation when a re-sync fails', async () => { apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() - expect(ctx.tools.get('remote')).toBeDefined() + mockListTools.mockRejectedValue(new Error('flaky server')) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + // Must not reject (contained), and must keep the last good generation. + await handler() - // Trigger disposal by disposing a child scope. - // Cordis ctx.effect registers the disposer; calling scope dispose runs it. - await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 50)) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('effect disposer unregisters the CURRENT generation and closes client', async () => { + // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the + // registry must survive to observe the unregistration. + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig) + await sleep(50) + + // Advance to a second generation first. + mockListTools.mockResolvedValue({ + tools: [{ name: 'updated', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() + + await fiber.dispose() + await sleep(50) expect(mockClose).toHaveBeenCalled() + // The live (second) generation was unregistered, not just the first. + expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined() }) it('effect disposer handles client.close failure gracefully', async () => { mockClose.mockRejectedValue(new Error('already closed')) apply(ctx, stdioConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) // Should not throw when dispose is triggered. await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 50)) + await sleep(50) expect(mockClose).toHaveBeenCalled() }) @@ -164,16 +267,16 @@ describe('apply (plugin lifecycle)', () => { it('uses streamable-http config path', async () => { const httpConfig: Config = { transport: 'streamable-http', + serverName: 'web', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer x' }, - toolPrefix: '', toolCallTimeoutMs: 30_000, } apply(ctx, httpConfig) - await new Promise(r => setTimeout(r, 50)) + await sleep(50) expect(mockConnect).toHaveBeenCalled() - expect(ctx.tools.get('remote')).toBeDefined() + expect(ctx.tools.get('mcp__web__remote')).toBeDefined() }) }) diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index 6b97c2b59c..d127412736 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -51,5 +51,15 @@ server.registerTool('image', { ], })) +// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract. +// Exercises the bridge's normalize-and-hash public-name path end to end. +server.registerTool('admin.reset', { + title: 'Admin Reset Tool', + description: 'Tool with a dotted name (normalization test).', + inputSchema: {}, +}, async () => ({ + content: [{ type: 'text', text: 'reset done' }], +})) + const transport = new StdioServerTransport() await server.connect(transport) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index d59ba59d66..686d51acea 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -1,22 +1,29 @@ /** - * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol over - * stdio transport against: - * 1. A self-written fixture server (controlled edge cases) + * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against: + * 1. A self-written fixture server over stdio (controlled edge cases) * 2. @modelcontextprotocol/server-everything (official integration test server) * 3. @modelcontextprotocol/server-filesystem (real filesystem operations) + * 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP * * No API key needed — all servers are local/keyless. */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { z } from 'zod' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' +import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' +import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) @@ -38,16 +45,31 @@ async function mountRegistry(): Promise { /** Apply the MCP client plugin and wait for tools to be registered. */ async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise { - const { apply } = await import('@deepseek-ai/dsh-mcp-client/src/index.ts') - const toolsReady = new Promise((resolve, reject) => { - const timer = setTimeout( - () => { reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, - timeoutMs, - ) - ctx.on('tools/change', () => { clearTimeout(timer); resolve() }) - }) + // Annotated bindings (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + const timer = setTimeout( + () => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, + timeoutMs, + ) + ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() }) apply(ctx, config) - await toolsReady + await gate.promise +} + +function sleep(ms: number): Promise { + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + +/** Narrow a result content block to its text, failing the test on any other shape. */ +function textOf(block: unknown): string { + if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') { + return block.text + } + throw new Error(`expected a text content block, got ${JSON.stringify(block)}`) } let callSeq = 0 @@ -62,11 +84,11 @@ describe('fixture server — controlled scenarios', () => { const fixtureConfig: Config = { transport: 'stdio', + serverName: 'fixture', command: process.execPath, args: ['--import', tsxLoader, fixtureServerPath], env: { TSX_TSCONFIG_PATH: repoTsconfig }, cwd: packageDir, - toolPrefix: '', toolCallTimeoutMs: 15_000, } @@ -77,21 +99,37 @@ describe('fixture server — controlled scenarios', () => { afterAll(async () => { if (ctx) await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 200)) + await sleep(200) }) - it('discovers all fixture tools', () => { + it('discovers all fixture tools under the server namespace', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) - expect(names).toContain('add') - expect(names).toContain('greet') - expect(names).toContain('fail') - expect(names).toContain('image') + expect(names).toContain('mcp__fixture__add') + expect(names).toContain('mcp__fixture__greet') + expect(names).toContain('mcp__fixture__fail') + expect(names).toContain('mcp__fixture__image') + // Raw names are not registered. + expect(names).not.toContain('add') + }) + + it('normalizes the dotted tool name with a deterministic hash suffix', () => { + const publicName = publicToolName('fixture', 'admin.reset') + expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/) + expect(ctx.tools.get(publicName)).toBeDefined() + }) + + it('executes the dotted tool via its normalized public name', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' }) }) it('executes add(2, 3) → "5"', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'add', arguments: { a: 2, b: 3 }, + callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '5' }) @@ -99,7 +137,7 @@ describe('fixture server — controlled scenarios', () => { it('executes greet("World") → "Hello, World!"', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'greet', arguments: { name: 'World' }, + callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' }) @@ -107,7 +145,7 @@ describe('fixture server — controlled scenarios', () => { it('executes fail() → isError result', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'fail', arguments: {}, + callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ type: 'text' }) @@ -115,49 +153,35 @@ describe('fixture server — controlled scenarios', () => { it('executes image() → image placeholder', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'image', arguments: {}, + callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) expect(result.isError).toBe(false) - const text = (result.content[0] as { type: string; text: string }).text + const text = textOf(result.content[0]) expect(text).toContain('Here is an image:') expect(text).toContain('[image: image/png, content discarded]') expect(text).toContain('End of image.') }) }) -describe('fixture server — toolPrefix', () => { - let ctx: Context - - beforeAll(async () => { - ctx = await mountRegistry() - await applyAndWait(ctx, { +describe('fixture server — duplicate serverName', () => { + it('rejects a second instance with the same serverName on one root', async () => { + const ctx = await mountRegistry() + const config: Config = { transport: 'stdio', + serverName: 'dup', command: process.execPath, args: ['--import', tsxLoader, fixtureServerPath], env: { TSX_TSCONFIG_PATH: repoTsconfig }, cwd: packageDir, - toolPrefix: 'fx_', toolCallTimeoutMs: 15_000, - }) + } + await applyAndWait(ctx, config) + + expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) + + await ctx.fiber.dispose() + await sleep(200) }, 30_000) - - afterAll(async () => { - if (ctx) await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 200)) - }) - - it('registers tools with prefix', () => { - expect(ctx.tools.get('fx_add')).toBeDefined() - expect(ctx.tools.get('fx_greet')).toBeDefined() - expect(ctx.tools.get('add')).toBeUndefined() - }) - - it('executes prefixed tool', async () => { - const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'fx_add', arguments: { a: 10, b: 20 }, - }) - expect(result.content[0]).toEqual({ type: 'text', text: '30' }) - }) }) describe('fixture server — disposal', () => { @@ -165,21 +189,21 @@ describe('fixture server — disposal', () => { const ctx = await mountRegistry() await applyAndWait(ctx, { transport: 'stdio', + serverName: 'fixture', command: process.execPath, args: ['--import', tsxLoader, fixtureServerPath], env: { TSX_TSCONFIG_PATH: repoTsconfig }, cwd: packageDir, - toolPrefix: '', toolCallTimeoutMs: 15_000, }) // Tools are registered before dispose. - expect(ctx.tools.get('add')).toBeDefined() + expect(ctx.tools.get('mcp__fixture__add')).toBeDefined() expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4) // Dispose should complete without throwing. await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 200)) + await sleep(200) }, 30_000) }) @@ -190,11 +214,11 @@ describe('server-everything — official test server', () => { const config: Config = { transport: 'stdio', + serverName: 'everything', command: join(localBin, 'mcp-server-everything'), args: ['stdio'], env: {}, cwd: '', - toolPrefix: '', toolCallTimeoutMs: 30_000, } @@ -205,43 +229,40 @@ describe('server-everything — official test server', () => { afterAll(async () => { if (ctx) await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 500)) + await sleep(500) }) it('discovers tools from server-everything', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) - expect(names).toContain('echo') - expect(names).toContain('get-sum') - expect(names).toContain('get-tiny-image') + expect(names).toContain('mcp__everything__echo') + expect(names).toContain('mcp__everything__get-sum') + expect(names).toContain('mcp__everything__get-tiny-image') expect(names.length).toBeGreaterThanOrEqual(8) }) it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'echo', arguments: { message: 'hello' }, + callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, }) expect(result.isError).toBe(false) - const text = (result.content[0] as { type: string; text: string }).text - expect(text).toBe('Echo: hello') + expect(textOf(result.content[0])).toBe('Echo: hello') }) it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'get-sum', arguments: { a: 3, b: 7 }, + callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, }) expect(result.isError).toBe(false) - const text = (result.content[0] as { type: string; text: string }).text - expect(text).toContain('10') + expect(textOf(result.content[0])).toContain('10') }) it('executes get-tiny-image → image placeholder', async () => { const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'get-tiny-image', arguments: {}, + callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) expect(result.isError).toBe(false) - const text = (result.content[0] as { type: string; text: string }).text - expect(text).toContain('[image: image/png, content discarded]') + expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]') }) }) @@ -257,11 +278,11 @@ describe('server-filesystem — real filesystem operations', () => { ctx = await mountRegistry() const config: Config = { transport: 'stdio', + serverName: 'filesystem', command: join(localBin, 'mcp-server-filesystem'), args: [tempDir], env: {}, cwd: '', - toolPrefix: '', toolCallTimeoutMs: 30_000, } await applyAndWait(ctx, config) @@ -269,16 +290,16 @@ describe('server-filesystem — real filesystem operations', () => { afterAll(async () => { if (ctx) await ctx.fiber.dispose() - await new Promise(r => setTimeout(r, 500)) + await sleep(500) await rm(tempDir, { recursive: true, force: true }) }) it('discovers filesystem tools', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) - expect(names).toContain('read_file') - expect(names).toContain('write_file') - expect(names).toContain('list_directory') + expect(names).toContain('mcp__filesystem__read_file') + expect(names).toContain('mcp__filesystem__write_file') + expect(names).toContain('mcp__filesystem__list_directory') }) it('write_file + read_file round-trip', async () => { @@ -287,7 +308,7 @@ describe('server-filesystem — real filesystem operations', () => { // Write via MCP tool const writeResult = await ctx.tools.execute({ - callId: nextCallId(), name: 'write_file', arguments: { path: filePath, content }, + callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, }) expect(writeResult.isError).toBe(false) @@ -297,11 +318,10 @@ describe('server-filesystem — real filesystem operations', () => { // Read back via MCP tool const readResult = await ctx.tools.execute({ - callId: nextCallId(), name: 'read_file', arguments: { path: filePath }, + callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, }) expect(readResult.isError).toBe(false) - const text = (readResult.content[0] as { type: string; text: string }).text - expect(text).toContain(content) + expect(textOf(readResult.content[0])).toContain(content) }) it('list_directory shows written file', async () => { @@ -309,10 +329,113 @@ describe('server-filesystem — real filesystem operations', () => { await writeFile(join(tempDir, 'listed.txt'), 'listed') const result = await ctx.tools.execute({ - callId: nextCallId(), name: 'list_directory', arguments: { path: tempDir }, + callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, }) expect(result.isError).toBe(false) - const text = (result.content[0] as { type: string; text: string }).text - expect(text).toContain('listed.txt') + expect(textOf(result.content[0])).toContain('listed.txt') + }) +}) + +// ---- Streamable HTTP transport ---- + +describe('streamable-http — in-process MCP server', () => { + let ctx: Context + let httpServer: Server + let baseUrl: string + /** Authorization header values observed by the HTTP server, in arrival order. */ + const seenAuth: Array = [] + + /** + * Stateless Streamable HTTP endpoint: a fresh McpServer + server transport + * per request (the SDK's documented stateless pattern — no session id, no + * SSE stream to keep). The tool set mirrors a minimal fixture server. + */ + async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise { + seenAuth.push(req.headers.authorization) + const server = new McpServer( + { name: 'http-fixture', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + server.registerTool('ping', { + description: 'Replies pong.', + inputSchema: {}, + }, async () => ({ + content: [{ type: 'text', text: 'pong' }], + })) + server.registerTool('shout', { + description: 'Upper-cases a message.', + inputSchema: { message: z.string().describe('Message to upper-case') }, + }, async args => ({ + content: [{ type: 'text', text: args.message.toUpperCase() }], + })) + // Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and + // explicit-undefined identically; exactOptionalPropertyTypes forbids the + // SDK-documented explicit `sessionIdGenerator: undefined` spelling). + const transport = new StreamableHTTPServerTransport({}) + res.on('close', () => { void transport.close(); void server.close() }) + // Same exactOptionalPropertyTypes mismatch the client transport factory + // documents (src/transport.ts): the SDK types optional callbacks without + // `| undefined`. The SDK constructed the object; the cast is safe. + await server.connect(transport as Transport) + await transport.handleRequest(req, res) + } + + beforeAll(async () => { + httpServer = createServer((req, res) => { + handleMcpRequest(req, res).catch((error: unknown) => { + res.writeHead(500).end(String(error)) + }) + }) + const listening: PromiseWithResolvers = Promise.withResolvers() + httpServer.listen(0, '127.0.0.1', listening.resolve) + await listening.promise + const address = httpServer.address() + if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`) + baseUrl = `http://127.0.0.1:${address.port}/mcp` + + ctx = await mountRegistry() + const config: Config = { + transport: 'streamable-http', + serverName: 'web', + url: baseUrl, + headers: { Authorization: 'Bearer e2e-test-token' }, + toolCallTimeoutMs: 15_000, + } + await applyAndWait(ctx, config) + }, 30_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await sleep(200) + const closed: PromiseWithResolvers = Promise.withResolvers() + httpServer.close(() => { closed.resolve() }) + await closed.promise + }) + + it('discovers tools under the server namespace over HTTP', () => { + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('mcp__web__ping') + expect(names).toContain('mcp__web__shout') + }) + + it('executes ping() → "pong" over HTTP', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'pong' }) + }) + + it('executes shout({ message }) with args over HTTP', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' }) + }) + + it('sends configured headers on every HTTP request', () => { + expect(seenAuth.length).toBeGreaterThan(0) + for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token') }) }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index e81369c0f0..8fff832434 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' +import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -40,12 +40,41 @@ async function mountRegistry(): Promise { } const defaultOpts: ToolBridgeOptions = { - toolPrefix: '', + serverName: 'srv', toolCallTimeoutMs: 60_000, } // ---- Tests ---- +describe('publicToolName', () => { + it('joins clean names verbatim', () => { + expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue') + expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum') + }) + + it('replaces invalid characters and appends an identity hash', () => { + const name = publicToolName('srv', 'admin.reset') + expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/) + expect(name.length).toBeLessThanOrEqual(64) + }) + + it('truncates over-long names and appends an identity hash', () => { + const rawName = 'a'.repeat(80) + const name = publicToolName('srv', rawName) + expect(name).toHaveLength(64) + expect(name).toMatch(/_[0-9a-f]{12}$/) + expect(name.startsWith('mcp__srv__aaa')).toBe(true) + }) + + it('is deterministic and collision-free for distinct identities', () => { + // Two raw names that normalize to the same base must not collapse. + const a = publicToolName('srv', 'admin.reset') + const b = publicToolName('srv', 'admin_reset') + expect(a).toBe(publicToolName('srv', 'admin.reset')) + expect(a).not.toBe(b) + }) +}) + describe('syncTools', () => { let ctx: Context @@ -53,7 +82,7 @@ describe('syncTools', () => { ctx = await mountRegistry() }) - it('registers tools from listTools response', async () => { + it('registers tools under server-qualified public names', async () => { const client = createMockClient([ { name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } }, { name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } }, @@ -62,44 +91,85 @@ describe('syncTools', () => { const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) expect(disposers.size).toBe(2) - expect(ctx.tools.get('greet')).toBeDefined() - expect(ctx.tools.get('add')).toBeDefined() + expect(ctx.tools.get('mcp__srv__greet')).toBeDefined() + expect(ctx.tools.get('mcp__srv__add')).toBeDefined() + // Raw names are NOT registered. + expect(ctx.tools.get('greet')).toBeUndefined() + expect(ctx.tools.get('add')).toBeUndefined() }) - it('applies toolPrefix to registered names', async () => { - const client = createMockClient([ - { name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' } }, - ]) + it('lets two servers publish the same raw name side by side', async () => { + const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) + const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) - const disposers = await syncTools(client as never, ctx, { ...defaultOpts, toolPrefix: 'gh_' }, new Map()) + await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map()) + await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map()) - expect(disposers.size).toBe(1) - expect(ctx.tools.get('gh_create_issue')).toBeDefined() - expect(ctx.tools.get('create_issue')).toBeUndefined() + expect(ctx.tools.get('mcp__github__search')).toBeDefined() + expect(ctx.tools.get('mcp__web__search')).toBeDefined() }) - it('skips tools with conflicting names and logs warning', async () => { - // Pre-register a tool with the same name. + it('coexists with a native tool of the same raw name', async () => { ctx.tools.register({ - name: 'existing', - description: 'Already here', + name: 'search', + description: 'Native search', parameters: { type: 'object' }, execute: async () => [{ type: 'text', text: 'native' }], }) + const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) + await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(ctx.tools.get('search')).toBeDefined() + expect(ctx.tools.get('mcp__srv__search')).toBeDefined() + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} }) + expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) + }) + + it('rejects a tool list where one raw name appears twice', async () => { const client = createMockClient([ - { name: 'existing', description: 'Conflicts', inputSchema: { type: 'object' } }, - { name: 'unique', description: 'No conflict', inputSchema: { type: 'object' } }, + { name: 'dup', inputSchema: { type: 'object' } }, + { name: 'dup', inputSchema: { type: 'object' } }, + ]) + + await expect(syncTools(client as never, ctx, defaultOpts, new Map())) + .rejects.toThrow(/listed tool "dup" more than once/) + // Nothing registered, previous generation untouched (it was empty). + expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined() + }) + + it('keeps the previous generation when the fetch phase fails', async () => { + const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }]) + const first = await syncTools(client as never, ctx, defaultOpts, new Map()) + expect(ctx.tools.get('mcp__srv__stable')).toBeDefined() + + client.listTools.mockRejectedValue(new Error('network down')) + await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down') + + // The previous generation is still live. + expect(ctx.tools.get('mcp__srv__stable')).toBeDefined() + }) + + it('rolls back the whole generation when a foreign tool squats on the namespace', async () => { + // A foreign registration occupies one of this server's public names. + ctx.tools.register({ + name: 'mcp__srv__taken', + description: 'Squatter', + parameters: { type: 'object' }, + execute: async () => [{ type: 'text', text: 'squatter' }], + }) + const client = createMockClient([ + { name: 'free', inputSchema: { type: 'object' } }, + { name: 'taken', inputSchema: { type: 'object' } }, ]) const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) - // Only the non-conflicting tool registers. - expect(disposers.size).toBe(1) - expect(ctx.tools.get('unique')).toBeDefined() - // Original tool unchanged. - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'existing', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) + // All-or-nothing: the non-conflicting tool is rolled back too. + expect(disposers.size).toBe(0) + expect(ctx.tools.get('mcp__srv__free')).toBeUndefined() + // The squatter is untouched. + expect(ctx.tools.get('mcp__srv__taken')).toBeDefined() }) it('unregisters previous tools before re-syncing', async () => { @@ -108,14 +178,14 @@ describe('syncTools', () => { ]) const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map()) - expect(ctx.tools.get('old_tool')).toBeDefined() + expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined() // Second sync with different tools should remove old_tool. client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined }) const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers) - expect(ctx.tools.get('old_tool')).toBeUndefined() - expect(ctx.tools.get('new_tool')).toBeDefined() + expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__new_tool')).toBeDefined() expect(secondDisposers.size).toBe(1) }) @@ -128,8 +198,8 @@ describe('syncTools', () => { const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) expect(disposers.size).toBe(2) - expect(ctx.tools.get('page1')).toBeDefined() - expect(ctx.tools.get('page2')).toBeDefined() + expect(ctx.tools.get('mcp__srv__page1')).toBeDefined() + expect(ctx.tools.get('mcp__srv__page2')).toBeDefined() }) }) @@ -140,17 +210,18 @@ describe('tool execution', () => { ctx = await mountRegistry() }) - it('calls MCP callTool and returns text content', async () => { + it('calls MCP callTool with the RAW name and returns text content', async () => { const client = createMockClient( [{ name: 'echo', inputSchema: { type: 'object' } }], { content: [{ type: 'text', text: 'hello world' }] }, ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { msg: 'hi' } }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) + // The wire sees the raw MCP name, never the public name. expect(client.callTool).toHaveBeenCalledWith( { name: 'echo', arguments: { msg: 'hi' } }, undefined, @@ -158,6 +229,24 @@ describe('tool execution', () => { ) }) + it('sends the raw name for normalized public names', async () => { + const client = createMockClient( + [{ name: 'admin.reset', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'reset done' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const publicName = publicToolName('srv', 'admin.reset') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} }) + + expect(result.isError).toBe(false) + expect(client.callTool).toHaveBeenCalledWith( + { name: 'admin.reset', arguments: {} }, + undefined, + expect.anything(), + ) + }) + it('joins multiple text blocks with newline', async () => { const client = createMockClient( [{ name: 'multi', inputSchema: { type: 'object' } }], @@ -165,7 +254,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'multi', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) @@ -177,7 +266,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) }) @@ -189,7 +278,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fail', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) @@ -203,7 +292,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: controller.signal }) + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal }) expect(client.callTool).toHaveBeenCalledWith( expect.anything(), @@ -219,7 +308,7 @@ describe('tool execution', () => { client.callTool.mockResolvedValue({ toolResult: { key: 'value' } }) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) @@ -240,7 +329,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_tool', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) }) @@ -252,7 +341,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'res_tool', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -264,7 +353,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'link_tool', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -276,7 +365,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'unknown_tool', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) }) @@ -288,7 +377,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img2', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) }) @@ -300,7 +389,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_no_mime', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) }) @@ -312,7 +401,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) }) @@ -324,7 +413,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) }) @@ -337,7 +426,7 @@ describe('tool execution edge cases', () => { client.callTool.mockResolvedValue({}) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy2', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) }) @@ -349,13 +438,9 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'err_notext', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) expect(result.isError).toBe(true) - // The error message falls back to 'MCP tool error' when content[0] is not text. - // But mapContent converts image to text placeholder, so it should use that. - // Actually mapContent ALWAYS returns text, so the ternary always takes the truthy branch. - // Let me check: mapContent returns [{type:'text', text:'[image: ...]'}], so content[0].type IS 'text'. expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) }) @@ -366,7 +451,7 @@ describe('tool execution edge cases', () => { ]) await syncTools(client as never, ctx, defaultOpts, new Map()) - const tool = ctx.tools.get('described') + const tool = ctx.tools.get('mcp__srv__described') expect(tool?.description).toBe('A described tool') }) @@ -376,7 +461,7 @@ describe('tool execution edge cases', () => { ]) await syncTools(client as never, ctx, defaultOpts, new Map()) - const tool = ctx.tools.get('nodesc') + const tool = ctx.tools.get('mcp__srv__nodesc') expect(tool?.description).toBe('') }) }) @@ -385,11 +470,11 @@ describe('createTransport', () => { it('creates StdioClientTransport for stdio config', () => { const config: Config = { transport: 'stdio', + serverName: 'srv', command: 'node', args: ['server.js'], env: {}, cwd: '/tmp', - toolPrefix: '', toolCallTimeoutMs: 60_000, } const transport = createTransport(config) @@ -401,9 +486,9 @@ describe('createTransport', () => { it('creates StreamableHTTPClientTransport for http config without headers', () => { const config: Config = { transport: 'streamable-http', + serverName: 'srv', url: 'http://localhost:3000/mcp', headers: {}, - toolPrefix: '', toolCallTimeoutMs: 60_000, } const transport = createTransport(config) @@ -415,9 +500,9 @@ describe('createTransport', () => { it('creates StreamableHTTPClientTransport for http config with headers', () => { const config: Config = { transport: 'streamable-http', + serverName: 'srv', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer token' }, - toolPrefix: '', toolCallTimeoutMs: 60_000, } const transport = createTransport(config) @@ -436,11 +521,11 @@ describe('createTransport', () => { const config: Config = { transport: 'stdio', + serverName: 'srv', command: 'echo', args: [], env: { EXTRA: 'injected' }, cwd: '', - toolPrefix: '', toolCallTimeoutMs: 60_000, } // createTransport internally calls buildChildEnv; we verify by inspecting @@ -463,11 +548,11 @@ describe('createTransport', () => { it('merges explicit env on top of scrubbed ambient env', () => { const config: Config = { transport: 'stdio', + serverName: 'srv', command: 'echo', args: [], env: { CUSTOM: 'value' }, cwd: '', - toolPrefix: '', toolCallTimeoutMs: 60_000, } const transport = createTransport(config) @@ -490,7 +575,7 @@ describe('tool execution — non-object args fallback', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) // Simulate model emitting `null` as tool arguments (malformed). - await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce', arguments: null }) + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce', arguments: {} }, @@ -506,7 +591,7 @@ describe('tool execution — non-object args fallback', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce2', arguments: 'bad' }) + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce2', arguments: {} }, @@ -515,4 +600,3 @@ describe('tool execution — non-object args fallback', () => { ) }) }) - From b9eeb2162c2eee306977893772098d608d9c7a43 Mon Sep 17 00:00:00 2001 From: lintianle Date: Wed, 15 Jul 2026 15:40:02 +0800 Subject: [PATCH 21/33] Fix lockfile broken by hand-resolved merge conflict The web-editor conflict resolution renamed the cordis snapshot keys to rc.7 but left the mcp-client importer pointing at the deleted cordis@4.0.0-rc.6(...rc.4) key, so every CI lane failed at 'pnpm install --frozen-lockfile' with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Align the mcp-client cordis range with the repo-wide ^4.0.0-rc.7 sweep from master and re-resolve the lockfile with pnpm. --- packages/mcp/mcp-client/package.json | 4 ++-- pnpm-lock.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 638777017d..6b8f145108 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1b56db805..4e0a2af98a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,8 +746,8 @@ importers: specifier: ^2026.7.4 version: 2026.7.10(zod@4.4.3) cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) zod: specifier: ^4.4.3 version: 4.4.3 From 8e6ad0a347b07934e288e3227e9e409c0b2037d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 19 Jun 2026 23:46:52 +0800 Subject: [PATCH 22/33] fix(scripts): launch lefthook bin shim via shell on Windows spawnSync on a .cmd shim returns EINVAL/null status on recent Node (CVE-2024-27980) unless shell:true, which made postinstall fail and blocked every 'pnpm run' on Windows. (cherry picked from commit 65a08f889ff9738ddaceeeb724e6e24f3be4b2ea) --- scripts/install-lefthook.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index be81daa153..9256a9b462 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -6,8 +6,16 @@ import { join } from 'node:path' const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }) if (git.status !== 0) process.exit(0) -const lefthook = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook') +const isWindows = process.platform === 'win32' +const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') if (!existsSync(lefthook)) process.exit(0) -const result = spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) +// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980) +// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns +// `EINVAL` with a null status, which would otherwise fail postinstall. Quote +// the path because a shell re-parses the command line and the path may contain +// spaces. POSIX needs no shell: the extensionless shim is directly executable. +const result = isWindows + ? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true }) + : spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) process.exit(result.status ?? 1) From 1c4bb7008de55e905747b4d193c77a267d1e30f1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 16:41:40 +0800 Subject: [PATCH 23/33] Pin LF working trees via .gitattributes The repo's committed content is already 100% LF (verified: 802/802 text files); until now the working-tree form depended on each contributor's core.autocrlf, and autocrlf=true checkouts produced CRLF working copies that byte-level gates had to tolerate (fence parsing, consistency-record parsing, blob hashing, README splice comparison). eol=lf removes the smudge boundary entirely: attributes override any local autocrlf, so every checkout on every host sees the repo's canonical form. git add --renormalize confirmed a zero-change no-op - no committed blob (including vendor/) is rewritten. The script-side CRLF tolerances remain as defense in depth for editor-introduced CRLF in not-yet-committed files. If a file class ever needs CRLF in the working tree (.bat/.cmd), a per-pattern eol=crlf override keeps the in-repo form LF while smudging those checkouts only. (cherry picked from commit 5d21ebee20391c3d5c1d3812bd3aaa92bc652973) --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..a51c5e7b5e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# The repo's canonical text form is LF, enforced at checkout too: no smudge +# boundary between working tree and repo, so byte-level gates (verify-* +# comparisons, blob hashing, coverage offsets) see one form on every host. +# If a file class ever genuinely needs CRLF in the working tree (.bat/.cmd +# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the +# in-repo form stays LF; CRLF becomes checkout-time presentation only. +* text=auto eol=lf From d42b118fe3e8f06e3f7613a7c3f86fcef00480f8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 16:55:46 +0800 Subject: [PATCH 24/33] Spawn tsc by its JS entry so doc-typecheck runs on Windows execFileSync('node_modules/.bin/tsc') spawns an extensionless shim that is not executable on Windows (the CVE-2024-27980 class the sibling scripts hit); the catch treated the spawn failure as a compile failure with empty diagnostics. The .cmd shim would need shell:true, which concatenates args unescaped - a hazard for the temp project path - so invoke typescript/bin/tsc through the current node instead; identical behavior on every platform. Note this gate had never actually run on this Windows checkout: with the pre-eol=lf CRLF working copy the fence regex matched no ts blocks ('.' does not match \\r), so it reported 'no ts code blocks to check' and exited green. The LF working tree surfaced the spawn bug; with this fix the gate compiles all 21 blocks on Windows. (cherry picked from commit b49993c28e068c2beb411eae1f0c8ac4985aa3ae) --- scripts/doc-typecheck.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index dfcbbf844c..86266f89b2 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -125,7 +125,12 @@ try { }) try { - execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) + // tsc's JS entry via the current node, not the .bin shim: the extensionless + // shim is not spawnable on Windows (the CVE-2024-27980 class the sibling + // scripts hit), and the .cmd variant would need shell:true, which + // concatenates args UNESCAPED — a hazard for the temp project path. The JS + // entry behaves identically on every platform. + execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) } catch (error: unknown) { const failed = error as { stdout?: Buffer; stderr?: Buffer } const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}` From 4cbd722074f1883b9818dc31f235eb76663973f7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 17:04:35 +0800 Subject: [PATCH 25/33] Declare LF and final-newline conventions in .editorconfig Pairs with .gitattributes: eol=lf pins what git produces at checkout; .editorconfig pins what editors write to disk - the one CRLF vector git's filters cannot reach (git never rewrites the working tree, so an editor-written CRLF file would persist with a clean status while the doc gates misbehave on it). insert_final_newline declares the existing one-trailing-newline policy (AGENTS.md, gated by git diff --check) at the editor layer too. (cherry picked from commit 7a09602fd76efff81a0875fb177289004c3dfc9b) --- .editorconfig | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..1c17d31b2c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +# Editor-side declaration of the repo's text conventions. Pairs with +# .gitattributes: that file pins what GIT produces (LF checkouts), this one +# pins what EDITORS write to disk — the one path git's filters cannot reach. +root = true + +[*] +end_of_line = lf +insert_final_newline = true From 65d07a490f9a8cf5ce8a3bbc2936285182600450 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 4 Jul 2026 23:10:37 +0800 Subject: [PATCH 26/33] fix(scripts): handle .cmd bin shims on Windows (CVE-2024-27980) execFileSync on a .cmd shim returns EINVAL on recent Node without shell:true. Same bug class as install-lefthook.mjs. Affected publint-all.ts and verify-node-next-types.ts. (cherry picked from commit 5ae40bee1c840fbbdd197ee15907aa660a343c52) --- scripts/publint-all.ts | 9 ++++++++- scripts/verify-node-next-types.ts | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index bce8c31dd4..722cf4d2ac 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -6,12 +6,18 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' +const isWindows = process.platform === 'win32' // Discover harness packages at packages//; group containers, // examples, and private vendored sources are not package targets. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') +// On Windows recent Node (CVE-2024-27980) refuses to launch .cmd/.bat bin +// shims without shell:true. Use the absolute path to the .cmd shim so the +// subprocess (not a pnpm child — PATH lacks node_modules/.bin) still finds it. +const publintBin = resolve(root, `node_modules/.bin/publint${isWindows ? '.cmd' : ''}`) + type PublintResult = | { path: string; status: 'passed'; stdout: string; stderr: string } | { path: string; status: 'failed'; stdout: string; stderr: string; message: string } @@ -50,10 +56,11 @@ function outputText(value: unknown): string { async function runPublint(path: string): Promise { try { - const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], { + const { stdout, stderr } = await execFileAsync(publintBin, [path], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, + shell: isWindows, }) return { path, status: 'passed', stdout, stderr } } catch (error: unknown) { diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts index 7883a855c3..7ee046c7bb 100644 --- a/scripts/verify-node-next-types.ts +++ b/scripts/verify-node-next-types.ts @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' +const isWindows = process.platform === 'win32' const root = resolve(import.meta.dirname, '..') interface ExportTarget { @@ -143,9 +144,13 @@ try { .join('\n') writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) - execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + // On Windows the bin shim is a .cmd file; recent Node (CVE-2024-27980) + // refuses to launch .cmd/.bat via execFileSync without shell:true. + const tscBin = isWindows ? resolve(root, 'node_modules/.bin/tsc.cmd') : resolve(root, 'node_modules/.bin/tsc') + execFileSync(tscBin, ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { cwd: root, stdio: 'pipe', + shell: isWindows, }) console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) } catch (error: unknown) { From 7b0310cac427ef629a7604be72098ca20ce39320 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:18:11 +0800 Subject: [PATCH 27/33] fix(scripts): run publint/tsc via node JS entry, not a shell .cmd shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell:true space-joins the executable and args UNESCAPED (Node DEP0190), so an absolute .cmd path breaks whenever the repo path contains spaces and `pnpm run hygiene` fails. Invoke publint's (`node_modules/publint/src/cli.js`) and tsc's (`node_modules/typescript/bin/tsc`) JS entry through process.execPath instead — no shell, extension-agnostic, identical on every platform, matching the pattern already used by doc-typecheck.ts. Addresses ds-review-bot on #324. --- scripts/publint-all.ts | 14 +++++++------- scripts/verify-node-next-types.ts | 11 +++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 722cf4d2ac..0911316f18 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -6,17 +6,18 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' -const isWindows = process.platform === 'win32' // Discover harness packages at packages//; group containers, // examples, and private vendored sources are not package targets. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') -// On Windows recent Node (CVE-2024-27980) refuses to launch .cmd/.bat bin -// shims without shell:true. Use the absolute path to the .cmd shim so the -// subprocess (not a pnpm child — PATH lacks node_modules/.bin) still finds it. -const publintBin = resolve(root, `node_modules/.bin/publint${isWindows ? '.cmd' : ''}`) +// Run publint's JS CLI through the current node, not the .bin shim: the +// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd +// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and +// breaks when the repo path contains spaces. The JS entry is identical on every +// platform (`bin` is `./src/cli.js` per publint's package.json). +const publintCli = resolve(root, 'node_modules/publint/src/cli.js') type PublintResult = | { path: string; status: 'passed'; stdout: string; stderr: string } @@ -56,11 +57,10 @@ function outputText(value: unknown): string { async function runPublint(path: string): Promise { try { - const { stdout, stderr } = await execFileAsync(publintBin, [path], { + const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, - shell: isWindows, }) return { path, status: 'passed', stdout, stderr } } catch (error: unknown) { diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts index 7ee046c7bb..3b577a39bc 100644 --- a/scripts/verify-node-next-types.ts +++ b/scripts/verify-node-next-types.ts @@ -10,7 +10,6 @@ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' -const isWindows = process.platform === 'win32' const root = resolve(import.meta.dirname, '..') interface ExportTarget { @@ -144,13 +143,13 @@ try { .join('\n') writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) - // On Windows the bin shim is a .cmd file; recent Node (CVE-2024-27980) - // refuses to launch .cmd/.bat via execFileSync without shell:true. - const tscBin = isWindows ? resolve(root, 'node_modules/.bin/tsc.cmd') : resolve(root, 'node_modules/.bin/tsc') - execFileSync(tscBin, ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + // tsc's JS entry via the current node, not the .bin shim: the extensionless + // shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs + // shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the + // temp tsconfig path. The JS entry behaves identically on every platform. + execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { cwd: root, stdio: 'pipe', - shell: isWindows, }) console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) } catch (error: unknown) { From e6e587b97d82db3cf2da8d09413863a66a0798e7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:27:10 +0800 Subject: [PATCH 28/33] ci: add a native-Windows build lane (install + build) Runs `pnpm install` + `pnpm run build` (tsc -b + tsdown) on windows-2025, and is listed in all-checks-passed `needs` so a Windows build regression cannot land silently. Windows path/shell support is still partial, so this lane covers the build surface only; tests and gates are not run here yet. --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e45d6698e5..884a0417e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,29 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest + # Windows build lane: install + `pnpm run build` (tsc -b + tsdown) on native + # Windows. Windows path/shell support is still partial, so this lane covers + # the build surface only — tests and gates are not run here yet. Wired into + # all-checks-passed so a native-Windows build regression cannot land silently. + windows-build: + runs-on: windows-2025 + name: windows / build + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Build (tsc -b + tsdown) + run: pnpm run build + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every other job in THIS workflow must be listed in @@ -171,7 +194,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk] + needs: [node-24, node-compat, python-sdk, windows-build] if: always() steps: - name: Fail if any needed job did not succeed From 6f77da4c8c8860d4e1dd7705d5c188c8d47b307b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:57:57 +0800 Subject: [PATCH 29/33] refactor: relocate demo app bundles to packages/examples/*-demo Move the agent-spine bundle and the stdio/ACP/JSON-RPC app packages out of core/ and ui/ into a new packages/examples/ group, renamed with a -demo suffix so the npm name marks them as non-product surface: core/agent-core -> examples/agent-spine-demo (dsh-agent-spine-demo) ui/stdio-agent -> examples/stdio-demo (dsh-stdio-demo) ui/acp-agent -> examples/acp-demo (dsh-acp-demo) ui/jsonrpc-agent -> examples/jsonrpc-demo (dsh-jsonrpc-demo) Update every code/config/test reference and reference-only doc mentions, and regenerate module-graph, config-catalog, and doc-graphs. The jsonrpc bin (dsh-jsonrpc-agent) and single-file exe (dsh-jsonrpc-agent-pkg) keep their names; the SDK runtime-startup surface is reconciled separately. --- .agents/skills/dsh-pre-push-checks/SKILL.md | 2 +- docs/architecture.md | 2 +- docs/capability-seams.md | 18 +- docs/config-catalog.md | 76 ++--- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/user-interaction.md | 2 +- docs/i18n/style-samples.md | 4 +- docs/module-graph.md | 72 +++-- ...2026-06-20-extract-example-app-packages.md | 16 +- ...ile-executable-sdk-runtime-distribution.md | 12 +- ...-executable-sdk-runtime-distribution.zh.md | 12 +- .../feature/2026-06-25-ask-user-question.md | 4 +- .../feature/2026-07-05-skill-system.md | 2 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../feature/2026-07-06-explicit-tool-order.md | 2 +- .../feature/2026-07-14-time-context-plugin.md | 4 +- .../2026-07-14-time-context-plugin.zh.md | 4 +- .../process/2026-07-06-node-engine-floor.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- ...-04-trim-acp-bridge-unreachable-surface.md | 2 +- .../2026-07-08-shared-acp-snapshot-package.md | 2 +- .../2026-06-20-providerless-example-base.md | 2 +- examples/README.md | 10 +- examples/acp-agent/README.md | 2 +- .../acp-agent/advanced.cordis.snapshot.yml | 2 +- examples/acp-agent/advanced.cordis.yml | 2 +- .../acp-agent/both-mode.cordis.snapshot.yml | 2 +- examples/acp-agent/both-mode.cordis.yml | 2 +- .../acp-agent/code-mode.cordis.snapshot.yml | 2 +- examples/acp-agent/code-mode.cordis.yml | 2 +- examples/acp-agent/composition.md | 6 +- examples/acp-agent/cordis.yml | 2 +- examples/acp-agent/tests/acp.e2e.ts | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 4 +- examples/acp-agent/tests/escalation.e2e.ts | 2 +- examples/acp-agent/tests/hooks.e2e.ts | 2 +- examples/coding-agent/README.md | 4 +- examples/coding-agent/code-mode.cordis.yml | 2 +- examples/coding-agent/composition.md | 6 +- examples/coding-agent/cordis.yml | 4 +- .../tests/code-mode-keyless-smoke.e2e.ts | 2 +- .../coding-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/cordis-agent/composition.md | 6 +- examples/cordis-agent/cordis.yml | 2 +- .../cordis-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/echo-agent/README.md | 8 +- examples/echo-agent/composition.md | 6 +- examples/echo-agent/cordis.yml | 4 +- examples/echo-agent/tests/echo.e2e.ts | 2 +- knip.json | 6 +- package.json | 8 +- packages/context/README.md | 2 +- packages/context/time-context/README.md | 2 +- .../time-context/tests/fixtures/cordis.yml | 2 +- .../time-context/tests/time-context.e2e.ts | 2 +- .../acp-agent => examples/acp-demo}/README.md | 16 +- .../acp-demo}/package.json | 10 +- .../acp-demo}/src/bin.ts | 6 +- .../acp-demo}/src/index.ts | 16 +- .../acp-demo}/tests/acp-agent.spec.ts | 12 +- .../acp-demo}/tests/built-bin.e2e.ts | 10 +- .../acp-demo}/tests/load-path.e2e.ts | 6 +- .../acp-demo}/tsconfig.json | 10 +- .../acp-demo}/tsdown.config.ts | 0 .../agent-spine-demo}/README.md | 6 +- .../agent-spine-demo}/package.json | 2 +- .../agent-spine-demo}/src/index.ts | 4 +- .../tests/agent-core.spec.ts | 18 +- .../tests/gen-config-catalog.spec.ts | 0 .../agent-spine-demo}/tsconfig.json | 0 .../jsonrpc-demo}/README.md | 2 +- .../jsonrpc-demo}/package.json | 2 +- .../jsonrpc-demo}/src/bin.ts | 2 +- .../jsonrpc-demo}/src/index.ts | 2 +- .../jsonrpc-demo}/tsconfig.json | 2 +- .../jsonrpc-demo}/tsdown.config.ts | 0 .../stdio-demo}/README.md | 20 +- .../stdio-demo}/package.json | 10 +- .../stdio-demo}/src/bin.ts | 6 +- .../stdio-demo}/src/index.ts | 16 +- .../stdio-demo}/tests/built-bin.e2e.ts | 10 +- .../stdio-demo}/tests/stdio-agent.spec.ts | 12 +- .../stdio-demo}/tsconfig.json | 10 +- .../stdio-demo}/tsdown.config.ts | 0 packages/subagent/subagent-acp/README.md | 2 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 2 +- packages/support/README.md | 2 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 2 +- packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 2 +- packages/todo/README.md | 2 +- packages/todo/tool-todo/README.md | 2 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/src/index.ts | 2 +- packages/ui/jsonrpc/package.json | 2 +- .../ui/jsonrpc/tests/plugin-apply.spec.ts | 2 +- packages/ui/jsonrpc/tests/server.spec.ts | 2 +- pnpm-lock.yaml | 306 +++++++++--------- python/README.md | 4 +- python/README.zh.md | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/package.json | 4 +- .../src/deepseek_harness_runtime/__init__.py | 2 +- .../runtime/cordis.yml | 2 +- python/sdk/tests/manual_sdk_agent_smoke.py | 2 +- python/sdk/tests/test_bundled_runtime.py | 2 +- python/sdk/tests/test_runtime_resolution.py | 2 +- scripts/build-exe-for-python-sdk.ts | 2 +- scripts/demo-code-mode.mjs | 4 +- scripts/gen-doc-graphs.ts | 16 +- scripts/run-gates.ts | 4 +- scripts/smoke-python-runtime.py | 2 +- .../verify-package-readme-model-experience.ts | 6 +- tsconfig.build.json | 8 +- tsconfig.json | 8 +- 118 files changed, 492 insertions(+), 490 deletions(-) rename packages/{ui/acp-agent => examples/acp-demo}/README.md (79%) rename packages/{ui/acp-agent => examples/acp-demo}/package.json (79%) rename packages/{ui/acp-agent => examples/acp-demo}/src/bin.ts (89%) rename packages/{ui/acp-agent => examples/acp-demo}/src/index.ts (89%) rename packages/{ui/acp-agent => examples/acp-demo}/tests/acp-agent.spec.ts (93%) rename packages/{ui/acp-agent => examples/acp-demo}/tests/built-bin.e2e.ts (95%) rename packages/{ui/acp-agent => examples/acp-demo}/tests/load-path.e2e.ts (96%) rename packages/{ui/acp-agent => examples/acp-demo}/tsconfig.json (74%) rename packages/{ui/acp-agent => examples/acp-demo}/tsdown.config.ts (100%) rename packages/{core/agent-core => examples/agent-spine-demo}/README.md (91%) rename packages/{core/agent-core => examples/agent-spine-demo}/package.json (97%) rename packages/{core/agent-core => examples/agent-spine-demo}/src/index.ts (98%) rename packages/{core/agent-core => examples/agent-spine-demo}/tests/agent-core.spec.ts (94%) rename packages/{core/agent-core => examples/agent-spine-demo}/tests/gen-config-catalog.spec.ts (100%) rename packages/{core/agent-core => examples/agent-spine-demo}/tsconfig.json (100%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/README.md (98%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/package.json (95%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/src/bin.ts (97%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/src/index.ts (85%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/tsconfig.json (89%) rename packages/{ui/jsonrpc-agent => examples/jsonrpc-demo}/tsdown.config.ts (100%) rename packages/{ui/stdio-agent => examples/stdio-demo}/README.md (73%) rename packages/{ui/stdio-agent => examples/stdio-demo}/package.json (84%) rename packages/{ui/stdio-agent => examples/stdio-demo}/src/bin.ts (85%) rename packages/{ui/stdio-agent => examples/stdio-demo}/src/index.ts (91%) rename packages/{ui/stdio-agent => examples/stdio-demo}/tests/built-bin.e2e.ts (95%) rename packages/{ui/stdio-agent => examples/stdio-demo}/tests/stdio-agent.spec.ts (94%) rename packages/{ui/stdio-agent => examples/stdio-demo}/tsconfig.json (77%) rename packages/{ui/stdio-agent => examples/stdio-demo}/tsdown.config.ts (100%) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 7c0d629fff..4ce005ee79 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -54,7 +54,7 @@ pnpm run test:snapshot Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. ```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts ``` Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..4049049e25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca ### Bundles And Apps -`dsh-agent-core` is the default composition bundle: one plugin loading the shared spine ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-agent` for terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/capability-seams.md b/docs/capability-seams.md index fb4746dc68..af22ba4c15 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,13 +40,13 @@ flowchart LR pkg_tool_todo["tool-todo"] pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] - pkg_stdio_agent["stdio-agent"] + pkg_stdio_demo["stdio-demo"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent registry"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] - pkg_agent_core["agent-core"] + pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] @@ -113,7 +113,7 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_skill --> svc_skills pkg_skill_local --> svc_skills - pkg_stdio_agent --> svc_userInteraction + pkg_stdio_demo --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -129,11 +129,11 @@ flowchart LR pkg_web_search_perplexity --> svc_web pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows - svc_agentLoop --> pkg_agent_core + svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_invariants - svc_agents --> pkg_stdio_agent + svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash svc_approval --> pkg_tools @@ -173,7 +173,7 @@ flowchart LR svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web svc_userInteraction --> pkg_acp - svc_userInteraction --> pkg_stdio_agent + svc_userInteraction --> pkg_stdio_demo svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web svc_workflows --> pkg_tool_workflow @@ -188,10 +188,10 @@ flowchart LR | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `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.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | +| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 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), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`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 replace bash-local without touching them. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e3576d05..af8ef0832c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/ui/acp/src/index.ts:203`](../packages/ui/acp/src/index.ts) -## `@deepseek-ai/dsh-acp-agent` +## `@deepseek-ai/dsh-acp-demo` ```ts config-catalog /** @@ -37,7 +37,7 @@ Source: [`packages/ui/acp/src/index.ts:203`](../packages/ui/acp/src/index.ts) * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-core); `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ @@ -46,20 +46,43 @@ 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). */ + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig } ``` -Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/acp-agent/src/index.ts:31`](../packages/ui/acp-agent/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp-demo/src/index.ts) -## `@deepseek-ai/dsh-agent-core` +## `@deepseek-ai/dsh-agent-loop` + +Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` + +```ts config-catalog +/** Plugin configuration for declarative startup agents. */ +export interface Config { + /** Agents created or resumed at plugin startup. */ + agents: (AgentOptions & { + /** Registry identity for the live agent. */ + id: AgentId + /** Optional workspace for a fresh session. */ + cwd?: string + /** Persisted session to resume instead of creating a fresh session. */ + resumeSessionId?: SessionId + })[] +} +``` + +Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) + +Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) + +## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog /** @@ -97,30 +120,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) -Source: [`packages/core/agent-core/src/index.ts:46`](../packages/core/agent-core/src/index.ts) - -## `@deepseek-ai/dsh-agent-loop` - -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` - -```ts config-catalog -/** Plugin configuration for declarative startup agents. */ -export interface Config { - /** Agents created or resumed at plugin startup. */ - agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId - /** Optional workspace for a fresh session. */ - cwd?: string - /** Persisted session to resume instead of creating a fresh session. */ - resumeSessionId?: SessionId - })[] -} -``` - -Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) - -Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:46`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -675,13 +675,13 @@ export interface Config { Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) -## `@deepseek-ai/dsh-stdio-agent` +## `@deepseek-ai/dsh-stdio-demo` ```ts config-catalog /** * App config: the swappable per-demo values, each routed to where the app wires * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is + * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `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); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions @@ -695,13 +695,13 @@ 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). */ + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; 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.'`. */ welcome?: string - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** * If set, the `main` agent RESUMES this persisted session id instead of @@ -712,9 +712,9 @@ export interface Config { } ``` -Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:36`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:36`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1242,7 +1242,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts)) +- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 40ee22b352..3474bc116b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle. +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 4e5bc68c97..1a605b20fe 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent),ACP 演示加载 [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent),两个 app 包通过 [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle 共享主干。 +三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 ## 功能→机制映射 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 6155fd9896..4edc415039 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-agent` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index e97cc331a9..6d9e82d184 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -10,9 +10,9 @@ 本文介绍 DeepSeek Harness 整体架构,它是 **DeepSeek Code** 的底层基座。微内核设计讨论中确立了核心设计准则:**一切皆插件**。内核刻意做得极精简,仅包含少量抽象服务,外加一个实体循环插件 `dsh-agent-loop`。所有产品功能均基于本文定义的扩展接口开发为独立插件,无需改动主循环逻辑。 -> Dependency rule: extension plugins depend on interfaces, never on `dsh-agent-loop` (the loop is swappable); the sanctioned exception is the composition bundle `dsh-agent-core`, whose job is assembling the concrete spine. +> Dependency rule: extension plugins depend on interfaces, never on `dsh-agent-loop` (the loop is swappable); the sanctioned exception is the composition bundle `dsh-agent-spine-demo`, whose job is assembling the concrete spine. -依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-core`,它的职责是组装整套实体主干。 +依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-spine-demo`,它的职责是组装整套实体主干。 > This document covers **behavior**; type shapes live in [core-data-structures/](../core-data-structures/core.md), the per-event/service reference in the [generated catalog](../cordis-catalog/events.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). diff --git a/docs/module-graph.md b/docs/module-graph.md index 1b804e5214..5d5ef1addf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,7 +18,6 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] - pkg_agent_core["agent-core"] pkg_agent_loop["agent-loop"] pkg_scope["scope"] pkg_session["session"] @@ -94,13 +93,10 @@ flowchart TD end subgraph group_ui["packages/ui"] pkg_acp["acp"] - pkg_acp_agent["acp-agent"] pkg_app_boot["app-boot"] pkg_jsonrpc["jsonrpc"] - pkg_jsonrpc_agent["jsonrpc-agent"] pkg_permission["permission"] pkg_stdio["stdio"] - pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] @@ -112,6 +108,12 @@ flowchart TD subgraph group_context["packages/context"] pkg_time_context["time-context"] end + subgraph group_examples["packages/examples"] + pkg_acp_demo["acp-demo"] + pkg_agent_spine_demo["agent-spine-demo"] + pkg_jsonrpc_demo["jsonrpc-demo"] + pkg_stdio_demo["stdio-demo"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -280,17 +282,6 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow - pkg_agent_core --> pkg_agent - pkg_agent_core --> pkg_agent_loop - pkg_agent_core --> pkg_invariants - pkg_agent_core --> pkg_llm - pkg_agent_core --> pkg_session - pkg_agent_core --> pkg_skill - pkg_agent_core --> pkg_skill_local - pkg_agent_core --> pkg_system_prompt - pkg_agent_core --> pkg_tool_bash - pkg_agent_core --> pkg_tool_skill - pkg_agent_core --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_subagent @@ -319,6 +310,17 @@ flowchart TD pkg_jsonrpc --> pkg_llm_deepseek pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tools pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm @@ -332,22 +334,22 @@ flowchart TD pkg_subagent_fork --> pkg_subagent_inprocess pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess - pkg_acp_agent --> pkg_acp - 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 - pkg_stdio_agent --> pkg_app_boot - pkg_stdio_agent --> pkg_llm - pkg_stdio_agent --> pkg_session - pkg_stdio_agent --> pkg_session_persistence_jsonl - pkg_stdio_agent --> pkg_stdio - pkg_stdio_agent --> pkg_tool_ask_user - pkg_stdio_agent --> pkg_tools - pkg_stdio_agent --> pkg_user_interaction + pkg_acp_demo --> pkg_acp + pkg_acp_demo --> pkg_agent_spine_demo + pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_session_persistence_jsonl + pkg_acp_demo --> pkg_tools + pkg_acp_demo --> pkg_user_interaction + pkg_stdio_demo --> pkg_agent + pkg_stdio_demo --> pkg_agent_spine_demo + pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_llm + pkg_stdio_demo --> pkg_session + pkg_stdio_demo --> pkg_session_persistence_jsonl + pkg_stdio_demo --> pkg_stdio + pkg_stdio_demo --> pkg_tool_ask_user + pkg_stdio_demo --> pkg_tools + pkg_stdio_demo --> pkg_user_interaction ``` | Package | Group | Depends on | @@ -360,8 +362,8 @@ flowchart TD | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | -| [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | +| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | @@ -414,15 +416,15 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`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), [`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), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`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-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index f3d4178e3b..0582515c62 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -12,18 +12,18 @@ The leaf configs also owned a coupled front door. ACP requires stdout purity and Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. -- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. -- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. +- **`@deepseek-ai/dsh-agent-spine-demo`** ([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. +- **`@deepseek-ai/dsh-stdio-demo`** ([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo)) and **`@deepseek-ai/dsh-acp-demo`** ([packages/examples/acp-demo](../../../../packages/examples/acp-demo)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. +- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-demo` / `dsh-acp-demo`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-demo ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). -- **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. -- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-core`. +- **echo-agent folds onto `dsh-stdio-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. +- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`. `bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. ### Amendment on implementation: `hmr` stays a leaf entry -The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-agent` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: +The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-demo` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: 1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. 2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function. @@ -45,11 +45,11 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Consequences -- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight. +- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight. - **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. ## Related -- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted. - Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 372058dc04..b177af24e9 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,12 +23,12 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay goldens, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving surface is a plugin: the two packages ui/jsonrpc + ui/jsonrpc-agent +### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo -The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `ui/acp-agent` pattern — the serving surface is itself a plugin: +The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: - [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md) (`@deepseek-ai/dsh-jsonrpc-agent`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. @@ -40,13 +40,13 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. @@ -54,7 +54,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ### Naming lineage -`@deepseek-ai/dsh-jsonrpc-agent` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. +`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. ## Disposition of worker-style plugins diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cd12a65d18..0b964e8a74 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,12 +23,12 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放 golden、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 -### 对外服务接口也是插件:ui/jsonrpc + ui/jsonrpc-agent 两包 +### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两包 -确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——对外服务接口本身也是插件: +确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: - [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 -- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。 @@ -40,13 +40,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。 @@ -54,7 +54,7 @@ exe“必须显式配置”的硬语义不变;零配置体验由包装层恢 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 ## 工作线程插件 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index 9320189de1..d7c6631b94 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -20,7 +20,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway ## UI mappings -`dsh-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. +`dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. `dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. @@ -46,4 +46,4 @@ The feature gives the model a powerful pause primitive, so prompt guidance matte ## Testing -Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-agent` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-demo` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.md index c29140a854..0b74aa00ae 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.md @@ -10,7 +10,7 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth ## Decision -`@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the session-prefix catalog and model-facing loader tool. `dsh-agent-core` loads the registry, local provider, and consumer by default so stdio and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. +`@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the session-prefix catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so stdio and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session prefix. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 521ddd55e7..4ac066e393 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -23,7 +23,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. +The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index e8ef337206..c56e1eb0ba 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -21,7 +21,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). -Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-agent`, `dsh-acp-agent`) accept the key and forward it through `dsh-agent-core` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. +Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-demo`, `dsh-acp-demo`) accept the key and forward it through `dsh-agent-spine-demo` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md index 13e0eff4b9..105bf53550 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md @@ -12,7 +12,7 @@ Prompt assembly can derive both facts per step from durable session timestamps, ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-core` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-spine-demo` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section. @@ -45,7 +45,7 @@ Unit tests pin formatting, baselines, refresh policy, validation, per-agent stat - **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. - **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it. - **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either. -- **Mount the plugin in `dsh-agent-core`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. +- **Mount the plugin in `dsh-agent-spine-demo`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. - **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. ## Consequences diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md index 5ee50a4d49..60e9004b14 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-core` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 @@ -45,7 +45,7 @@ agent loop(智能体循环)会在发送前通过 `request/header` 和 `reque - **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 - **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 - **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 -- **在 `dsh-agent-core` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 +- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 - **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 ## 后果 diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md index 51328a5a1a..af08c96be3 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md @@ -13,7 +13,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibili Two Node features gate the source runtime: - **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. -- **Native TypeScript type-stripping** — the `packages/ui/stdio-agent/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. +- **Native TypeScript type-stripping** — the `packages/examples/stdio-demo/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 634e8ac6ca..d3775754b9 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-agent`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. +The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 7253f8a1fe..191ddd833f 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -6,7 +6,7 @@ Status: implemented Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: -1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/examples/acp-demo/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. 2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". ## Decision diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index b3b8831a32..1fb887db52 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -22,7 +22,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su - **Copy the modules into each example** — the fork this RFC exists to prevent: the record/guard logic is exactly the code that must stay byte-identical across suites, and examples are outside the coverage gate, so each copy is also unmeasured. - **A shared module directory under `examples/`** — keeps the code outside the coverage gate and forces relative imports across example boundaries, against the package-name import convention; `examples/` leaves stay thin by design. -- **A `/testing` subpath export of `dsh-acp-agent`** — couples test infrastructure into a product package's surface and dependency set; `packages/support/` exists precisely for real-but-lower-compatibility dev/test packages, with `dsh-llm-replay` as the precedent this package completes. +- **A `/testing` subpath export of `dsh-acp-demo`** — couples test infrastructure into a product package's surface and dependency set; `packages/support/` exists precisely for real-but-lower-compatibility dev/test packages, with `dsh-llm-replay` as the precedent this package completes. - **Export raw test-body functions instead of a suite factory** — each example would re-own the `describe`/`it` skeleton (~80 lines of registration boilerplate per suite) for no flexibility gain; the factory keeps consumers to a scenario table plus one call, and the exported pure helpers preserve unit-testability inside the factory design. - **An injectable ACP `Client` factory instead of declarative `permissionAnswers`** — maximally flexible, but it leaks SDK client construction to every consumer and reopens per-example drift in exactly the layer being unified; a declarative queue keeps `input.json` the single scripting surface and stays golden-normalizable. - **Generalize beyond ACP (a transport-agnostic snapshot harness)** — no second transport exists; the harness is ACP-shaped end to end (SDK client, JSON-RPC frames, `session/update` waiters), and a speculative abstraction would be a seam split ahead of any consumer. diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index 9746d4ac07..0fd4ca27f7 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,6 +1,6 @@ # RFC: Make the shared example base providerless -Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem diff --git a/examples/README.md b/examples/README.md index 2cd4ea7a04..5db1e18372 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,12 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent -A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-agent`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: +A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-demo`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: -- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-agent` app +- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` - "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter @@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. The UI is a terminal readline REPL. +A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. The UI is a terminal readline REPL. 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. @@ -29,7 +29,7 @@ Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/R ## acp-agent -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. +An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 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`); `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. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 7e796d4de7..35218f4753 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -11,7 +11,7 @@ The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval an ## stdout is the protocol -This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` includes no logger entry, so this leaf has none to get wrong by default; do not add one (use a stderr exporter if you need logs). +This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-demo` includes no logger entry, so this leaf has none to get wrong by default; do not add one (use a stderr exporter if you need logs). ## Zed configuration diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index 5e14c40995..3c8a57f32a 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -8,7 +8,7 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index 39520be5a8..630a9c10f7 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -6,7 +6,7 @@ path: ./cordis.yml patches: - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 09dbe796fd..c82025252d 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -10,7 +10,7 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index d92a66c250..1624e43330 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -8,7 +8,7 @@ path: ./cordis.yml patches: - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index 14c36e4399..ea3de598d8 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -10,7 +10,7 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index d7d60f5af9..0170e808ce 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -9,7 +9,7 @@ path: ./cordis.yml patches: - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b93b4cb1e2..194ff3dee0 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -18,9 +18,9 @@ flowchart LR cfg --> plugin_acp_approval plugin_acp_permission["permission
@deepseek-ai/dsh-permission"] cfg --> plugin_acp_permission - plugin_acp_acp_agent["acp-agent
@deepseek-ai/dsh-acp-agent"] + plugin_acp_acp_agent["acp-agent
@deepseek-ai/dsh-acp-demo"] cfg --> plugin_acp_acp_agent - plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"] bundle_agent_core --> spine_llm["ctx.llm"] @@ -58,7 +58,7 @@ flowchart LR | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | -| `acp-agent` | `@deepseek-ai/dsh-acp-agent` | +| `acp-agent` | `@deepseek-ai/dsh-acp-demo` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 201feafe41..345fd49b45 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -37,7 +37,7 @@ # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it # (so it can harvest / isolate the log), else ./.sessions for the demo. - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index e02f534fa6..23c6f58301 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -23,7 +23,7 @@ import { */ // The child runs from a temp cwd, so its bin and config path are absolute. -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Resolve tsx absolutely because the subprocess runs outside the repo. const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0d29f7b5ce..3e605b3141 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -13,11 +13,11 @@ import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from * docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. */ -// The dsh-acp-agent bin (the demo:acp entry), this example's cordis.yml, and +// The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and // the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all // ABSOLUTE: the subprocess cwd is a temp dir outside the repo. const AGENT = { - binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 1a690367aa..507e938950 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -26,7 +26,7 @@ import { * or runner support self-skip; real denial markers remain on sandbox e2e tiers. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The subprocess runs from a temp cwd outside the repo; point tsx at the repo diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index dd7c6dca05..99cebb907e 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -23,7 +23,7 @@ import { * The test owns and disposes the ACP subprocess. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 5d6713144c..380ab8134f 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -48,14 +48,14 @@ and watch the transcript: one `run_code` call, a program looping over tools, and ## 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: +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-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-agent`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | +| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index 8b4d22b917..5d7198124c 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -9,7 +9,7 @@ path: ./cordis.yml patches: - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index be9a457124..90ff8222b9 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -14,9 +14,9 @@ flowchart LR cfg --> plugin_coding_llm_deepseek plugin_coding_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_coding_bash - plugin_coding_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-agent"] + plugin_coding_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] cfg --> plugin_coding_stdio_agent - plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] plugin_coding_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] @@ -54,7 +54,7 @@ flowchart LR | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 5581a910b1..600ffda160 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,4 +1,4 @@ -# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-agent` +# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` # supplies the agent-core spine, logging, JSONL persistence, readline UI, and `main` agent. # HMR remains a leaf because it requires Loader internals; `demo:repl` passes # `--expose-internals`. The app bin loads the gitignored root `.env`; this file @@ -29,7 +29,7 @@ # The app bundle pre-creates the REPL's `main` agent. - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: deepseek-v4-flash # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index ac2cb9430b..dd4239a2c4 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -8,7 +8,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l * a prompt and assert the banner. No model or `run_code` turn runs. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index 6cfeca646e..831d0daf5c 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -9,7 +9,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l * immediate EOF guarantees there is no model call. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 015bec724e..6a08c379fc 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,9 +20,9 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local - plugin_cordis_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-agent"] + plugin_cordis_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] cfg --> plugin_cordis_stdio_agent - plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] plugin_cordis_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] @@ -41,7 +41,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 2634233cec..8cd989e9e4 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -49,7 +49,7 @@ # The app bundle pre-creates the self-referential demo's `main` agent. - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index c1f20f1987..24cf2138fb 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -8,7 +8,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l * prompt and assert the banner. The dummy key never reaches a model call. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index dad0ce348a..d6156130db 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -4,7 +4,7 @@ Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-m ## What it shows -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app (which bundles the whole [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. - `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. @@ -17,16 +17,16 @@ Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates th |---|---|---| | `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol | | `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` | -| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-agent` entry carrying the app config | +| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-demo` entry carrying the app config | -The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-agent` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring. +The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-demo` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring. ## Run ```sh pnpm run demo:echo # or: -node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml +node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml ``` Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 1491c56955..5160bd20b7 100644 --- a/examples/echo-agent/composition.md +++ b/examples/echo-agent/composition.md @@ -16,9 +16,9 @@ flowchart LR cfg --> plugin_echo_echo_tool plugin_echo_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_echo_bash - plugin_echo_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-agent"] + plugin_echo_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] cfg --> plugin_echo_stdio_agent - plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] plugin_echo_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] @@ -33,7 +33,7 @@ flowchart LR | `mock-llm` | `./src/mock-llm.ts` | | `echo-tool` | `./src/echo-tool.ts` | | `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index a476927a89..fbf998e770 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -3,7 +3,7 @@ # No API key: the `mock-echo` adapter never touches the network. # Hot-module reload for the dev/demo loop (a leaf entry, not baked into -# dsh-stdio-agent — it needs `node --expose-internals`, which `demo:echo` passes). +# dsh-stdio-demo — it needs `node --expose-internals`, which `demo:echo` passes). - id: hmr name: '@cordisjs/plugin-hmr' config: @@ -24,7 +24,7 @@ # The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: mock-echo persona: 'You are echo-agent, a demo agent.' diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 1bd2733d94..db0d998336 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -8,7 +8,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l * and the complete behavior proof for the example. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/knip.json b/knip.json index ad33bec613..71c9d1e01d 100644 --- a/knip.json +++ b/knip.json @@ -82,11 +82,11 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/acp-agent": { + "packages/examples/acp-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/stdio-agent": { + "packages/examples/stdio-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, @@ -94,7 +94,7 @@ "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/jsonrpc-agent": { + "packages/examples/jsonrpc-demo": { "project": ["src/**/*.ts"] }, "packages/subagent/subagent-spawn": { diff --git a/package.json b/package.json index b436a161fe..081c2eb51e 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,11 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "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:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", + "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/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 --config examples/acp-agent/cordis.yml", + "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/context/README.md b/packages/context/README.md index 49a297d11a..0045c6629c 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -1,6 +1,6 @@ # context/ — optional request context -Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-core` bundle excludes them. +Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-spine-demo` bundle excludes them. | Package | Role | ctx key | |---|---|---| diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index b50470aef7..84d487445b 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-time-context -Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-core` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). +Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). ## Config diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/packages/context/time-context/tests/fixtures/cordis.yml index e9558abec6..da18fc11df 100644 --- a/packages/context/time-context/tests/fixtures/cordis.yml +++ b/packages/context/time-context/tests/fixtures/cordis.yml @@ -9,7 +9,7 @@ name: '@deepseek-ai/dsh-time-context' - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: mock-echo persona: 'Test the time-context plugin.' diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index daa6e9a9b8..f83b451ef7 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session' -const binScript = fileURLToPath(new URL('../../../ui/stdio-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) diff --git a/packages/ui/acp-agent/README.md b/packages/examples/acp-demo/README.md similarity index 79% rename from packages/ui/acp-agent/README.md rename to packages/examples/acp-demo/README.md index 63c9d3df23..3bc9863ccf 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/examples/acp-demo/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-acp-agent +# @deepseek-ai/dsh-acp-demo -The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. +The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. -It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. +It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. ## What it bakes in — and what it deliberately omits @@ -10,7 +10,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | Plugin | Why | |---|---| -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | @@ -28,15 +28,15 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `model` | (required) | the per-session agent template the bridge creates agents from | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | -| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` | -| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-core` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | +| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. ## The bin -`dsh-acp-agent [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`): +`dsh-acp-demo [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`): - loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call; - honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); @@ -48,7 +48,7 @@ All diagnostics go to **stderr** — stdout is the protocol. ## Model Experience -Indirectly, through `dsh-agent-core` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. +Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. ## Known Limitations and Deferred Work diff --git a/packages/ui/acp-agent/package.json b/packages/examples/acp-demo/package.json similarity index 79% rename from packages/ui/acp-agent/package.json rename to packages/examples/acp-demo/package.json index 0c1a45e713..3c4851c961 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,13 +1,13 @@ { - "name": "@deepseek-ai/dsh-acp-agent", - "description": "ACP server app: the agent-core spine + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "name": "@deepseek-ai/dsh-acp-demo", + "description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", "version": "0.0.1", "private": true, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-acp-agent": "lib/bin.js" + "dsh-acp-demo": "lib/bin.js" }, "exports": { ".": { @@ -34,7 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", - "@deepseek-ai/dsh-agent-core": "^0.0.1", + "@deepseek-ai/dsh-agent-spine-demo": "^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", @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/examples/acp-demo/src/bin.ts similarity index 89% rename from packages/ui/acp-agent/src/bin.ts rename to packages/examples/acp-demo/src/bin.ts index 62e562ad58..60127760e4 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/examples/acp-demo/src/bin.ts @@ -1,19 +1,19 @@ #!/usr/bin/env node /** * Boot an ACP stdio server from `cordis.yml`; usage is - * `dsh-acp-agent [--config path]`, defaulting to `./cordis.yml`. Shared env + * `dsh-acp-demo [--config path]`, defaulting to `./cordis.yml`. Shared env * loading, Loader guards, snapshot config selection, and settled-tree boot live * in dsh-app-boot. Replay skips `.env` and selects sibling * `cordis.snapshot.yml` so a stray key cannot trigger a model call. EOF disposes * and flushes snapshot runs; editors normally own process lifetime. Stdout is * reserved for JSON-RPC, so diagnostics go only to stderr. - * @module @deepseek-ai/dsh-acp-agent/bin + * @module @deepseek-ai/dsh-acp-demo/bin */ import { parseArgs } from 'node:util' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -const NAME = 'dsh-acp-agent' +const NAME = 'dsh-acp-demo' /* v8 ignore start -- thin self-executing composition over the unit-tested dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the diff --git a/packages/ui/acp-agent/src/index.ts b/packages/examples/acp-demo/src/index.ts similarity index 89% rename from packages/ui/acp-agent/src/index.ts rename to packages/examples/acp-demo/src/index.ts index 8f76728d8f..0439363301 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,23 +1,23 @@ /** - * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}), + * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It * writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see * docs/postmortem/0001). - * @module @deepseek-ai/dsh-acp-agent + * @module @deepseek-ai/dsh-acp-demo */ 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 * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' 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' -export const name = 'acp-agent' +export const name = 'acp-demo' /** * App config: the swappable per-deployment values. `model` configures the @@ -26,7 +26,7 @@ export const name = 'acp-agent' * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is * the explicit model-facing tool order (forwarded to the system-prompt plugin); * `tools` is the tool registry's config (its presentation `mode`, forwarded - * through agent-core); `persistenceRoot` is the JSONL backend's directory. + * through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory. */ export interface Config { /** Model name for ACP-created agents (must have a registered adapter). */ @@ -35,11 +35,11 @@ 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). */ + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig } @@ -62,7 +62,7 @@ export const Config: z = z.object({ /* jscpd:ignore-end */ /** - * Compose the spine with the ACP front door. The agent-core bundle pre-creates + * Compose the spine with the ACP front door. The agent-spine-demo bundle pre-creates * NO agents (its `agents` list defaults to `[]`) and carries the deployment * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP * bridge owns stdout for JSON-RPC and creates one agent per `session/new` diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts similarity index 93% rename from packages/ui/acp-agent/tests/acp-agent.spec.ts rename to packages/examples/acp-demo/tests/acp-agent.spec.ts index 52f62d8ac8..bb096776ff 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -10,7 +10,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import * as acpAgent from '../src/index.ts' /** - * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition: + * In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition: * mounting it brings up the agent-core spine + JSONL persistence + the ACP * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO * Loader-only plugin (no hmr), so it mounts in a plain Context. @@ -29,7 +29,7 @@ async function mount(config: acpAgent.Config): Promise { } async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise> { - const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-skills-')) return { local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }, ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {}, @@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise { async function withIsolatedSkillHomes(run: () => Promise): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME - const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-default-skills-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-default-skills-')) process.env.DSH_HOME = join(home, '.dsh') process.env.DSH_AGENTS_HOME = join(home, '.agents') try { @@ -67,9 +67,9 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -describe('dsh-acp-agent composition', () => { +describe('dsh-acp-demo composition', () => { it('brings up the spine + persistence + the ACP bridge', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig() }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -121,7 +121,7 @@ describe('dsh-acp-agent composition', () => { const ctx = await mount({ model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], - persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order', + persistenceRoot: '/tmp/dsh-acp-demo-test-tool-order', }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts similarity index 95% rename from packages/ui/acp-agent/tests/built-bin.e2e.ts rename to packages/examples/acp-demo/tests/built-bin.e2e.ts index b31082fa23..7b5749fee5 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -25,14 +25,14 @@ import { afterEach, describe, expect, it } from 'vitest' */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js') +const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js') const dshPackages = [ - 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', + 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent', + 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', @@ -82,7 +82,7 @@ async function makeConsumer(): Promise { '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', - ' name: \'@deepseek-ai/dsh-acp-agent\'', + ' name: \'@deepseek-ai/dsh-acp-demo\'', ' config:', ' model: deepseek-v4-flash', ' persona: \'test agent\'', @@ -100,7 +100,7 @@ afterEach(async () => { consumer = undefined }) -describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => { +describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => { it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => { consumer = await makeConsumer() child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], { diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts similarity index 96% rename from packages/ui/acp-agent/tests/load-path.e2e.ts rename to packages/examples/acp-demo/tests/load-path.e2e.ts index 2fd6db54d4..f1edf7832a 100644 --- a/packages/ui/acp-agent/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -26,7 +26,7 @@ import { const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Repo root is four levels up from packages/ui/acp-agent/tests. +// Repo root is four levels up from packages/examples/acp-demo/tests. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // A minimal leaf that loads this app + the two backends — the same shape as @@ -40,7 +40,7 @@ const CORDIS_YML = ` - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' + name: '@deepseek-ai/dsh-acp-demo' config: model: deepseek-v4-flash persona: 'You are a test agent.' @@ -105,7 +105,7 @@ async function boot(): Promise { return { ...spawned, cwd } } -describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => { +describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => { it('boots via its bin and answers initialize → session/new → session/load', async () => { const { client, cwd, stderr } = await boot() // initialize: a broken export shape (collapsed bridge plugin, dropped inject) diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/examples/acp-demo/tsconfig.json similarity index 74% rename from packages/ui/acp-agent/tsconfig.json rename to packages/examples/acp-demo/tsconfig.json index 5eb1c62282..6805405026 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -18,22 +18,22 @@ "path": "../../../vendor/loader" }, { - "path": "../app-boot" + "path": "../../ui/app-boot" }, { - "path": "../acp" + "path": "../../ui/acp" }, { "path": "../../core/agent" }, { - "path": "../../core/agent-core" + "path": "../agent-spine-demo" }, { - "path": "../user-interaction" + "path": "../../ui/user-interaction" }, { - "path": "../tool-ask-user" + "path": "../../ui/tool-ask-user" }, { "path": "../../session-persistence/session-persistence-jsonl" diff --git a/packages/ui/acp-agent/tsdown.config.ts b/packages/examples/acp-demo/tsdown.config.ts similarity index 100% rename from packages/ui/acp-agent/tsdown.config.ts rename to packages/examples/acp-demo/tsdown.config.ts diff --git a/packages/core/agent-core/README.md b/packages/examples/agent-spine-demo/README.md similarity index 91% rename from packages/core/agent-core/README.md rename to packages/examples/agent-spine-demo/README.md index fac4f4819a..a85f3e0889 100644 --- a/packages/core/agent-core/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-agent-core +# @deepseek-ai/dsh-agent-spine-demo The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. @@ -31,14 +31,14 @@ The spine is everything COMMON to every front door. The swappable and front-door - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). +- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. ## Config ```ts -import type { Config } from '@deepseek-ai/dsh-agent-core' +import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas, // so validation and defaulting can never drift from the owners. ``` diff --git a/packages/core/agent-core/package.json b/packages/examples/agent-spine-demo/package.json similarity index 97% rename from packages/core/agent-core/package.json rename to packages/examples/agent-spine-demo/package.json index 2c2b25e772..049530872a 100644 --- a/packages/core/agent-core/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-agent-core", + "name": "@deepseek-ai/dsh-agent-spine-demo", "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + invariants + tool-bash + tool-skill + agent-loop)", "version": "0.0.1", "private": true, diff --git a/packages/core/agent-core/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts similarity index 98% rename from packages/core/agent-core/src/index.ts rename to packages/examples/agent-spine-demo/src/index.ts index 14ffbd6133..d1aecaa4c0 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -4,7 +4,7 @@ * deployments still choose the LLM adapter, bash executor, and presentation. * The plugin intentionally exposes named exports only because Loader default * unwrapping would discard its `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-agent-core + * @module @deepseek-ai/dsh-agent-spine-demo */ import type { Context } from 'cordis' @@ -22,7 +22,7 @@ import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' -export const name = 'agent-core' +export const name = 'agent-spine-demo' /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ export interface SkillConfig { diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts similarity index 94% rename from packages/core/agent-core/tests/agent-core.spec.ts rename to packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 0bf0660364..38f046c6c6 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -19,7 +19,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { } /** - * Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings + * Unit coverage for the @deepseek-ai/dsh-agent-spine-demo bundle: mounting it brings * up the whole default spine in one `ctx.plugin`, and the forwarded * `agents` config reaches the loop (default `[]`, or a pre-created agent). * @@ -31,8 +31,8 @@ async function composePrefix(ctx: Context, cwd: string): Promise { async function mount(config?: agentCore.Config): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME - process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-')) - process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-')) + process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-')) + process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-')) const ctx = new Context() try { await ctx.plugin(agentCore, config) @@ -57,8 +57,8 @@ async function mount(config?: agentCore.Config): Promise { async function withIsolatedSkillHomes(run: () => Promise): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME - process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-')) - process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-')) + process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-')) + process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-')) try { return await run() } finally { @@ -75,7 +75,7 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -describe('dsh-agent-core bundle', () => { +describe('dsh-agent-spine-demo bundle', () => { it('brings up the full default spine', async () => { const ctx = await mount() // One service from each layer of the spine proves the children loaded. @@ -131,9 +131,9 @@ describe('dsh-agent-core bundle', () => { }) it('forwards skill config to the registry, local provider, and model-facing consumer', async () => { - const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-')) - const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-')) - const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-home-')) + const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-agents-')) + const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-custom-')) await mkdir(custom, { recursive: true }) await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n') const ctx = await mount({ diff --git a/packages/core/agent-core/tests/gen-config-catalog.spec.ts b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts similarity index 100% rename from packages/core/agent-core/tests/gen-config-catalog.spec.ts rename to packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts diff --git a/packages/core/agent-core/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json similarity index 100% rename from packages/core/agent-core/tsconfig.json rename to packages/examples/agent-spine-demo/tsconfig.json diff --git a/packages/ui/jsonrpc-agent/README.md b/packages/examples/jsonrpc-demo/README.md similarity index 98% rename from packages/ui/jsonrpc-agent/README.md rename to packages/examples/jsonrpc-demo/README.md index 4ced8d038a..ef717f1e16 100644 --- a/packages/ui/jsonrpc-agent/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-jsonrpc-agent +# @deepseek-ai/dsh-jsonrpc-demo Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. diff --git a/packages/ui/jsonrpc-agent/package.json b/packages/examples/jsonrpc-demo/package.json similarity index 95% rename from packages/ui/jsonrpc-agent/package.json rename to packages/examples/jsonrpc-demo/package.json index 1919a7336a..3b04fcc977 100644 --- a/packages/ui/jsonrpc-agent/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-jsonrpc-agent", + "name": "@deepseek-ai/dsh-jsonrpc-demo", "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", "version": "0.0.1", "private": true, diff --git a/packages/ui/jsonrpc-agent/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts similarity index 97% rename from packages/ui/jsonrpc-agent/src/bin.ts rename to packages/examples/jsonrpc-demo/src/bin.ts index 09af1028b9..ad17709efc 100644 --- a/packages/ui/jsonrpc-agent/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -7,7 +7,7 @@ * stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130. * Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames. * - * @module @deepseek-ai/dsh-jsonrpc-agent/bin + * @module @deepseek-ai/dsh-jsonrpc-demo/bin */ import { existsSync } from 'node:fs' diff --git a/packages/ui/jsonrpc-agent/src/index.ts b/packages/examples/jsonrpc-demo/src/index.ts similarity index 85% rename from packages/ui/jsonrpc-agent/src/index.ts rename to packages/examples/jsonrpc-demo/src/index.ts index 032644a7c9..d4a4017f62 100644 --- a/packages/ui/jsonrpc-agent/src/index.ts +++ b/packages/examples/jsonrpc-demo/src/index.ts @@ -3,7 +3,7 @@ * process exit. This module exports no composition plugin; the config chooses * whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin. * - * @module @deepseek-ai/dsh-jsonrpc-agent + * @module @deepseek-ai/dsh-jsonrpc-demo */ export {} diff --git a/packages/ui/jsonrpc-agent/tsconfig.json b/packages/examples/jsonrpc-demo/tsconfig.json similarity index 89% rename from packages/ui/jsonrpc-agent/tsconfig.json rename to packages/examples/jsonrpc-demo/tsconfig.json index e76defe8e2..83d6cf5aa2 100644 --- a/packages/ui/jsonrpc-agent/tsconfig.json +++ b/packages/examples/jsonrpc-demo/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/loader" }, { - "path": "../app-boot" + "path": "../../ui/app-boot" } ] } diff --git a/packages/ui/jsonrpc-agent/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts similarity index 100% rename from packages/ui/jsonrpc-agent/tsdown.config.ts rename to packages/examples/jsonrpc-demo/tsdown.config.ts diff --git a/packages/ui/stdio-agent/README.md b/packages/examples/stdio-demo/README.md similarity index 73% rename from packages/ui/stdio-agent/README.md rename to packages/examples/stdio-demo/README.md index 45e09c8dd6..5087a2fca0 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/examples/stdio-demo/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-stdio-agent +# @deepseek-ai/dsh-stdio-demo -The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. +The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. -It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. +It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. ## What it bakes in @@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` | +| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | @@ -28,17 +28,17 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `model` | (required) | the pre-created `main` agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | -| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` | -| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-core` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | +| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header. ## The bin -`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. +`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. ## Example leaf `cordis.yml` @@ -58,7 +58,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s config: timeoutMs: 60000 - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' + name: '@deepseek-ai/dsh-stdio-demo' config: model: deepseek-v4-flash persona: 'You are a coding assistant powered by the {{model}} model.' @@ -70,7 +70,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ### Composed terminal agent request -**What the model sees**: Through `dsh-agent-core`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message. +**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message. **Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens. diff --git a/packages/ui/stdio-agent/package.json b/packages/examples/stdio-demo/package.json similarity index 84% rename from packages/ui/stdio-agent/package.json rename to packages/examples/stdio-demo/package.json index 5861ae2b12..3fe11f47a2 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/examples/stdio-demo/package.json @@ -1,13 +1,13 @@ { - "name": "@deepseek-ai/dsh-stdio-agent", - "description": "Terminal stdio chat app: the agent-core spine + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", + "name": "@deepseek-ai/dsh-stdio-demo", + "description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", "version": "0.0.1", "private": true, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-stdio-agent": "lib/bin.js" + "dsh-stdio-demo": "lib/bin.js" }, "exports": { ".": { @@ -36,7 +36,7 @@ "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-agent-core": "^0.0.1", + "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", @@ -53,7 +53,7 @@ "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/examples/stdio-demo/src/bin.ts similarity index 85% rename from packages/ui/stdio-agent/src/bin.ts rename to packages/examples/stdio-demo/src/bin.ts index f3eaf5cf34..462e821e50 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/examples/stdio-demo/src/bin.ts @@ -1,14 +1,14 @@ #!/usr/bin/env node /** - * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the + * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs. - * @module @deepseek-ai/dsh-stdio-agent/bin + * @module @deepseek-ai/dsh-stdio-demo/bin */ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -const NAME = 'dsh-stdio-agent' +const NAME = 'dsh-stdio-demo' /* v8 ignore start -- thin self-executing composition over the unit-tested dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/examples/stdio-demo/src/index.ts similarity index 91% rename from packages/ui/stdio-agent/src/index.ts rename to packages/examples/stdio-demo/src/index.ts index fb125cec56..89f4142af6 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -1,12 +1,12 @@ /** - * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the + * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the * coupled front-door cluster a terminal chat needs — a console logger, the independently * packaged readline UI, JSONL session persistence, the user-interaction seam with its * `ask_user_question` tool, and a pre-created `main` agent the UI drives. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-stdio-agent + * @module @deepseek-ai/dsh-stdio-demo */ import type { Context } from 'cordis' @@ -15,18 +15,18 @@ 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 * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' -export const name = 'stdio-agent' +export const name = 'stdio-demo' /** * App config: the swappable per-demo values, each routed to where the app wires * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is + * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `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); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions @@ -40,13 +40,13 @@ 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). */ + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; 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.'`. */ welcome?: string - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** * If set, the `main` agent RESUMES this persisted session id instead of @@ -74,7 +74,7 @@ export const Config: z = z.object({ /** * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-core bundle pre-creating the `main` agent from this + * (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is * a leaf concern (see the module doc), so it is not mounted here. diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts similarity index 95% rename from packages/ui/stdio-agent/tests/built-bin.e2e.ts rename to packages/examples/stdio-demo/tests/built-bin.e2e.ts index 0f791782e8..fdbeb2b8e4 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -14,16 +14,16 @@ import { afterEach, describe, expect, it } from 'vitest' */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') +const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') // Symlink each required workspace package by package name so plain Node resolves its built `main`, // matching an installed dependency rather than tsconfig paths. const dshPackages = [ - 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', + 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', + 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', ] const vendorPackages = [ @@ -71,7 +71,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: stdio-agent', - ' name: \'@deepseek-ai/dsh-stdio-agent\'', + ' name: \'@deepseek-ai/dsh-stdio-demo\'', ' config:', ' model: mock-echo', ' persona: \'demo\'', @@ -120,7 +120,7 @@ afterEach(async () => { consumer = undefined }) -describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.js, no tsx)', () => { +describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => { it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => { consumer = await makeConsumer('BUILT-BIN-OK ready.') const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts similarity index 94% rename from packages/ui/stdio-agent/tests/stdio-agent.spec.ts rename to packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 6bbba0e492..6c867ca393 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -25,7 +25,7 @@ async function mount(config: stdioAgent.Config): Promise { } async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise> { - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-')) return { local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }, ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {}, @@ -44,7 +44,7 @@ async function composePrefix(ctx: Context): Promise { async function withIsolatedSkillHomes(run: () => Promise): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-')) process.env.DSH_HOME = join(home, '.dsh') process.env.DSH_AGENTS_HOME = join(home, '.agents') try { @@ -63,9 +63,9 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -describe('dsh-stdio-agent app', () => { +describe('dsh-stdio-demo app', () => { it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() }) // The spine services (brought up by the agent-core bundle) are all present. expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() @@ -111,7 +111,7 @@ describe('dsh-stdio-agent app', () => { const ctx = await mount({ model: 'mock', persona: 'hi', - persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', + persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume', resumeSessionId: 'no-such-session', skills: await isolatedSkillsConfig(), }) @@ -135,7 +135,7 @@ describe('dsh-stdio-agent app', () => { const ctx = await mount({ model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], - persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order', + persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order', }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json similarity index 77% rename from packages/ui/stdio-agent/tsconfig.json rename to packages/examples/stdio-demo/tsconfig.json index b0bfa760c3..bb360810a5 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../../vendor/loader" }, { - "path": "../app-boot" + "path": "../../ui/app-boot" }, { "path": "../../../vendor/logger-console" @@ -30,16 +30,16 @@ "path": "../../core/session" }, { - "path": "../../core/agent-core" + "path": "../agent-spine-demo" }, { - "path": "../user-interaction" + "path": "../../ui/user-interaction" }, { - "path": "../stdio" + "path": "../../ui/stdio" }, { - "path": "../tool-ask-user" + "path": "../../ui/tool-ask-user" }, { "path": "../../session-persistence/session-persistence-jsonl" diff --git a/packages/ui/stdio-agent/tsdown.config.ts b/packages/examples/stdio-demo/tsdown.config.ts similarity index 100% rename from packages/ui/stdio-agent/tsdown.config.ts rename to packages/examples/stdio-demo/tsdown.config.ts diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 8adf5df07b..69a5f14181 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th config: providerName: acp command: node - args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml'] + args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml'] permission: reject env: DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 73070ab585..0c20b27fa1 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -15,7 +15,7 @@ import * as acp from '../src/index.ts' */ // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). -const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url)) const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) diff --git a/packages/support/README.md b/packages/support/README.md index e9c72a558d..d1bb1883ed 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -10,4 +10,4 @@ Packages that exist to serve development, testing, and the examples rather than | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 644ec2853d..03254a17a0 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -21,7 +21,7 @@ const SCENARIOS: Scenario[] = [ defineAcpSnapshotSuite({ agent: { // absolute paths, resolved from the suite's own location - binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), }, diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 7fd8cf25a8..3bae0ba279 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -37,7 +37,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) * them from its own `import.meta.url`. */ export interface AgentUnderTest { - /** The agent bin entry (e.g. `packages/ui/acp-agent/src/bin.ts`), run unbuilt via tsx. */ + /** The agent bin entry (e.g. `packages/examples/acp-demo/src/bin.ts`), run unbuilt via tsx. */ binScript: string /** * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 54fb26b333..db8cf9e231 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -2,7 +2,7 @@ Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior. -The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-core`](../../core/agent-core/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. +The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8da5429b06..d6e2b37946 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -2,7 +2,7 @@ * Runtime listeners that fail loudly when cross-event contracts are broken: * turn and step nesting, scoped dispatch, status transitions, and request * reconstruction. The plugin has no environment guard and is active wherever - * mounted, including the default `dsh-agent-core` bundle; custom compositions + * mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions * may omit it. Sessions still own event snapshots and freezing. * @module @deepseek-ai/dsh-invariants */ diff --git a/packages/todo/README.md b/packages/todo/README.md index 3f427295c1..b6a9ce2fc5 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 5736d23e85..0b07539f3d 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index c3deaa4f48..a0af1c5a69 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-app-boot` -Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. +Shared boot glue for the app bins ([`dsh-stdio-demo`](../stdio-agent/README.md), [`dsh-acp-demo`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. | Export | Role | |---|---| diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 3521769816..91ab0d3a2f 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load the gitignored + * Shared boot glue for the app bins (`dsh-stdio-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and * drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled. * @module @deepseek-ai/dsh-app-boot diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index be98f173de..bb91ab4fc2 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts index 9c96e815dc..3b4e585ff6 100644 --- a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import * as agentCore from '@deepseek-ai/dsh-agent-core' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 1cde550f2e..8c024c6e81 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import * as agentCore from '@deepseek-ai/dsh-agent-core' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e0a2af98a..857a8406ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,52 +332,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/core/agent-core: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@cordisjs/plugin-timer': - specifier: workspace:^ - version: link:../../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../skill/skill-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../../bash/tool-bash - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../skill/tool-skill - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/core/agent-loop: dependencies: schemastery: @@ -483,6 +437,152 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/examples/acp-demo: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../../ui/acp + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:^ + version: link:../agent-spine-demo + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../ui/app-boot + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@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:../../ui/user-interaction + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + + packages/examples/agent-spine-demo: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../skill/skill-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/examples/jsonrpc-demo: + dependencies: + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../ui/app-boot + devDependencies: + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/examples/stdio-demo: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@cordisjs/plugin-logger-console': + specifier: workspace:^ + version: link:../../../vendor/logger-console + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:^ + version: link:../agent-spine-demo + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../ui/app-boot + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-stdio': + specifier: workspace:^ + version: link:../../ui/stdio + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../ui/tool-ask-user + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../ui/user-interaction + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': @@ -1313,45 +1413,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/ui/acp-agent: - devDependencies: - '@cordisjs/plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-acp': - specifier: workspace:^ - version: link:../acp - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-core': - specifier: workspace:^ - version: link:../../core/agent-core - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../app-boot - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - '@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 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - packages/ui/app-boot: devDependencies: '@cordisjs/plugin-include': @@ -1376,9 +1437,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-core': + '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ - version: link:../../core/agent-core + version: link:../../examples/agent-spine-demo '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1398,16 +1459,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/ui/jsonrpc-agent: - dependencies: - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../app-boot - devDependencies: - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/ui/permission: dependencies: schemastery: @@ -1455,57 +1506,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/ui/stdio-agent: - devDependencies: - '@cordisjs/plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@cordisjs/plugin-logger-console': - specifier: workspace:^ - version: link:../../../vendor/logger-console - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-core': - specifier: workspace:^ - version: link:../../core/agent-core - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../app-boot - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-stdio': - specifier: workspace:^ - version: link:../stdio - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@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 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - packages/ui/tool-ask-user: devDependencies: '@deepseek-ai/dsh-agent': @@ -1799,12 +1799,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-core': - specifier: workspace:^ - version: link:../../packages/core/agent-core '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:^ + version: link:../../packages/examples/agent-spine-demo '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot @@ -1853,9 +1853,9 @@ importers: '@deepseek-ai/dsh-jsonrpc': specifier: workspace:^ version: link:../../packages/ui/jsonrpc - '@deepseek-ai/dsh-jsonrpc-agent': + '@deepseek-ai/dsh-jsonrpc-demo': specifier: workspace:^ - version: link:../../packages/ui/jsonrpc-agent + version: link:../../packages/examples/jsonrpc-demo '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm diff --git a/python/README.md b/python/README.md index a04a0f99c9..30bca971f1 100644 --- a/python/README.md +++ b/python/README.md @@ -45,8 +45,8 @@ with DeepSeekHarness() as harness: Two flavors, both for repo members: -- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. -- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. +- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. +- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. ## Distributing the Python packages diff --git a/python/README.zh.md b/python/README.zh.md index 04e58c5a39..d2eea59d14 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -45,8 +45,8 @@ with DeepSeekHarness() as harness: 两种方式,均面向仓库成员: -- **已构建的 `node` 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次运行构建脚本都会刷新,与 exe 打入 pkg 虚拟文件系统(VFS)的是同一份依赖闭包,因此插件语义一致。它不会被自动选中,也不进入分发物。 -- **未构建的源码(tsx)**——把客户端直接指向 `bin` 的 TypeScript 源码,用于编辑、运行和调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,再通过 `cordis=...` 传入配置(或使用默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 +- **已构建的 `node` 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`。这棵树每次运行构建脚本都会刷新,与 exe 打入 pkg 虚拟文件系统(VFS)的是同一份依赖闭包,因此插件语义一致。它不会被自动选中,也不进入分发物。 +- **未构建的源码(tsx)**——把客户端直接指向 `bin` 的 TypeScript 源码,用于编辑、运行和调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts")`,`cwd` 设为仓库根,再通过 `cordis=...` 传入配置(或使用默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 ## 分发 Python 包 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index a28ae59971..5525e8a7b8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -9,7 +9,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: - **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 10dbb6b14f..30723d6d78 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -9,7 +9,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: - **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index cff5eda404..491d975e04 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,7 +10,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", @@ -28,7 +28,7 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", - "@deepseek-ai/dsh-jsonrpc-agent": "workspace:^", + "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 9076861e88..4a014d5a5b 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -8,7 +8,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's {x64, arm64}); the target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 61e19be267..a47dcc26a7 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -8,7 +8,7 @@ # Agent spine; the SDK server creates agents per sessionId. - id: agent-core - name: '@deepseek-ai/dsh-agent-core' + name: '@deepseek-ai/dsh-agent-spine-demo' # Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown # may use a dummy key because they do not call the model. diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index 8173c40f7f..39a00856b7 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -44,7 +44,7 @@ class MockCompletionHandler(BaseHTTPRequestHandler): def run_smoke(repo_root: Path, keep_sessions: bool) -> None: session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) - runtime_entry = repo_root / "packages/ui/jsonrpc-agent/src/bin.ts" + runtime_entry = repo_root / "packages/examples/jsonrpc-demo/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) thread.start() diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index e480147dfe..0ee373102e 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -21,7 +21,7 @@ _CORDIS_YML = """\ - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' - id: agent-core - name: '@deepseek-ai/dsh-agent-core' + name: '@deepseek-ai/dsh-agent-spine-demo' - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index d05c4fded7..4858686191 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -15,7 +15,7 @@ from deepseek_harness_runtime import ( def test_default_config_is_shipped_with_the_package() -> None: path = bundled_default_config_path() assert path == bundled_package_dir() / "runtime" / "cordis.yml" - assert "@deepseek-ai/dsh-agent-core" in path.read_text() + assert "@deepseek-ai/dsh-agent-spine-demo" in path.read_text() def test_unknown_explicit_mode_fails_loud() -> None: diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 8501bde1fe..ebca6cebd7 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -17,7 +17,7 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js' +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 76d5fe9215..edfc26b4d5 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -9,8 +9,8 @@ import { spawn } from 'node:child_process' // 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', '--config', 'examples/acp-agent/code-mode.cordis.yml']], + ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) const ui = process.argv[2] ?? 'repl' diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a6660f7cd3..0e1774c2e0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -129,8 +129,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'user-interaction', title: 'Human question/answer seam', mode: 'seam', - implementations: ['stdio-agent', 'acp'], - consumers: ['tool-ask-user', 'stdio-agent', 'acp'], + implementations: ['stdio-demo', 'acp'], + consumers: ['tool-ask-user', 'stdio-demo', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { @@ -147,7 +147,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent registry', mode: 'core', - consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'], + consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'], note: 'Owns live Agent handles and the create/resume factory seam.', }, { @@ -155,7 +155,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent-loop', title: 'Concrete loop driver', mode: 'bundle', - consumers: ['agent-core'], + consumers: ['agent-spine-demo'], note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.', }, { @@ -422,11 +422,11 @@ type AppExample = typeof APP_EXAMPLES[number] function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void { const agentCore = nodeId('bundle', 'agent_core') const jsonl = nodeId('bundle', 'jsonl') - lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`) + lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) - if (pluginName === '@deepseek-ai/dsh-stdio-agent') { + if (pluginName === '@deepseek-ai/dsh-stdio-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI
console logger
pre-created main agent"]`) - } else if (pluginName === '@deepseek-ai/dsh-acp-agent') { + } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]`) } lines.push( @@ -452,7 +452,7 @@ function renderAppComposition(example: AppExample): string { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) - if (plugin.name === '@deepseek-ai/dsh-stdio-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') { + if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { renderAppExpansion(lines, pluginNode, plugin.name) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 7f23a666ae..4de4742c9d 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -335,8 +335,8 @@ function builtBinSmokeGate(): Gate { 'run', '--config', 'vitest.e2e.config.ts', - 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', - 'packages/ui/acp-agent/tests/built-bin.e2e.ts', + 'packages/examples/stdio-demo/tests/built-bin.e2e.ts', + 'packages/examples/acp-demo/tests/built-bin.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 2c61d07c19..be9d36a6cd 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -55,7 +55,7 @@ CUSTOM_CORDIS = """\ - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' - id: agent-core - name: '@deepseek-ai/dsh-agent-core' + name: '@deepseek-ai/dsh-agent-spine-demo' config: tools: mode: both diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e7c4ada82..8bd2d615ed 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,7 +42,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, - 'packages/core/agent-core': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, + 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, @@ -58,9 +58,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' }, - 'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' }, + 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, - 'packages/ui/jsonrpc-agent': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, + 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index fc1e9f488e..05725fe0ae 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -31,7 +31,7 @@ { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/core/agent-loop" }, - { "path": "./packages/core/agent-core" }, + { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/bash/bash" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, @@ -57,12 +57,12 @@ { "path": "./packages/timeout/timeout-policy" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, - { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/examples/acp-demo" }, { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, - { "path": "./packages/ui/jsonrpc-agent" }, + { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/stdio" }, - { "path": "./packages/ui/stdio-agent" }, + { "path": "./packages/examples/stdio-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, diff --git a/tsconfig.json b/tsconfig.json index 02b01678ca..af6e900f0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,7 @@ { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/core/agent-loop" }, - { "path": "./packages/core/agent-core" }, + { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/bash/bash" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, @@ -68,12 +68,12 @@ { "path": "./packages/timeout/timeout-policy" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, - { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/examples/acp-demo" }, { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, - { "path": "./packages/ui/jsonrpc-agent" }, + { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/stdio" }, - { "path": "./packages/ui/stdio-agent" }, + { "path": "./packages/examples/stdio-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, From 2a48b31306ce7dac8d0ae598a78fa7605a193f3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:58:47 +0800 Subject: [PATCH 30/33] docs: rewrite group maps for the examples/ relocation Add the packages/examples/ group README, drop the moved packages from the core/ and ui/ group READMEs, and add the examples/ row to the packages hierarchy table and the repo-layout in AGENTS.md. --- AGENTS.md | 9 +++++---- packages/README.md | 5 +++-- packages/core/README.md | 3 +-- packages/examples/README.md | 20 ++++++++++++++++++++ packages/ui/README.md | 5 +---- 5 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 packages/examples/README.md diff --git a/AGENTS.md b/AGENTS.md index 22c1f47aa8..d692064058 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every ``` vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ Harness packages at packages///, all named @deepseek-ai/dsh- - core/ product API spine: session, system-prompt, tools, agent, agent-loop, agent-core (the bundle) + core/ product API spine: session, system-prompt, tools, agent, agent-loop llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools @@ -26,11 +26,12 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/JSON-RPC front doors; boot, approval, and interaction plugins + ui/ ACP/stdio/JSON-RPC bridges; boot glue, approval, and interaction plugins + examples/ demo app bundles: the agent-spine bundle + stdio/ACP/JSON-RPC app bins the runnable leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) -examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) +examples/ Runnable cordis.yml leaves that load the packages/examples bundles (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators ``` @@ -78,7 +79,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/packages/README.md b/packages/README.md index 5cb4cfe419..1428fe07c6 100644 --- a/packages/README.md +++ b/packages/README.md @@ -27,7 +27,8 @@ Packages are grouped by modular role at `packages///`. The group dir | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, stdio channel, boot glue, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`examples/`](examples/README.md) | Ready-to-run demo bundles: the agent-spine bundle + stdio/ACP/JSON-RPC app bins that thin `cordis.yml` leaves (and the Python runtime) load | Support — example infra, lower compatibility | | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | @@ -37,6 +38,6 @@ The split is the point: a package's group says whether it is part of the product The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). +The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/core/README.md b/packages/core/README.md index b132b04d49..921591d85e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,10 +10,9 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | -| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. -`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle. +The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door. diff --git a/packages/examples/README.md b/packages/examples/README.md new file mode 100644 index 0000000000..5ee0206d0f --- /dev/null +++ b/packages/examples/README.md @@ -0,0 +1,20 @@ +# examples/ — ready-to-run demo bundles + +Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. + +| Package | npm name | Role | +|---|---|---| +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | +| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | + +`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. + +These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. + +Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. + +## The jsonrpc bin/exe names are legacy + +`jsonrpc-demo` renamed like its siblings, but its bin is still `dsh-jsonrpc-agent` and the single-file executable is still `dsh-jsonrpc-agent-pkg` (referenced across the [Python distribution](../../python/sdk-runtime/README.md)). Those names are the SDK's runtime-startup surface; they are reconciled when the SDK unifies that startup flow, not by this move. diff --git a/packages/ui/README.md b/packages/ui/README.md index 29ff591ee5..699a11bdb4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -10,14 +10,11 @@ Integrations that expose the agent to an external editor or client. These are ** | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) | -| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | -| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | -| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -`stdio-agent` and `acp-agent` compose the [`agent-core`](../core/agent-core/README.md) spine with their front-door plugins and own their boot bins; a leaf `cordis.yml` supplies backends and optional tools. `jsonrpc-agent` is bin-only because its external config also chooses the serving `jsonrpc` plugin. Each lives in `ui/` as a user-facing front door whose artifact owns its stdout policy. +The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. From e4691a2f7979a71e3a361fdba7041b1b11c4196f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:05:58 +0800 Subject: [PATCH 31/33] fix: register the examples/ group in the tsconfig paths map The @deepseek-ai/dsh-* wildcard maps each package name to its group's src; the new packages/examples/ group needs its own glob, or the four moved packages fall back to unbuilt lib/ and vitest cannot resolve them. Also update the moved specs' plugin export-name assertions (acp-agent -> acp-demo, stdio-agent -> stdio-demo, agent-core -> agent-spine-demo). --- packages/examples/acp-demo/tests/acp-agent.spec.ts | 4 ++-- packages/examples/agent-spine-demo/tests/agent-core.spec.ts | 4 ++-- packages/examples/stdio-demo/tests/stdio-agent.spec.ts | 4 ++-- tsconfig.base.json | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index bb096776ff..f3495c827c 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -113,7 +113,7 @@ describe('dsh-acp-demo composition', () => { }) it('exposes its plugin shape', () => { - expect(acpAgent.name).toBe('acp-agent') + expect(acpAgent.name).toBe('acp-demo') expect(acpAgent.Config).toBeDefined() }) @@ -147,7 +147,7 @@ describe('dsh-acp-demo composition', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(acpAgent) as Record expect(unwrapped).toBe(acpAgent) - expect(unwrapped.name).toBe('acp-agent') + expect(unwrapped.name).toBe('acp-demo') expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 38f046c6c6..ecf997f3cd 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -183,7 +183,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('re-exports the loop config schema as its own', () => { expect(agentCore.Config).toBeDefined() - expect(agentCore.name).toBe('agent-core') + expect(agentCore.name).toBe('agent-spine-demo') }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { @@ -195,7 +195,7 @@ describe('dsh-agent-spine-demo bundle', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(agentCore) as Record expect(unwrapped).toBe(agentCore) - expect(unwrapped.name).toBe('agent-core') + expect(unwrapped.name).toBe('agent-spine-demo') expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 6c867ca393..20bcaa5704 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -127,7 +127,7 @@ describe('dsh-stdio-demo app', () => { }) it('exposes its name and Config schema', () => { - expect(stdioAgent.name).toBe('stdio-agent') + expect(stdioAgent.name).toBe('stdio-demo') expect(stdioAgent.Config).toBeDefined() }) @@ -161,7 +161,7 @@ describe('dsh-stdio-demo app', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(stdioAgent) as Record expect(unwrapped).toBe(stdioAgent) - expect(unwrapped.name).toBe('stdio-agent') + expect(unwrapped.name).toBe('stdio-demo') expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/tsconfig.base.json b/tsconfig.base.json index a4288b2dd3..74696dc2d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -60,6 +60,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/ui/*/src", + "./packages/examples/*/src", "./packages/util/*/src", "./packages/mcp/*/src", "./packages/support/*/src" From 6dde9f70f0045036b72af0139b533e98227ad370 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:21:21 +0800 Subject: [PATCH 32/33] fix: satisfy doc gates after the examples/ relocation Repair cross-group README links whose targets changed groups (ui/jsonrpc, ui/app-boot -> examples; examples/* -> ui seams), re-record the bilingual-pair consistency hashes for the docs the rename touched on both language sides, and condense the AGENTS.md and packages/README.md group entries. The packages/README ceiling rises 710 -> 760 for the mandated new examples/ hierarchy row (5% headroom). --- AGENTS.md | 6 +++--- docs/cookbook/extension-cookbook.i18n.yaml | 4 ++-- ...ingle-file-executable-sdk-runtime-distribution.i18n.yaml | 4 ++-- .../feature/2026-07-14-time-context-plugin.i18n.yaml | 4 ++-- packages/README.md | 4 ++-- packages/examples/acp-demo/README.md | 4 ++-- packages/examples/jsonrpc-demo/README.md | 4 ++-- packages/ui/app-boot/README.md | 2 +- packages/ui/jsonrpc/README.md | 2 +- python/README.i18n.yaml | 4 ++-- python/sdk-runtime/README.i18n.yaml | 4 ++-- scripts/doc-budgets.manifest.json | 2 +- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d692064058..23d7c0263b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,12 +26,12 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/JSON-RPC bridges; boot glue, approval, and interaction plugins - examples/ demo app bundles: the agent-spine bundle + stdio/ACP/JSON-RPC app bins the runnable leaves load + ui/ ACP/stdio/JSON-RPC bridges; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) -examples/ Runnable cordis.yml leaves that load the packages/examples bundles (see examples/AGENTS.md) +examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators ``` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index ef1d8d606f..207ea114e0 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 40ee22b352c884d7f295c87726c54ab8e166c844 -extension-cookbook.zh.md: 4e5bc68c973649574bcb2404bea00096eb9ca41f +extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796 +extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44 diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index bc3c0403c2..0655e89ba5 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 372058dc04c4a36e82f5a5a6f5ef1af48068e4e3 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cd12a65d185e8cdeafc4d04faad4a3349c6150d4 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: b177af24e988c6a314db522b8de0d1c09e30464f +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0b964e8a748e4adcc32c017957e5294a3f258365 diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml index e70f8059f5..cb5d12c562 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-time-context-plugin.md: 13e0eff4b9d286ee562d7a0a2c0a3a659126ba3b -2026-07-14-time-context-plugin.zh.md: 5ee50a4d49eb9a7e00a09f70b436e15d72612b1f +2026-07-14-time-context-plugin.md: 105bf53550f087fdefb1e6fe0ec493f8628d3e18 +2026-07-14-time-context-plugin.zh.md: 60e9004b1453e75e1bcd84870ad7f18d200a95d8 diff --git a/packages/README.md b/packages/README.md index 1428fe07c6..db364421b1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -27,8 +27,8 @@ Packages are grouped by modular role at `packages///`. The group dir | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, stdio channel, boot glue, user-approval and user-interaction seams, ask-user tool | Product — stable surface | -| [`examples/`](examples/README.md) | Ready-to-run demo bundles: the agent-spine bundle + stdio/ACP/JSON-RPC app bins that thin `cordis.yml` leaves (and the Python runtime) load | Support — example infra, lower compatibility | +| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | +| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 3bc9863ccf..17e3da78f3 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp-demo -The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. +The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. @@ -16,7 +16,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | | ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer | -| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | +| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.) diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index ef717f1e16..39cb4cd917 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-jsonrpc-demo -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../ui/jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry. ## Config discovery -The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`. +The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../../ui/app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`. A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not designate a server plugin. diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index a0af1c5a69..e7145c24fc 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-app-boot` -Shared boot glue for the app bins ([`dsh-stdio-demo`](../stdio-agent/README.md), [`dsh-acp-demo`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. +Shared boot glue for the app bins ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. | Export | Role | |---|---| diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 46c82aa05b..bf686ea800 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-jsonrpc -Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../jsonrpc-agent/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design. +Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../../examples/jsonrpc-demo/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design. ## Wiring diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 58b7c68a78..1470a34443 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: a04a0f99c95337b4d9e073e97075654190929520 -README.zh.md: 04e58c5a399565994df721d018ee0fc8a1d86578 +README.md: 30bca971f1bfc6f694302c8f7eb8ce80843ed9b2 +README.zh.md: d2eea59d148b6de1bdf36b2f2e9c96fc1c933be7 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 7b6b50e50d..b4e7981f10 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: a28ae59971e1997f8129c04518307eabece0b6de -README.zh.md: 10dbb6b14f488072382eb01da7621c9bbb0dbcec +README.md: 5525e8a7b88df3f686bb1fa3a08c3556acc2e655 +README.zh.md: 30723d6d78b84e16261898a88ed22c3c58fc83bf diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 80957af255..72d2d74533 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 800, "examples/AGENTS.md": 200, "packages/AGENTS.md": 290, - "packages/README.md": 710 + "packages/README.md": 760 } From 6c2b86b5f2f644d6a7fca47829f85a90db53a621 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:39:32 +0800 Subject: [PATCH 33/33] fix: point node-mode launcher at the renamed dsh-jsonrpc-demo package _node_launch_args builds the launcher path from segments, so the package rename to dsh-jsonrpc-demo did not reach it via the path sweep. A freshly deployed node closure ships @deepseek-ai/dsh-jsonrpc-demo, so node mode raised FileNotFoundError. The docstring and exe ENTRY_BIN already use the new name. --- python/sdk-runtime/src/deepseek_harness_runtime/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 4a014d5a5b..b38df5a211 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -123,7 +123,7 @@ def _current_platform_tag() -> str: def _node_launch_args() -> tuple[str, str]: node_root = bundled_package_dir() / "runtime" / "node" bin_js = ( - node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-agent" / "lib" / "bin.js" + node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-demo" / "lib" / "bin.js" ) if not bin_js.is_file(): raise FileNotFoundError(