From d4614f92d658c30b36835549776f8acc409eb7a3 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 11:59:52 +0800 Subject: [PATCH 01/97] feat(fs): add a model-facing directory listing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ctx.fs.listDir` has shipped since the filesystem seam gained it, with skill discovery as its only consumer; the model-facing tool was deferred to a separate decision. Nothing else could answer "what is in this directory": `rg --files` backs glob and grep and never emits a directory entry, so an empty directory is invisible, no output says which names are directories, and no output gives an entry count. `list` takes an optional `path`, defaulting to the session workspace so the common question needs no argument, and returns the direct children of one directory with their type. Two presentation rules carry it: directories sort first, then files, then non-regular children, each alphabetically — so truncation loses leaves rather than the tree — and the footer always states the complete listing's size and composition, so a capped view can never read as a whole directory. It emits no `fs/observed`: seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate. --- docs/config-catalog.md | 4 +- docs/tool-catalog.md | 22 ++- examples/acp-agent/tests/acp.snapshot.ts | 4 + .../system-prompt.expected.md | 14 ++ .../tool-schemas.expected.json | 13 ++ .../both-mode-turn/system-prompt.expected.md | 14 ++ .../both-mode-turn/tool-schemas.expected.json | 13 ++ .../code-mode-turn/system-prompt.expected.md | 14 ++ .../system-prompt.expected.md | 14 ++ .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 13 ++ .../tests/snapshots/fs-list/input.json | 7 + .../tests/snapshots/fs-list/session.jsonl | 32 +++++ .../snapshots/fs-list/stdout.expected.jsonl | 4 + .../snapshots/fs-list/workspace/README.txt | 1 + .../fs-list/workspace/docs/guide.txt | 1 + .../snapshots/fs-list/workspace/package.json | 1 + .../snapshots/fs-list/workspace/src/index.txt | 1 + .../lsp-definition/system-prompt.expected.md | 2 + .../lsp-definition/tool-schemas.expected.json | 13 ++ .../pty-tools/system-prompt.expected.md | 2 + .../pty-tools/tool-schemas.expected.json | 13 ++ .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 13 ++ .../skill-load/system-prompt.expected.md | 2 + .../skill-load/tool-schemas.expected.json | 13 ++ .../text-turn/system-prompt.expected.md | 2 + .../text-turn/tool-schemas.expected.json | 13 ++ .../web-fetch/system-prompt.expected.md | 2 + .../web-fetch/tool-schemas.expected.json | 13 ++ .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 13 ++ .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../terminal.expected.txt | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/fs/tool-fs/README.i18n.yaml | 6 +- packages/fs/tool-fs/README.md | 43 ++++-- packages/fs/tool-fs/README.zh.md | 43 ++++-- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-fs/src/index.ts | 18 ++- packages/fs/tool-fs/src/list-render.ts | 82 +++++++++++ packages/fs/tool-fs/src/list.ts | 120 ++++++++++++++++ packages/fs/tool-fs/tests/list-render.spec.ts | 69 +++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 131 +++++++++++++++++- scripts/gen-tool-catalog.ts | 2 +- 48 files changed, 761 insertions(+), 46 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/fs-list/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-list/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/package.json create mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt create mode 100644 packages/fs/tool-fs/src/list-render.ts create mode 100644 packages/fs/tool-fs/src/list.ts create mode 100644 packages/fs/tool-fs/tests/list-render.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 47a8acfd30..0b0045b668 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1449,6 +1449,8 @@ Requires: `tools` · `fs` · `systemPrompt` ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Maximum entries one `list` call renders inline; the footer still reports the complete count. */ + listMaxEntries?: number /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -1460,7 +1462,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 311791e594..6edd5be644 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | @@ -316,6 +316,24 @@ Edit an existing UTF-8 text file by replacing literal text. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `list` + +List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. + +```json +{ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `read` Read a UTF-8 text file and return line-numbered content. @@ -371,7 +389,7 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4fb6f9493b..6b04b0038e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -121,6 +121,10 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: true, }, + // A workspace whose subdirectories are what the answer depends on: `glob` + // could not produce them at all, so this scenario pins the listing envelope + // end to end (see the directory-listing Agent Note). + { name: 'fs-list', hasModelTurn: true, recorded: true }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, { name: 'fs-edit', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3a37f3da6a..c78b763fec 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -97,6 +99,11 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + list: { + /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ + path?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -298,6 +305,13 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; + list: { + path: string; + entries: ({ + name: string; + type: "file" | "directory" | "other"; + })[]; + }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 1abccfd566..f9aea468b5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -172,6 +172,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 0cee2a6517..ede2684ae4 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -80,6 +82,11 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + list: { + /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ + path?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -269,6 +276,13 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; + list: { + path: string; + entries: ({ + name: string; + type: "file" | "directory" | "other"; + })[]; + }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index b61d7bf623..1333345b48 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 0cee2a6517..ede2684ae4 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -80,6 +82,11 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + list: { + /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ + path?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -269,6 +276,13 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; + list: { + path: string; + entries: ({ + name: string; + type: "file" | "directory" | "other"; + })[]; + }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 0cee2a6517..ede2684ae4 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -80,6 +82,11 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + list: { + /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ + path?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -269,6 +276,13 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; + list: { + path: string; + entries: ({ + name: string; + type: "file" | "directory" | "other"; + })[]; + }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md index e3437ad61a..edca40eed4 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index 01ac777a42..ffb310c278 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/fs-list/input.json b/examples/acp-agent/tests/snapshots/fs-list/input.json new file mode 100644 index 0000000000..67a0bc3f42 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Call the list tool exactly once, with no arguments at all (NOT bash, NOT glob, and do not pass a path). Then reply with exactly the names of the subdirectories it reported, alphabetically, separated by a single space, and nothing else." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-list/session.jsonl b/examples/acp-agent/tests/snapshots/fs-list/session.jsonl new file mode 100644 index 0000000000..0a05e2b1fe --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/session.jsonl @@ -0,0 +1,32 @@ +{"type":"session","version":0,"id":"ea640046-d959-4644-9a03-ff3d08133d4b","createdAt":1785159113359,"cwd":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785159113361,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785159113362,"data":{"content":[{"type":"text","text":"Call the list tool exactly once, with no arguments at all (NOT bash, NOT glob, and do not pass a path). Then reply with exactly the names of the subdirectories it reported, alphabetically, separated by a single space, and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785159113363,"data":{"title":"Call the list tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785159113368,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785159113369,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785159115248,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785159115249,"data":{"turn":1,"step":1,"index":0,"dt":[146,45,3,0,0,1,43,1,0,0,1,1,31,1,46,0,0,0,0,0,41,1,0,0,45,2,2,0,0,1,44,3,3,0],"texts":["The"," user"," wants"," me"," to"," call"," the"," list"," tool"," with"," no"," arguments"," and"," then"," reply"," with"," the"," names"," of"," the"," sub","direct","ories"," it"," reports",","," alphabet","ically",","," separated"," by"," a"," single"," space","."]}} +{"type":"assistant/chunk","seq":41,"time":1785159115836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":1785159115837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":43,"time":1785159115837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":44,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."}}}} +{"type":"assistant/chunk","seq":45,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":46,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":47,"time":1785159115922,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":48,"time":1785159115926,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."},{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"} +{"type":"tool/call","seq":49,"time":1785159115927,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}} +{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\ndirectory\n\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":54,"time0":1785159116890,"data":{"turn":1,"step":2,"index":0,"dt":[145,49,1,0,60,0,0,1,0,39,1,0,0,0,0,23,1,46,3,0,0],"texts":["The"," sub","direct","ories"," reported"," are"," `","docs","/","`"," and"," `","src","/","`."," Alphabet","ically",":"," docs",","," src","."]}} +{"type":"assistant/chunk","seq":76,"time":1785159117259,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1785159117259,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"docs"}}} +{"type":"assistant/chunk","seq":78,"time":1785159117297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" src"}}} +{"type":"assistant/chunk","seq":79,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subdirectories reported are `docs/` and `src/`. Alphabetically: docs, src."}}}} +{"type":"assistant/chunk","seq":80,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"docs src"}}}} +{"type":"assistant/chunk","seq":81,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":161,"outputTokens":25,"cacheReadTokens":5504,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":82,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":83,"time":1785159117301,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subdirectories reported are `docs/` and `src/`. Alphabetically: docs, src."},{"type":"text","text":"docs src"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":161,"outputTokens":25,"cacheReadTokens":5504,"reasoningTokens":22}},"sourceEventSeqs":[53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} +{"type":"step/end","seq":84,"time":1785159117308,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":85,"time":1785159117309,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl new file mode 100644 index 0000000000..22d36a3c79 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"docs src"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt new file mode 100644 index 0000000000..dab306f45e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt @@ -0,0 +1 @@ +# Project diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt new file mode 100644 index 0000000000..8c0d02fadc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt @@ -0,0 +1 @@ +# Guide diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json b/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json new file mode 100644 index 0000000000..e36fa754cf --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json @@ -0,0 +1 @@ +{ "name": "demo" } diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt new file mode 100644 index 0000000000..eab39ce89c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt @@ -0,0 +1 @@ +export const answer = 42 diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index 7bde8fe289..5a5df6233c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 9b5925605c..3a0e7c1408 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index df065a83cb..70dd754517 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 8e093db8bd..c529ad9077 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md index 68bdd841c7..658fa4e07c 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index beb93c6b53..4eff11591a 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 17e6773a03..6d926a90a8 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 01ac777a42..ffb310c278 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 17e6773a03..6d926a90a8 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 01ac777a42..ffb310c278 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md index 45705db0a5..4c20e18272 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 70940f8907..59e3baf6b1 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 6cd8d5725f..6c5155f262 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 01ac777a42..ffb310c278 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -115,6 +115,19 @@ "properties": {} } }, + { + "name": "list", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 6cae860e36..c0f80e3cdf 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index c00a4119c7..2bf8be7bfb 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 9d2b188a45..8f531cf789 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 99eaf6e4ee..2cde7d9e42 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index aad4b2cd50..7a2024ac06 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -59,7 +59,7 @@ buffer style 1-1 inverse 27| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" +28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 4% c" style 0-93 dim style 96-99 dim 29-35| diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3754595f56..974d88c18c 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 13f1ecd649..e6f4ccf9c0 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 -README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041 +# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md +README.md: 08a4f74b928a38b92fdce5f6a03dd7c6c8f0af7c +README.zh.md: cdb45a12eb644e4882f7b92ac77b5bc7fc33f906 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 4ff9b04352..08a4f74b92 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,23 +2,24 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. +The **model-facing filesystem tools** — `list`, `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, **listing order**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(ToolFs) // this package — registers list/read/write/edit ``` `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. ## Config -All keys are optional; the defaults are the shipped read caps. +All keys are optional; the defaults are the shipped listing and read caps. | Key | Default | Meaning | |---|---|---| +| `listMaxEntries` | `200` | Entries one `list` call renders inline; the footer still reports the complete directory's size and composition. | | `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | | `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | | `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | @@ -28,18 +29,20 @@ All keys are optional; the defaults are the shipped read caps. | Tool | Arguments | Behavior | |---|---|---| +| `list` | `path?` | Direct children of one directory with their type, defaulting to the session workspace. Ordered directories first, then files, then non-regular children, each alphabetical, and capped at the configured `listMaxEntries` (200). | | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`, `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: +- **list** — one `ctx.fs.listDir`; the seam already answers absence with `FS_NOT_FOUND` and a non-directory target with `FS_NOT_DIRECTORY`, so no probe precedes it. No `fs/observed`: a listing reads no file content and must not satisfy the read-before-write gate. (0 stat.) - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -50,9 +53,9 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +`list` and `read` opt into concurrent scheduling — `list` mutates nothing at all, and `read`'s only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). -The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Pure presentation lives beside the executors and is independently unit-tested: read windowing and output formatting in `src/read-render.ts`, listing order and envelope in `src/list-render.ts` (both Cordis-free); `src/list.ts`/`read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience @@ -60,7 +63,13 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con #### What the model sees -Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. +Every request in this plugin's registration scope receives the independently registered list, read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. + +##### List guidance + +```markdown +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +``` ##### Read guidance @@ -92,7 +101,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`list`, `read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -102,6 +111,20 @@ Fixed schema cost on every request in that tool view. Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. +### List result + +#### What the model sees + +A successful listing is exactly ``, newline, `directory`, newline, ``, one line per entry, a blank line, one footer, and ``. A directory entry carries a trailing `/` and a non-regular child a trailing `@`; a regular file carries neither. The footer is exactly `(Empty directory)`, `( entries: directories, files)` — with `, other` appended only when such a child exists, and singulars where the count is one — or, when the view is capped, `(Showing of entries: directories, files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`. The complete count and composition are stated whether or not the view was capped, so a partial listing can never read as a whole directory. + +#### Token effect + +Listing output is capped by `listMaxEntries`; the retained call and result are resent until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Read result #### What the model sees @@ -134,7 +157,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `path must be a non-empty string when given`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. #### Token effect @@ -146,6 +169,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. +- **`list` reads one directory level and has no spill path** — recursion, pagination, and per-directory child counts are absent, and a listing past `listMaxEntries` is summarized by its footer rather than saved anywhere retrievable; the model lists a subdirectory instead. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index f94a903c9c..cdb45a12eb 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,23 +2,24 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不** 注入政策服务。新鲜度/观察政策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。 +**面向模型的文件系统工具**(`list`、`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑**、**列出顺序** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不** 注入政策服务。新鲜度/观察政策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(ToolFs) // this package — registers list/read/write/edit ``` `@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。 ## 配置 -所有键均为可选;默认值是随产品交付的读取上限。 +所有键均为可选;默认值是随产品交付的列出与读取上限。 | 键 | 默认值 | 含义 | |---|---|---| +| `listMaxEntries` | `200` | 一次 `list` 调用内联渲染的条目数;footer 仍会报告整个目录的规模与构成。 | | `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 | | `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 | | `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 | @@ -28,18 +29,20 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| +| `list` | `path?` | 单个目录的直接子项及其类型,默认取会话工作区。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列,并受配置的 `listMaxEntries`(200)限制。 | | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有政策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有政策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 +规范成功值分别为:`list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`,`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 ## 工具就是执行器;政策是事件门禁 工具**不** 注入政策服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: +- **list**:一次 `ctx.fs.listDir`;seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,因此前面不需要任何探测。不发出 `fs/observed`:列出不读取任何文件内容,也不得满足编辑前读取门禁。(0 次 stat。) - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -50,9 +53,9 @@ await ctx.plugin(ToolFs) // this package — re `fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 -`read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +`list` 与 `read` 都允许并发调度:`list` 完全不做任何变更,而 `read` 的唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 -包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 +包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。纯展示逻辑与执行器并列存放并单独进行单元测试:读取窗口与输出格式化位于 `src/read-render.ts`,列出顺序与包络位于 `src/list-render.ts`(两者均不依赖 Cordis);`src/list.ts`/`read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 ## 模型体验 @@ -60,7 +63,13 @@ await ctx.plugin(ToolFs) // this package — re #### 模型看到的内容 -该插件注册作用域内的每个请求都会收到下方独立注册的 read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。 +该插件注册作用域内的每个请求都会收到下方独立注册的 list、read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。 + +##### List 指导 + +```markdown +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +``` ##### Read 指导 @@ -92,7 +101,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`list`、`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -102,6 +111,20 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 只要可见工具定义和顺序不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 +### 列出结果 + +#### 模型看到的内容 + +成功列出结果精确为 ``、换行、`directory`、换行、``、每个条目一行、一个空行、一条 footer 和 ``。目录条目带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 精确为 `(Empty directory)`、`( entries: directories, files)`(仅当存在此类子项时才追加 `, other`,计数为一时使用单数形式),或在视图被截断时为 `(Showing of entries: directories, files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`。无论视图是否被截断,都会说明完整计数与构成,因此部分列出结果绝不会被读成整个目录。 + +#### Token 影响 + +列出输出受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + ### 读取结果 #### 模型看到的内容 @@ -134,7 +157,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和政策模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`path must be a non-empty string when given`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和政策模板在各自包的 README 中逐字列出。 #### Token 影响 @@ -146,6 +169,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## 已知限制与延期工作 -- **未交付面向模型的目录列出工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 +- **`list` 只读取一层目录,且没有溢出落盘路径**:不提供递归、分页和逐目录子项计数,超出 `listMaxEntries` 的部分只由 footer 概括,不会保存到任何可取回的位置;模型改为列出对应子目录。 - **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 737f7ac26b..936d125013 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-fs", - "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", + "description": "Model-facing filesystem tools (list, read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index a4c96d606b..c8376ed123 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,16 +1,19 @@ /** - * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, - * read windows, formatting, and observation events, never a concrete provider. An optional - * event policy supplies mutation guards; without one the tools use unconditional provider calls. + * Model-facing list, read, write, and edit tools over `ctx.fs`. This package owns schemas, + * validation, read windows, listing order, formatting, and observation events, never a concrete + * provider. An optional event policy supplies mutation guards; without one the tools use + * unconditional provider calls. * @module @deepseek-ai/dsh-tool-fs */ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-user-approval' +import { applyListTool } from './list.ts' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' +import { LIST_MAX_ENTRIES } from './list-render.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxSurface } from './sandbox.ts' @@ -22,6 +25,8 @@ export const inject = ['tools', 'fs', 'systemPrompt'] /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Maximum entries one `list` call renders inline; the footer still reports the complete count. */ + listMaxEntries?: number /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -33,6 +38,7 @@ export interface Config { } export const Config: z = z.object({ + listMaxEntries: z.number().default(LIST_MAX_ENTRIES), readLimit: z.number().default(READ_LIMIT), readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH), readMaxBytes: z.number().default(READ_MAX_BYTES), @@ -42,21 +48,23 @@ export const Config: z = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required -/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */ +/** Every read or listing cap counts lines/chars/bytes/entries — a positive integer, or windowing arithmetic misbehaves silently. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { throw new Error(`tool-fs: ${name} must be a positive integer`) } } -/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +/** Register the full `list`/`read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig + assertPositiveInteger('listMaxEntries', resolved.listMaxEntries) assertPositiveInteger('readLimit', resolved.readLimit) assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength) assertPositiveInteger('readMaxBytes', resolved.readMaxBytes) assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize) + applyListTool(ctx, { maxEntries: resolved.listMaxEntries }) applyReadTool(ctx, { limit: resolved.readLimit, maxLineLength: resolved.readMaxLineLength, diff --git a/packages/fs/tool-fs/src/list-render.ts b/packages/fs/tool-fs/src/list-render.ts new file mode 100644 index 0000000000..d7a65cbb27 --- /dev/null +++ b/packages/fs/tool-fs/src/list-render.ts @@ -0,0 +1,82 @@ +/** + * Pure listing presentation: order one directory's direct children so a capped + * view still shows the navigable structure, and render the model-facing + * envelope. Cordis-free and independently unit-tested, mirroring + * {@link module:@deepseek-ai/dsh-tool-fs/read-render}. + * @module @deepseek-ai/dsh-tool-fs/list-render + */ + +/** Default and maximum number of entries one `list` call renders inline (the `listMaxEntries` config). */ +export const LIST_MAX_ENTRIES = 200 + +/** One direct child in a rendered listing — the canonical entry shape the tool returns. */ +export interface ListedEntry { + /** Basename of the child inside the listed directory. */ + name: string + /** Whether the child is a regular file, a directory, or something else (symlink, socket, device). */ + type: 'file' | 'directory' | 'other' +} + +/** + * Order direct children so truncation cannot hide the directory tree: + * directories first, then files, then everything else, each group by name. + * + * The provider seam returns children in stable name order, which puts a + * subdirectory wherever the alphabet puts it; capping such a list can drop every + * subdirectory and leave the model believing a directory holds only files. This + * is the listing counterpart of the `glob` coverage footer. + * + * @param entries - the seam's direct children, in any order. + * @returns a new array in directory-first display order; the input is not mutated. + */ +export function orderEntries(entries: readonly T[]): T[] { + const rank = { directory: 0, file: 1, other: 2 } + return [...entries].sort((a, b) => rank[a.type] - rank[b.type] || a.name.localeCompare(b.name)) +} + +/** `1 directory` / `4 directories` — a count the model reads as prose, not as `1 directorie(s)`. */ +function count(n: number, singular: string, plural: string): string { + return `${n} ${n === 1 ? singular : plural}` +} + +/** The ` directories, files[, other]` breakdown; the `other` clause appears only when non-empty. */ +function breakdown(entries: readonly ListedEntry[]): string { + const directories = entries.filter(entry => entry.type === 'directory').length + const other = entries.filter(entry => entry.type === 'other').length + const files = entries.length - directories - other + const parts = [count(directories, 'directory', 'directories'), count(files, 'file', 'files')] + if (other > 0) parts.push(`${other} other`) + return parts.join(', ') +} + +/** + * Render the model-facing `list` result: the displayed entries, then a footer + * that always states the COMPLETE listing's size and composition, so a capped + * view can never read as the whole directory. + * + * Directories carry a trailing `/` and non-regular children a trailing `@`, so + * the model can tell what it may descend into without a second call. + * + * @param displayPath - the resolved directory as the backend displays it. + * @param entries - the complete listing, already in {@link orderEntries} order. + * @param maxEntries - how many entries to show inline; the rest are summarized by the footer. + * @returns the model-facing text. + */ +export function formatListOutput(displayPath: string, entries: readonly ListedEntry[], maxEntries: number): string { + const shown = entries.slice(0, maxEntries) + const suffix = { directory: '/', file: '', other: '@' } + const footer = shown.length < entries.length + ? `(Showing ${shown.length} of ${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)}. ` + + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)' + : entries.length === 0 + ? '(Empty directory)' + : `(${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)})` + const body = shown.length > 0 + ? `${shown.map(entry => `${entry.name}${suffix[entry.type]}`).join('\n')}\n\n${footer}` + : footer + return `${displayPath} +directory + +${body} +` +} diff --git a/packages/fs/tool-fs/src/list.ts b/packages/fs/tool-fs/src/list.ts new file mode 100644 index 0000000000..de6c6d1ddd --- /dev/null +++ b/packages/fs/tool-fs/src/list.ts @@ -0,0 +1,120 @@ +/** + * Model-facing directory listing. It enumerates ONE directory level through the + * provider seam's `listDir`, orders children so a capped view keeps the + * navigable structure, and renders the entries with their type. + * + * This is the orientation tool: `glob` and `grep` answer "where is the thing I + * can already name", while `list` answers "what is here at all". `rg --files` + * never emits directories, so no pattern makes `glob` describe a directory's + * shape — the gap this tool closes. + * @module @deepseek-ai/dsh-tool-fs/list + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { formatListOutput, orderEntries } from './list-render.ts' +import { sessionResolveOptions } from './session-cwd.ts' + +/** Resolved list-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface ListToolCaps { + /** Maximum entries rendered inline; the footer still reports the complete listing's size. */ + maxEntries: number +} + +/** Validated `list` arguments after defaulting. */ +export interface ListInput { + /** Directory to list; `.` means the calling agent's session workspace. */ + path: string +} + +/** + * Validate value constraints the schema DSL can't express, and default an + * omitted `path` to `.` — the session workspace, so "what is in this project" + * needs no argument at all. + * + * @param args - the schema-validated `list` arguments. + * @returns the accepted input with `path` defaulted. + */ +export function parseListArgs(args: { path?: string }): ListInput { + if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + return { path: args.path ?? '.' } +} + +/** + * Pending-call presentation: a generic card titled by the directory, with a + * follow-along location so a capable editor can reveal it. + * + * @param args - the raw tool arguments; only `path` is read. + * @returns the generic card view shown while the call runs. + */ +export function presentListCall(args: { path?: string }): GenericCallView { + const path = args.path ?? '.' + return { card: 'generic', title: `List ${path}`, kind: 'read', locations: [{ path }] } +} + +/** + * Register the `list` tool and its system-prompt guidance. + * + * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + * @param caps - the deployment's resolved list caps (plugin config after defaulting). + */ +export function applyListTool(ctx: Context, caps: ListToolCaps): void { + ctx.systemPrompt.section({ + name: 'tool:list', + order: 99, + text: 'Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, ' + + 'and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. ' + + 'Reach for glob or grep once you know the path pattern or the text you are looking for.', + }) + + ctx.tools.register(defineTool({ + name: 'list', + description: 'List the direct children of one directory, with their type. ' + + `Entries are directories first, then files, each alphabetical; the first ${caps.maxEntries} are returned inline and the footer reports the complete count. ` + + 'Unlike glob, this shows subdirectories, so it is how to see what a directory contains.', + parameters: { + path: { type: 'string', description: 'Directory to list. Defaults to the session workspace; a relative path resolves against it.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + entries: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', required: true }, + type: { type: 'string', required: true, enum: ['file', 'directory', 'other'] }, + }, + }, + }, + }, + }, + render: (_args, value) => [{ type: 'text', text: formatListOutput(value.path, value.entries, caps.maxEntries) }], + }, + // Listing reads directory metadata only: no content, no version recorded, + // nothing a concurrent call could observe out of order. + isConcurrencySafe: () => true, + async execute(args, exec) { + const input = parseListArgs(args) + const target = await ctx.fs.resolve(input.path, sessionResolveOptions(exec, input.path)) + // No stat first: the seam already answers absence with FS_NOT_FOUND and a + // non-directory target with FS_NOT_DIRECTORY, so a probe would only add a + // round-trip and a second source of truth. (0 stat.) + const entries = await ctx.fs.listDir(target, exec.signal) + return { + path: target.displayPath, + entries: orderEntries(entries).map(({ name, type }) => ({ name, type })), + } + }, + presentCall: presentListCall, + })) +} diff --git a/packages/fs/tool-fs/tests/list-render.spec.ts b/packages/fs/tool-fs/tests/list-render.spec.ts new file mode 100644 index 0000000000..3d0a3ac5ce --- /dev/null +++ b/packages/fs/tool-fs/tests/list-render.spec.ts @@ -0,0 +1,69 @@ +/** + * Pure listing-presentation tests: display ordering and the model-facing + * envelope, exercised without a context or provider. + */ + +import { describe, expect, it } from 'vitest' +import { formatListOutput, orderEntries } from '../src/list-render.ts' +import type { ListedEntry } from '../src/list-render.ts' + +const entry = (name: string, type: ListedEntry['type'] = 'file'): ListedEntry => ({ name, type }) + +describe('orderEntries', () => { + it('groups directories, then files, then other, each by name', () => { + const ordered = orderEntries([ + entry('zeta.txt'), + entry('socket', 'other'), + entry('beta'), + entry('src', 'directory'), + entry('assets', 'directory'), + ]) + expect(ordered.map(e => e.name)).toEqual(['assets', 'src', 'beta', 'zeta.txt', 'socket']) + }) + + it('leaves the input array untouched and preserves extra entry fields', () => { + const input = [{ name: 'b', type: 'file' as const, size: 2 }, { name: 'a', type: 'file' as const, size: 1 }] + const ordered = orderEntries(input) + expect(input.map(e => e.name)).toEqual(['b', 'a']) + expect(ordered).toEqual([{ name: 'a', type: 'file', size: 1 }, { name: 'b', type: 'file', size: 2 }]) + }) +}) + +describe('formatListOutput', () => { + it('marks directories and non-regular children, and counts the whole listing', () => { + expect(formatListOutput('/w', [entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')], 10)).toBe(`/w +directory + +src/ +a.txt +sock@ + +(3 entries: 1 directory, 1 file, 1 other) +`) + }) + + it('omits the "other" clause when every child is a file or a directory', () => { + expect(formatListOutput('/w', [entry('a.txt'), entry('b.txt')], 10)).toContain('(2 entries: 0 directories, 2 files)') + }) + + it('says a one-entry listing in the singular', () => { + expect(formatListOutput('/w', [entry('only', 'directory')], 10)).toContain('(1 entry: 1 directory, 0 files)') + }) + + it('states the complete size and composition when the view is capped', () => { + const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))] + const rendered = formatListOutput('/w', entries, 2) + expect(rendered).toContain('src/\nf0.txt\n') + expect(rendered).not.toContain('f2.txt') + expect(rendered).toContain('(Showing 2 of 6 entries: 1 directory, 5 files. ' + + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)') + }) + + it('renders an empty directory as a footer alone', () => { + expect(formatListOutput('/w', [], 10)).toBe(`/w +directory + +(Empty directory) +`) + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8f93b524a9..66f88a6348 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -38,6 +38,7 @@ const testToolSignal = new AbortController().signal class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError + dirs = new Map() writeIntents: (FsWriteIntent | undefined)[] = [] editIntents: ({ version: FsVersion } | undefined)[] = [] @@ -66,8 +67,9 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async listDir(_target: FsTarget): Promise { - return [] + override async listDir(target: FsTarget): Promise { + this.throwIfArmed() + return this.dirs.get(target.targetKey) ?? [] } override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() @@ -139,13 +141,15 @@ describe('session cwd resolution', () => { }) describe('registration', () => { - it('registers read, write, and edit', async () => { + it('registers list, read, write, and edit', async () => { const { ctx } = await setup() - expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) + expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'list', 'read', 'write']) }) - it('declares read parallel-safe while write/edit remain exclusive', async () => { + it('declares list and read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('list-safe'), name: 'list', arguments: {} })) + .toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) @@ -157,6 +161,7 @@ describe('registration', () => { it('registers prompt sections for each tool', async () => { const { ctx } = await setup() const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Use the list tool') expect(prompt).toContain('Use the read tool') expect(prompt).toContain('Use the write tool') expect(prompt).toContain('Use the edit tool') @@ -179,9 +184,10 @@ describe('registration', () => { const fiber = await ctx.plugin(ToolFs) // Each tool contributes BOTH a schema and a prompt section; disposal must // withdraw both, not just the schemas. - expect(ctx.tools.schemas()).toHaveLength(3) + expect(ctx.tools.schemas()).toHaveLength(4) const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() - expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write']) + expect(sectionNames(await ctx.systemPrompt.assemble())) + .toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:list', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) // Only the system-prompt plugin's own built-in sections remain. @@ -189,6 +195,107 @@ describe('registration', () => { }) }) +describe('list tool', () => { + /** Seed one directory's children; `listDir` order is deliberately NOT display order. */ + function seedDir(fs: FakeFs, path: string, children: readonly { name: string; type: 'file' | 'directory' | 'other' }[]): void { + fs.dirs.set(`key:${path}`, children.map(({ name, type }) => ({ + name, + type, + target: { targetKey: FsTargetKey(`key:${path}/${name}`), displayPath: `/abs/${path}/${name}` }, + }))) + } + + it('defaults to the session workspace and shows directories before files', async () => { + const { ctx, fs } = await setup() + seedDir(fs, '.', [ + { name: 'notes.md', type: 'file' }, + { name: 'zeroomega-3.3.23', type: 'directory' }, + { name: 'archive', type: 'directory' }, + { name: 'link-to-nowhere', type: 'other' }, + ]) + const result = await call(ctx, 'list', {}) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected list success') + // The canonical value carries display order, so a Code Mode caller and the + // model see the same ordering contract. + expect(result.value).toEqual({ + path: '/abs/.', + entries: [ + { name: 'archive', type: 'directory' }, + { name: 'zeroomega-3.3.23', type: 'directory' }, + { name: 'notes.md', type: 'file' }, + { name: 'link-to-nowhere', type: 'other' }, + ], + }) + expect(text(result)).toBe(`/abs/. +directory + +archive/ +zeroomega-3.3.23/ +notes.md +link-to-nowhere@ + +(4 entries: 2 directories, 1 file, 1 other) +`) + }) + + it('lists an explicit path and reports an empty directory as such', async () => { + const { ctx, fs } = await setup() + seedDir(fs, 'empty', []) + const result = await call(ctx, 'list', { path: 'empty' }) + expect(text(result)).toContain('(Empty directory)') + expect(text(result)).toContain('/abs/empty') + }) + + it('caps the rendered entries but still reports the complete composition', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(ToolFs, { listMaxEntries: 2 }) + const fs = ctx.fs as FakeFs + seedDir(fs, '.', [ + { name: 'a.txt', type: 'file' }, + { name: 'b.txt', type: 'file' }, + { name: 'c.txt', type: 'file' }, + { name: 'src', type: 'directory' }, + ]) + const result = await call(ctx, 'list', {}) + const rendered = text(result) + // The one directory survives the cap because directories sort first — the + // failure mode this ordering exists to prevent. + expect(rendered).toContain('src/\na.txt\n') + expect(rendered).not.toContain('c.txt') + expect(rendered).toContain('(Showing 2 of 4 entries: 1 directory, 3 files. ' + + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)') + }) + + it('rejects a blank path and surfaces provider failures', async () => { + const { ctx, fs } = await setup() + const blank = await call(ctx, 'list', { path: ' ' }) + expect(blank.isError).toBe(true) + expect(text(blank)).toContain('path must be a non-empty string when given') + + fs.rejectWith = new FsError('cannot list "/abs/a.txt": not a directory', 'FS_NOT_DIRECTORY') + const failed = await call(ctx, 'list', { path: 'a.txt' }) + expect(failed.isError).toBe(true) + expect(failed.error).toMatchObject({ info: { code: 'FS_NOT_DIRECTORY' } }) + }) + + it('records no observation, so a listing never authorizes a mutation', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'hello') + seedDir(fs, '.', [{ name: 'a.txt', type: 'file' }]) + const observed = vi.fn() + ctx.on('fs/observed', observed) + await call(ctx, 'list', {}) + expect(observed).not.toHaveBeenCalled() + // Seeing a name is not reading a file: the policy gate still demands a read. + const edit = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'h', new_string: 'j' }, { session: { header: {} } }) + expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + }) +}) + describe('read tool', () => { it('formats line-numbered content with a footer', async () => { const { ctx, fs } = await setup() @@ -439,6 +546,15 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) + it('list: titles by the directory, falling back to the workspace "." when unset', async () => { + expect(await presentCall('list', { path: 'src' })).toEqual({ + card: 'generic', title: 'List src', kind: 'read', locations: [{ path: 'src' }], + }) + expect(await presentCall('list', {})).toEqual({ + card: 'generic', title: 'List .', kind: 'read', locations: [{ path: '.' }], + }) + }) + it('read: bare title and line-1 location when offset/limit are unset', async () => { expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({ card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], @@ -621,6 +737,7 @@ describe('read caps are plugin config', () => { }) it.each([ + ['listMaxEntries', { listMaxEntries: 0 }], ['readLimit', { readLimit: 0 }], ['readLimit', { readLimit: 2.5 }], ['readMaxLineLength', { readMaxLineLength: -1 }], diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3103b9878c..f25084917e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -230,7 +230,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', From 9b9b45e65efc383c84b92f2607aad4cc891601ec Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 12:05:36 +0800 Subject: [PATCH 02/97] fix(fs-search): sample an over-cap glob result across the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked what a workspace contained, an agent described one subfolder as the whole project. `glob {"pattern": "*"}` matched 10030 paths across 22 top-level entries and the inline page was the first 100 of them, all under a single unpacked archive. Three properties compose into that page: a pattern with no `/` matches basenames at any depth, so `*` means the whole tree rather than its top level; `--sort=modified` orders oldest first, and unpacking an archive restores timestamps that predate everything the user wrote; and the page was the head of that order. Each is defensible alone, and together they make the most ordinary request an agent receives produce a confident wrong answer. A result within `globMaxResults` is unchanged — shown whole, in modification-time order. Beyond it the page is filled round-robin across the complete result's top-level entries, so one subtree cannot own every slot, and the footer states that the page was sampled rather than taken in modification-time order. Measured on a 24-entry, 716-file reproduction, the head of 100 reaches 7 top-level names and the sampled page reaches 21. The spill artifact still holds the complete sorted list. The guidance and schema stop steering away from `ls`, state the any-depth pattern rule, say results are files and never directories, and point at `list` for a directory's contents. --- docs/tool-catalog.md | 8 +- packages/fs/tool-fs-search/README.i18n.yaml | 6 +- packages/fs/tool-fs-search/README.md | 9 +- packages/fs/tool-fs-search/README.zh.md | 9 +- packages/fs/tool-fs-search/src/glob.ts | 138 +++++++++++++++--- packages/fs/tool-fs-search/src/index.ts | 4 +- .../fs/tool-fs-search/tests/tools.spec.ts | 70 +++++++++ scripts/gen-tool-catalog.ts | 2 +- 8 files changed, 208 insertions(+), 38 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 6edd5be644..0884910bde 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,7 +21,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. | -| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | @@ -395,7 +395,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an ` ### `glob` -Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, including hidden and ignored files (VCS metadata directories are excluded). Returns the first 100 paths inline; a capped result reports where the complete list was saved. +Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level directories, says so, and reports where the complete sorted list was saved. To see what a directory contains, use the list tool instead. ```json { @@ -403,7 +403,7 @@ Find files whose paths match a glob pattern. Returns matching paths sorted by mo "properties": { "pattern": { "type": "string", - "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\")." + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." }, "path": { "type": "string", @@ -447,7 +447,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. ## `@deepseek-ai/dsh-tool-pty` diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 07aaa3c9dc..ec8f6f8968 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: 88a80fb51d7161e6940a3460b7f506575592f9cb -README.zh.md: 87be92bb5a8e06dfc275aa6a1fcf97274a761025 +# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md +README.md: 33792a6f4b72baa2626c6e8d37c672fb39681554 +README.zh.md: 5d13a7ff2cb3ddfda8168a34e4a4a897d3413e11 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 88a80fb51d..33792a6f4b 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -24,7 +24,7 @@ All keys are optional; the defaults are the shipped search caps. | Key | Default | Meaning | |---|---|---| -| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. | +| `globMaxResults` | `100` | Max paths one `glob` call shows inline (matches Claude Code's `GlobTool` limit). Within it the result is shown whole in modification-time order; beyond it the inline page is sampled across top-level entries and the complete sorted list goes to the formatted spill artifact. | | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | @@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped search caps. | Tool | Arguments | Behavior | |---|---|---| -| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. | +| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line, modification-time ordered — `rg --files` never emits a directory, so no pattern makes `glob` describe a directory's contents; that is [`dsh-tool-fs`](../tool-fs/)'s `list`. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. | | `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. | Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint. @@ -58,7 +58,7 @@ After the load-time `rg` probe succeeds, every request in this plugin's registra ##### Glob guidance ```markdown -Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files. +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains. ``` ##### Grep guidance @@ -93,7 +93,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration #### What the model sees -`glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. +`glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. An over-cap `glob` result does not show the head of the sorted list: its inline page takes paths round-robin across the complete result's top-level entries, so one recently-written subtree cannot own every slot, and the footer says the page was sampled rather than taken in modification-time order, together with how many top-level entries it reached. When it could not reach them all, the footer also points at `list`. A result that fits inline is untouched, and a flat result — every path its own top-level entry — keeps the plain footer, because there the sample IS the recency-ordered head. The spill artifact always holds the complete list in modification-time order. #### Token effect @@ -122,3 +122,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. - **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. +- **Sampling groups by first path segment only** — an over-cap `glob` page balances across top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 87be92bb5a..5d13a7ff2c 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -24,7 +24,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | 键 | 默认值 | 含义 | |---|---|---| -| `globMaxResults` | `100` | 一次 `glob` 调用内联保留的最大路径数(与 Claude Code 的 `GlobTool` 上限相同);后续路径写入格式化 spill 产物。 | +| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过时结果整体按修改时间展示;超过时内联页面改为跨顶层条目取样,完整的排序列表写入格式化 spill 产物。 | | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | @@ -34,7 +34,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | 工具 | 参数 | 行为 | |---|---|---| -| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个路径,按修改时间排序。 | +| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目,因此任何 pattern 都无法让 `glob` 描述一个目录的内容,那是 [`dsh-tool-fs`](../tool-fs/) 的 `list`。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 | | `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录** 目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: ` 的匹配。 | 常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。 @@ -58,7 +58,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ##### Glob 指导 ```markdown -Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files. +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains. ``` ##### Grep 指导 @@ -93,7 +93,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### 模型看到的内容 -`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。 +`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。超过上限的 `glob` 结果不再展示排序列表的头部:其内联页面按轮转方式跨完整结果的顶层条目取样,因此单个新近写入的子树无法占满所有位置;footer 会说明该页面是取样得到而非按修改时间取用,并给出它触达了多少个顶层条目。未能触达全部时,footer 还会指向 `list`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer,因为此时取样结果就等于按新近度排序的头部。spill 产物始终保存按修改时间排序的完整列表。 #### Token 影响 @@ -122,3 +122,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read - **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。 - **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。 - **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。 +- **取样只按路径首段分组**:超过上限的 `glob` 页面在顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 6d42acee66..d46b1d352d 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -3,17 +3,21 @@ * pattern, sorted by modification time. Execution goes through the bash seam * (`ctx.bash`) with a fixed `rg --files` command — this module owns the * model-facing schema, argument validation, shell-safe command construction, - * result parsing, retention, and formatting; process concerns (defaulting, + * result parsing, inline sampling, and formatting; process concerns (defaulting, * scrubbing, kill, backend substitution) stay behind `ctx.bash`. * + * A complete result keeps ripgrep's modification-time order. A result too large + * to show inline does NOT: its inline page is sampled across the complete + * result's top-level entries ({@link sampleAcrossTopLevel}), because the sorted + * head of a broad match is routinely one subtree's worth of files and reads as + * if the workspace held nothing else. + * * @module @deepseek-ai/dsh-tool-fs-search/glob */ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import { ItemRetainer } from '@deepseek-ai/dsh-retention' -import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -99,30 +103,116 @@ export function buildGlobCommand(input: GlobInput): string { } /** - * Format the model-facing `glob` result: the retained paths, then — when the - * result was capped — a footer carrying either the formatted-spill recovery - * locator or the could-not-save explanation. The omitted count is a budget fact: - * the search itself completed. + * The inline page of a capped `glob` result, plus how much of the complete + * result's top level it reaches. + */ +export interface GlobSample { + /** Paths to show inline: grouped by top-level entry, recency-ordered within each group. */ + items: string[] + /** Distinct top-level entries the shown paths reach. */ + shown: number + /** Distinct top-level entries across the complete result. */ + total: number +} + +/** + * The leading path segment of one display path — the top-level entry, relative + * to the search root, that the path sits under. A path with no separator is its + * own top-level entry. Leading separators are stripped first so an absolute path + * (one outside the workdir, which {@link toWorkdirRelative} leaves untouched) + * groups by its first real name instead of collapsing every such path into one + * empty group. + */ +function topLevelSegment(path: string): string { + const trimmed = path.replace(/^[\\/]+/, '') + const cut = trimmed.search(/[\\/]/) + return cut === -1 ? trimmed : trimmed.slice(0, cut) +} + +/** + * Choose the inline page of an over-cap result by round-robin across the + * complete result's top-level entries, instead of taking its head. * - * @param retained - the retention outcome over every discovered path. + * `--sort=modified` (oldest first) is the right order for a complete result and + * the wrong basis for a sample of one: a broad pattern in a workspace holding one + * unpacked archive — whose restored timestamps predate everything the user + * wrote — gives a head that is entirely that subtree, and the model reads the + * page as the workspace. Round-robin gives every top-level entry a slot before + * any entry gets a second, so the page spans the tree; an entry that runs out of + * paths drops out and its remaining slots go to the rest. + * + * Modification-time order survives where it still means something: groups are + * visited in the order ripgrep first emits them, and each group's own paths keep + * their relative order. With one path per group — a flat result — this + * reproduces the sorted head exactly, so nothing changes for a result that has + * no subtree to hide. + * + * @param paths - the complete result, in ripgrep's modification-time order. + * @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`. + * @returns the page grouped by top-level entry, with the shown/total top-level spread. + */ +export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number): GlobSample { + const groups = new Map() + for (const path of paths) { + const group = groups.get(topLevelSegment(path)) + if (group === undefined) groups.set(topLevelSegment(path), [path]) + else group.push(path) + } + // Bounding the rounds by the largest group makes termination structural: the + // page can only fill or the groups run out, never spin on empty rounds. + const rounds = Math.max(0, ...[...groups.values()].map(group => group.length)) + const taken = new Map() + let count = 0 + for (let round = 0; round < rounds && count < maxItems; round += 1) { + for (const [key, group] of groups) { + if (count >= maxItems) break + const path = group[round] + if (path === undefined) continue + count += 1 + const bucket = taken.get(key) + if (bucket === undefined) taken.set(key, [path]) + else bucket.push(path) + } + } + return { items: [...taken.values()].flat(), shown: taken.size, total: groups.size } +} + +/** + * Format a CAPPED `glob` result: the inline page, then a footer stating that + * the page is a cross-directory sample rather than the most recent paths, how + * much of the top level it reaches, and either the formatted-spill recovery + * locator or the could-not-save explanation. The omitted count is a budget + * fact: the search itself completed. A result that fits inline never reaches + * here — it is emitted verbatim, in ripgrep's order. + * + * A result whose every path is its own top-level entry keeps the plain footer: + * the sample is the recency-ordered head, and naming a spread would only + * restate the path counts already there. + * + * @param sample - the inline page and its top-level spread. + * @param seen - how many paths the complete result holds; always more than the page. * @param spillRef - the saved complete-result reference, or `undefined` when unsaved. * @returns the model-facing text. */ -export function formatGlobOutput(retained: RetainedItems, spillRef: SpillRef | undefined): string { - const body = retained.items.join('\n') - if (!retained.truncated) return body +export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: SpillRef | undefined): string { + const body = sample.items.join('\n') const recovery = spillRef !== undefined ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` : 'The complete result could not be saved; narrow pattern or path to see more.' - return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})` + const basis = sample.total === seen + ? '.' + : `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.` + + (sample.shown < sample.total ? ' Use the list tool to see what a directory contains.' : '') + return `${body}\n\n(Showing ${sample.items.length} of ${seen} paths${basis} ${recovery})` } -/** Retain and format one canonical path list for the Native surface. */ +/** Bound and format one canonical path list for the Native surface. */ function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string { if (paths.length === 0) return 'No files found' - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) - for (const path of paths) retainer.push(path) - return formatGlobOutput(retainer.finish(), spillRef) + // A result that fits is shown whole, untouched: modification-time order is the + // tool's contract, and over a complete result it is what answers age questions. + if (paths.length <= maxResults) return paths.join('\n') + return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults), paths.length, spillRef) } /** @@ -147,16 +237,24 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { ctx.systemPrompt.section({ name: 'tool:glob', order: 103, - text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.', + text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. ' + + 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, ' + + 'so it spans the tree instead of one subtree. Use the list tool to see what a directory contains.', }) const tool = defineTool({ name: 'glob', - description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, ' + description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — ' + 'including hidden and ignored files (VCS metadata directories are excluded). ' - + `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`, + + `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level directories, ` + + 'says so, and reports where the complete sorted list was saved. To see what a directory contains, use the list tool instead.', parameters: { - pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' }, + pattern: { + type: 'string', + required: true, + description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). ' + + 'A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.', + }, path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' }, }, timeoutMs: caps.timeoutMs, diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 5930890b7a..b6ae6ebbad 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -33,8 +33,8 @@ import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' -export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts' -export type { GlobInput, GlobToolCaps } from './glob.ts' +export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts' +export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts' export { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 1951d97a8d..0bbe0e0bf5 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -29,6 +29,7 @@ import { presentGlobCall, presentGrepCall, previewLine, + sampleAcrossTopLevel, toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' @@ -498,6 +499,38 @@ describe('raw output acquisition', () => { }) }) +describe('cross-directory sampling', () => { + it('gives every top-level entry a slot before any entry gets a second', () => { + const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'] + // The head of 3 would be all `v/`; the sample reaches all three entries. + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 }) + // Extra slots go round again — to the only entry with paths left — and the + // page stays grouped by entry rather than interleaved. + expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 }) + }) + + it('hands an exhausted entry the remaining slots go to entries that still have paths', () => { + const paths = ['solo/a', 'many/b', 'many/c', 'many/d'] + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 }) + }) + + it('reports the entries it could not reach when the page is smaller than the top level', () => { + const paths = ['a/1', 'b/1', 'c/1', 'd/1'] + expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 }) + }) + + it('groups an absolute path by its first real name, not by its empty root segment', () => { + // Paths outside the workdir stay absolute; without stripping the leading + // separator every one of them would collapse into a single empty group. + expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2)) + .toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 }) + }) + + it('reproduces the recency-ordered head for a flat result', () => { + expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 }) + }) +}) + describe('glob results', () => { it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => { const { ctx, bash } = await setup() @@ -545,6 +578,43 @@ describe('glob results', () => { expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }]) }) + it('samples an over-cap result across top-level entries instead of taking its head', async () => { + // The shipped failure: `*` matches the whole tree, mtime order puts one + // freshly-unpacked subtree first, and a head-of-3 reads like the entire + // workspace. The sample reaches every top-level entry instead. + const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) + bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) + const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) + expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + + '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched ' + + 'instead of taken in modification-time order. Use the list tool to see what a directory contains. ' + + 'The complete result could not be saved; narrow pattern or path to see more.)') + }) + + it('drops the list hint when the sample does reach every top-level entry', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) + bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) + expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) + .toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n' + + '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched ' + + 'instead of taken in modification-time order. ' + + 'The complete result could not be saved; narrow pattern or path to see more.)') + }) + + it('keeps modification-time order untouched when the whole result fits', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 4 } }) + bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n') + expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) + .toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts') + }) + + it('keeps the plain footer for a flat result, where the sample IS the recency head', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') + expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) + .toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)') + }) + it('does not create a spill file when the result fits inline', async () => { const { ctx, bash, spill } = await setup({ spill: true }) bash.handler = () => runResult('a.ts\nb.ts\n') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index f25084917e..8ac6dd247b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -248,7 +248,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.', }, { pkg: '@deepseek-ai/dsh-tool-pty', From a9c0e006202d09307a76a5ecd6e47ddfc85aba34 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 12:06:25 +0800 Subject: [PATCH 03/97] docs: record the directory-listing and glob-sampling decision Agent Note for the two changes: what the session log showed, what ordering can and cannot fix (measured, correcting the first diagnosis), why the page is sampled only past the cap, why `list` is needed alongside it, and the alternatives each one beat. --- ...026-07-27-directory-listing-tool.i18n.yaml | 6 + .../2026-07-27-directory-listing-tool.md | 110 ++++++++++++++++++ .../2026-07-27-directory-listing-tool.zh.md | 110 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml new file mode 100644 index 0000000000..27a498cb46 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md +2026-07-27-directory-listing-tool.md: 4292c173f07064ff8825462e08096780c20ad9d6 +2026-07-27-directory-listing-tool.zh.md: 4033254b08a77ba7ed9b1f3e638213f019abb692 diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md new file mode 100644 index 0000000000..4292c173f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md @@ -0,0 +1,110 @@ +# Agent Note: Sample over-cap glob results across the tree, and give the model a directory-listing tool + +Status: implemented + +English | [中文](2026-07-27-directory-listing-tool.zh.md) + +## Problem + +Asked what a workspace contained, an agent described one subfolder as if it were the whole project. + +The session log shows exactly how. The workspace held 22 top-level entries and 11,485 files. The model called `glob {"pattern": "*"}`, which matched 10,030 paths; the tool showed the first 100, and all 100 sat under a single recently-unpacked subdirectory holding 355 of those files. The model never saw the other 21 top-level entries and answered from the one it did see. The session cwd was correct throughout — nothing was misconfigured, and every number the tool printed was true. + +Three properties of `glob` compose into that page: + +- **A pattern with no `/` matches at any depth.** The pattern goes to ripgrep as `--glob=`, where a glob without a separator matches the basename anywhere in the tree. `*` therefore means "every file in the workspace", not "the top level" — the opposite of what it means in a shell. The tool said nothing about this, and every example in its schema was `**/…`, so nothing suggested the plain form was recursive. +- **`--sort=modified` orders oldest first.** Unpacking an archive restores the timestamps stored inside it, which predate everything the user wrote, so a freshly unpacked subtree lands at the very front of any broad match. (Ripgrep sorts ascending; `--sortr` is the descending form and is not used here.) +- **The inline page was the head of that order.** `globMaxResults` (100) paths were kept from the front. Under the first two properties, one subtree takes every slot. + +Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 newest files in this workspace" from "this workspace". + +### What ordering can and cannot fix + +A directory's name reaches the model only as the prefix of one of its files' paths, since `rg --files` emits files and never directory entries. That is enough for ordering to matter a great deal, and not enough for `glob` to answer the question. + +Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one recently-written subtree: + +| First 100 paths chosen by | Distinct top-level names visible | +| --- | --- | +| modification time, oldest first (the shipped behavior) | 7 | +| round-robin across top-level entries | 21 | + +So a differently chosen page does surface most of the missing names, and the original diagnosis that ordering could not have helped was wrong. What no ordering fixes: an entry with no files beneath it never appears at all (the reproduction's empty directory is absent from the complete 716-path output), and nothing in the output says which names are directories or how many entries a directory holds. `glob` can therefore convey a tree's rough shape; it cannot state a directory's contents. + +## Decision + +Two changes, in the two packages that own the two halves of the failure. + +### The inline page of an over-cap `glob` result is sampled, not taken from the head + +`@deepseek-ai/dsh-tool-fs-search`. A result within `globMaxResults` is unchanged: shown whole, in modification-time order. Only when the result is larger does the page change — and there, taking the head is what fails. + +`sampleAcrossTopLevel` groups the complete result by leading path segment and fills the page round-robin: every top-level entry gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance. + +The footer states the basis, because a page that silently stopped being "the newest N" would be a second, quieter version of the same lie: + +``` +(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched +instead of taken in modification-time order. Full sorted result stored at: …) +``` + +When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and points at `list`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost. + +The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, that a fitting result is modification-time ordered while a larger one is sampled, and that `list` is the tool for a directory's contents. + +### `list`, in `@deepseek-ai/dsh-tool-fs` + +A fourth model-facing filesystem tool over the existing `ctx.fs.listDir` primitive, which until now shipped with skill discovery as its only consumer; [the seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) deferred the model-facing consumer to a separate decision, which this is. + +It takes an optional `path` — defaulting to `.`, the calling agent's session workspace, so the common question needs no argument — and returns the direct children of one directory as `{ path, entries: [{ name, type }] }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth. + +Two presentation rules carry the decision: + +- **Directories sort first, then files, then non-regular children, each alphabetically** — in the canonical value as well as the rendered text, so a Code Mode caller and the model see one ordering contract. The seam returns stable name order, which scatters subdirectories through the alphabet; capping such a list can drop every subdirectory and reproduce, inside `list`, the same blindness. Directory-first ordering makes truncation lose leaves, never structure. +- **The footer always states the complete listing's size and composition** — `(22 entries: 18 directories, 4 files)`, and when the view is capped at `listMaxEntries` (default 200, configurable), `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`. A partial listing therefore cannot read as a whole directory. + +Directory entries render with a trailing `/` and non-regular children with `@`, so the model can tell what it may descend into without a second call. + +`list` emits no `fs/observed`. Seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate that `@deepseek-ai/dsh-fs-policy` enforces. It declares `isConcurrencySafe`, because it mutates nothing at all. + +### Why both + +They answer different questions and neither substitutes for the other. `list` answers "what is here" exactly — entry names, their types, the complete count — which `glob` cannot do at any ordering. Sampling fixes the page a broad `glob` returns for every *other* question, which stays wrong even once a better tool exists, because the model has no reason to abandon a page that looks representative. + +## Alternatives considered + +**Leave `glob` ordering alone and only warn in the footer.** This is what the first implementation did: keep the recency head and add a clause saying the head covered 1 of 22 top-level entries. Rejected once measured. A warning asks the model to distrust the only data it has and go elsewhere; a better page just is not wrong. The warning also does nothing for a model that stops reading at the paths, which is the failure being fixed. + +**Sample always, replacing modification-time order outright.** Rejected. Over a complete result the order answers age questions — what is stale, what was touched last — a genuinely useful and separate purpose, and a complete result is the case where the order costs nothing and means everything. Sampling only past the cap is the point where the order has already stopped describing the result: the head of a 10,030-path list is not "the oldest files worth knowing about", it is an arbitrary 1% of them. + +**Sample by a skew threshold — head unless the head is badly concentrated.** Rejected. A threshold is a deployment-varying tunable with no evidence behind any value, and it makes the result's ordering contract conditional on data the model cannot see. "Over the cap" is a boundary the model already knows about from the footer. + +**Balance recursively, not just at the top level.** Deferred, and recorded as a Known Limitation. Top-level balance fixes the observed failure and is explainable in one sentence of tool description; per-level balancing needs a policy for how depth trades against breadth, which no current evidence settles. + +**Reject `*`, or silently rewrite it to a top-level-anchored pattern.** Rejected. The same basename-at-any-depth rule that makes `*` recursive is what makes `*.ts` mean "every TypeScript file", the overwhelmingly common and correct use; anchoring one and not the other is an arbitrary special case, and rejecting a pattern ripgrep accepts turns a working call into an error. Documenting the rule costs nothing and generalizes. + +**Add `list` without touching `glob`.** Rejected, for the reason stated under *Why both* above. The misleading page is reachable from any broad pattern, and a model that believes its sample is representative has no reason to reach for another tool. + +**Fix `glob` without adding `list`.** Rejected for the same reason in reverse. A sampled page shows most top-level *names*, but not which are directories, not the empty ones, and not the entry count; "what is in this directory" deserves a tool that answers it rather than a sample the model must infer from. + +**Report per-directory child counts in `list`.** Rejected. Counting each entry's children means one `listDir` per child — an N+1 fan-out across a seam that may be remote or sandboxed, paid on every listing, to sharpen a decision the model can settle by listing the one subdirectory it cares about. + +**Make `list` recursive with a depth argument.** Rejected for now. One level composes: the model lists what it needs to descend into. Recursion reintroduces the size and truncation problems this note exists to fix, and the provider primitive is deliberately one-level. + +**Spill a capped listing through `ctx.spillStore`, as `glob` does.** Rejected for v1. `glob` needs spill because a truncated path list has no cheap successor call; a truncated listing does, and the footer states the complete size and composition, so the model knows both that it is looking at part of a directory and what to do about it. + +## Consequences + +An over-cap `glob` result no longer returns the most recently modified paths. That is a real contract change on a hot path, and it is why the footer, the prompt section, and the schema all state the sampled basis rather than leaving the model to infer it. A result within the cap is byte-identical to before, so age-ordered reading keeps working wherever it was working. + +Balancing is by first path segment only, so a result concentrated deeper — one enormous directory inside an otherwise even tree — is still shown unevenly below the top level. Recorded in the package's Known Limitations. + +The shipped tool surface grows by one tool in every deployment that loads `@deepseek-ai/dsh-tool-fs`, which is all of them: a fixed schema and prompt cost on every request, and an invalidated pinned request header in the ACP snapshot scenario that pins full system-prompt and tool-schema content. The gain is that the harness's most common question has a correct answer; the previous state was not a missing convenience but a capability hole that produced confidently wrong answers. + +`ctx.fs.listDir` gains its first model-facing consumer, which makes its contract load-bearing for a product surface: a future remote or sandboxed backend must implement direct-child listing well enough for a model to navigate by, not merely well enough for skill discovery. The `other` type stays collapsed at the seam, so `list` cannot distinguish a symlink from a socket and marks both `@`. + +## Testing + +Package tests pin the model-visible text of both surfaces. For `glob`: `sampleAcrossTopLevel` over a concentrated result, an exhausted group handing its slots on, a page smaller than the top level, absolute paths outside the workdir, and a flat result that must reproduce the sorted head; plus end-to-end assertions that a fitting result is untouched, that an over-cap result returns the sampled page with the sampled-basis footer, that the list hint disappears once the page reaches every entry, and that a flat over-cap result keeps the plain footer. For `list`: the envelope, type markers, singular and plural footers, the empty-directory footer, and the capped footer that keeps the sole directory visible. A registry-level test asserts that a listing emits no `fs/observed` and that a following `edit` still fails `FS_NOT_OBSERVED`, so the tool cannot become an accidental read-before-write bypass. Prompt-section registration, schema registration, and HMR disposal cover the fourth tool alongside the existing three. + +The assembled transcript is the `fs-list` ACP scenario: a workspace whose answer is its subdirectories, where the model calls `list` with no arguments and the pinned tool result carries the directory-first envelope and its composition footer — an answer `glob` could not have produced at all. diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md new file mode 100644 index 0000000000..4033254b08 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md @@ -0,0 +1,110 @@ +# Agent Note: Sample over-cap glob results across the tree, and give the model a directory-listing tool + +Status: implemented + +[English](2026-07-27-directory-listing-tool.md) | 中文 + +## Problem + +被问到工作区里有什么时,agent(智能体)把其中一个子目录当作整个项目描述了一遍。 + +会话日志把过程说得很清楚。该工作区有 22 个顶层条目、11485 个文件。模型调用了 `glob {"pattern": "*"}`,匹配到 10030 条路径;工具展示了前 100 条,而这 100 条全部位于同一个新近解包的子目录下,该子目录只占其中 355 个文件。模型从未看到另外 21 个顶层条目,只能依据它看到的那一个作答。整个过程中会话 cwd 都是正确的——没有任何配置错误,工具打印的每个数字也都是真的。 + +`glob` 的三项性质合成了那个页面: + +- **不含 `/` 的 pattern 匹配任意深度。** pattern 直接作为 `--glob=` 交给 ripgrep,而不含分隔符的 glob 匹配树中任何位置的基名。因此 `*` 的含义是「工作区里的每个文件」,而不是「顶层」——与它在 shell 里的含义正好相反。工具对此只字未提,schema 里的示例又全是 `**/…`,没有任何线索表明朴素写法是递归的。 +- **`--sort=modified` 按从旧到新排序。** 解包压缩档会还原档案内部保存的时间戳,它们早于用户自己写下的一切,因此新近解包的子树会落在任何宽泛匹配的最前面。(ripgrep 按升序排序;降序是 `--sortr`,此处未使用。) +- **内联页面取的是该顺序的头部。** 从最前面保留 `globMaxResults`(100)条路径。在前两项性质之下,一个子树吃掉全部位置。 + +单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区最新的 100 个文件」与「本工作区」。 + +### 排序能修什么,不能修什么 + +由于 `rg --files` 输出文件、从不输出目录条目,一个目录的名字只能作为其下某个文件路径的前缀抵达模型。这既足以让排序变得非常重要,也不足以让 `glob` 回答那个问题。 + +在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个新近写入的子树: + +| 前 100 条路径的挑选方式 | 可见的顶层名个数 | +| --- | --- | +| 按修改时间、从旧到新(已交付的行为) | 7 | +| 跨顶层条目轮转 | 21 | + +也就是说,换一种页面挑选方式确实能呈现出大部分缺失的名字,最初那句「排序帮不上忙」的诊断是错的。排序修不了的是:没有任何文件的条目根本不会出现(复现目录里的空目录在完整的 716 条输出中一次都没出现),而且输出里没有任何信息说明哪些名字是目录、某个目录有多少条目。因此 `glob` 能传达一棵树的大致形状,却说不出一个目录的内容。 + +## Decision + +两项改动,分别落在承担这次失败两半责任的两个包中。 + +### 超过上限的 `glob` 结果,内联页面改为取样而非取头部 + +`@deepseek-ai/dsh-tool-fs-search`。未超过 `globMaxResults` 的结果完全不变:整体展示,按修改时间排序。只有结果更大时页面才改变——而恰恰在这种情况下,取头部是会失败的做法。 + +`sampleAcrossTopLevel` 按路径首段对完整结果分组,并以轮转方式填充页面:每个顶层条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。 + +footer 会说明取用依据,因为一个悄悄不再是「最新 N 条」的页面,只会成为同一个谎言更安静的版本: + +``` +(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched +instead of taken in modification-time order. Full sorted result stored at: …) +``` + +当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布并指向 `list`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。 + +同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序而更大的结果为取样所得、目录内容请用 `list`。 + +### `list`,位于 `@deepseek-ai/dsh-tool-fs` + +在既有的 `ctx.fs.listDir` 原语之上新增第四个面向模型的文件系统工具;该原语此前交付时唯一的消费方是 skill(技能)发现,[seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) 把面向模型的消费方推迟为一项单独的决策,也就是本文。 + +它接受可选的 `path`,默认为 `.`,即调用 agent 的会话工作区,因此那个最常见的问题不需要任何参数;返回单个目录的直接子项,形如 `{ path, entries: [{ name, type }] }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。 + +有两条展示规则承载了这个决策: + +- **先目录、再文件、最后非常规子项,各组内按字母序** —— 规范值与渲染文本采用同一顺序,使 Code Mode 调用方和模型看到同一份顺序契约。seam 返回的是稳定名称序,会把子目录散落在字母表各处;对这样的列表设上限可能丢掉全部子目录,在 `list` 内部重演同一种盲区。目录优先的顺序让截断只丢叶子,绝不丢结构。 +- **footer 始终说明完整列表的规模与构成** —— 例如 `(22 entries: 18 directories, 4 files)`;视图受 `listMaxEntries`(默认 200,可配置)截断时则为 `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`。因此部分列出结果不可能被读成整个目录。 + +目录条目渲染带尾部 `/`,非常规子项带 `@`,使模型无需第二次调用就能判断哪些可以继续进入。 + +`list` 不发出 `fs/observed`。看到文件名不等于读过文件,列出绝不能满足 `@deepseek-ai/dsh-fs-policy` 施加的编辑前读取门禁。它声明 `isConcurrencySafe`,因为它完全不做任何变更。 + +### 为什么两者都要 + +它们回答的是不同的问题,彼此无法替代。`list` 精确回答「这里有什么」——条目名、类型、完整计数——这是 `glob` 在任何排序下都做不到的。取样修的是宽泛 `glob` 为**其他**所有问题返回的那个页面;即便有了更好的工具,那个页面依然是错的,因为模型没有理由放弃一个看起来有代表性的页面。 + +## Alternatives considered + +**不动 `glob` 排序,只在 footer 里警告。** 这正是第一版实现的做法:保留新近序头部,加一句「该头部只覆盖 22 个顶层条目中的 1 个」。实测之后否决。一句警告是在要求模型不信任它手上唯一的数据并另寻他处;而一个更好的页面本身就不是错的。对于读到路径就停下的模型,警告也毫无作用——而那正是本次要修的失败。 + +**一律取样,彻底取消按修改时间排序。** 已否决。在完整结果上,该顺序回答的是与新旧有关的问题——哪些已经陈旧、哪些最后被动过——这是一个确实有用且独立的用途;而未超上限的完整结果恰恰是该顺序毫无代价、意义最大的场景。只在超过上限后取样,正好落在该顺序已经不再描述结果的那个点上:一份 10030 条列表的头部不是「最值得知道的最旧文件」,而是其中任意的 1%。 + +**按偏斜阈值决定是否取样——头部不太集中时仍取头部。** 已否决。阈值是随部署而变的可调参数,且其取值没有任何证据支撑;它还会让结果的顺序契约取决于模型看不到的数据。而「是否超过上限」是模型本来就能从 footer 得知的边界。 + +**递归均衡,而不只在顶层均衡。** 已延期,并记入已知限制。顶层均衡修好了已观察到的失败,且能用一句工具描述解释清楚;逐层均衡需要一套关于深度与广度如何权衡的策略,目前没有证据能定下来。 + +**拒绝 `*`,或悄悄将其改写为锚定顶层的 pattern。** 已否决。让 `*` 递归的那条「任意深度匹配基名」规则,同样让 `*.ts` 意为「每个 TypeScript 文件」,而后者是压倒性常见且正确的用法;只锚定其一是任意的特例,而拒绝一个 ripgrep 本可接受的 pattern 则把可用调用变成错误。把规则写进文档不花任何代价,而且可以推广。 + +**只加 `list`,不动 `glob`。** 已否决,理由见上文《为什么两者都要》。任何宽泛 pattern 都能触达那个误导性页面,而一个相信自己样本有代表性的模型,没有理由改用别的工具。 + +**只修 `glob`,不加 `list`。** 出于对称的理由否决。取样页面能展示大部分顶层**名字**,但说不出哪些是目录、看不到空目录、也给不出条目总数;「这个目录里有什么」值得一个直接回答它的工具,而不是一份需要模型去推断的样本。 + +**在 `list` 中报告每个目录的子项数。** 已否决。统计每个条目的子项意味着对每个子项各调用一次 `listDir`——在一个可能是远程或沙箱的 seam 上做 N+1 扇出,每次列出都要付费,只为了让模型少做一个它本可以通过列出所关心的那一个子目录就完成的判断。 + +**让 `list` 支持递归和深度参数。** 暂时否决。单层是可组合的:模型列出它需要进入的那一层即可。递归会重新引入本 Agent Note 要解决的规模与截断问题,而且提供方原语本身就有意只做一层。 + +**像 `glob` 那样,把达到上限的列出结果通过 `ctx.spillStore` 落盘。** v1 已否决。`glob` 需要 spill,是因为被截断的路径列表没有廉价的后继调用;被截断的列出结果有,而且 footer 已说明完整规模与构成,模型既知道自己只看到目录的一部分,也知道该怎么办。 + +## Consequences + +超过上限的 `glob` 结果不再返回修改时间最新的那些路径。这是热路径上一项真实的契约变更,也正因如此,footer、提示词段和 schema 都写明了取样这一依据,而不是留给模型去推断。未超上限的结果与此前逐字节相同,因此按新旧顺序阅读结果的用法在原本能用的地方继续能用。 + +均衡只按路径首段进行,因此集中在更深层的结果——一棵总体均匀的树里某个特别庞大的目录——在顶层以下仍然分布不均。已记入该包的已知限制。 + +随产品交付的工具接口在每个加载 `@deepseek-ai/dsh-tool-fs` 的部署中都多出一个工具,而这是全部部署:每个请求都要支付固定的 schema 与提示词成本,并且会使 ACP(Agent Client Protocol)快照场景中那份钉住完整系统提示词与工具 schema 内容的请求头失效。换来的是:harness 最常见的问题终于有了正确答案;此前的状态不是缺少一项便利,而是一个会产出笃定错误答案的能力空洞。 + +`ctx.fs.listDir` 拥有了第一个面向模型的消费方,其契约由此成为产品接口的承重部分:未来的远程或沙箱后端实现直接子项列出时,标准不再是「够 skill 发现用」,而是「够模型据以导航」。`other` 类型在 seam 处仍是合并的,因此 `list` 无法区分符号链接与套接字,两者都标记为 `@`。 + +## Testing + +包测试钉住两个接口面向模型的文本。`glob` 方面:`sampleAcrossTopLevel` 在集中结果、某分组用尽后交出份额、页面小于顶层条目数、工作目录之外的绝对路径,以及必须复现排序头部的扁平结果上的行为;另有端到端断言——未超上限的结果原样不动、超上限结果返回取样页面并带取样依据 footer、页面覆盖全部条目后 list 提示消失、扁平的超上限结果保留朴素 footer。`list` 方面:包络、类型标记、单复数 footer、空目录 footer,以及让唯一那个目录留在视野内的截断 footer。一项注册表层级的测试断言列出不会发出 `fs/observed`,且随后的 `edit` 仍以 `FS_NOT_OBSERVED` 失败,使该工具不会变成意外的编辑前读取绕过口。提示词段注册、schema 注册与 HMR(热模块替换)dispose(资源释放)覆盖了这第四个工具与既有三个工具。 + +组装后的 transcript(文本记录)由 `fs-list` ACP 场景承担:该工作区的答案就是它的子目录,模型不带任何参数调用 `list`,被钉住的工具结果携带目录优先的包络及其构成 footer —— 这个答案 `glob` 根本无法给出。 From 717852423fa4587134b2879378c5f5dbc4244ad4 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 13:01:26 +0800 Subject: [PATCH 04/97] fix(fs): harden directory listing and glob sampling --- ...026-07-27-directory-listing-tool.i18n.yaml | 4 +- .../2026-07-27-directory-listing-tool.md | 31 +++-- .../2026-07-27-directory-listing-tool.zh.md | 31 +++-- docs/config-catalog.md | 2 +- docs/tool-catalog.md | 16 ++- .../system-prompt.expected.md | 13 +- .../tool-schemas.expected.json | 6 +- .../both-mode-turn/system-prompt.expected.md | 13 +- .../both-mode-turn/tool-schemas.expected.json | 6 +- .../code-mode-turn/system-prompt.expected.md | 13 +- .../system-prompt.expected.md | 13 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 6 +- .../tests/snapshots/fs-list/session.jsonl | 2 +- .../lsp-definition/system-prompt.expected.md | 2 +- .../lsp-definition/tool-schemas.expected.json | 6 +- .../pty-tools/system-prompt.expected.md | 2 +- .../pty-tools/tool-schemas.expected.json | 6 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 6 +- .../skill-load/system-prompt.expected.md | 2 +- .../skill-load/tool-schemas.expected.json | 6 +- .../text-turn/system-prompt.expected.md | 2 +- .../text-turn/tool-schemas.expected.json | 6 +- .../web-fetch/system-prompt.expected.md | 2 +- .../web-fetch/tool-schemas.expected.json | 6 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 6 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 10 +- packages/fs/tool-fs-search/README.zh.md | 10 +- packages/fs/tool-fs-search/src/glob.ts | 57 +++++--- .../fs/tool-fs-search/tests/tools.spec.ts | 76 +++++++++-- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 18 +-- packages/fs/tool-fs/README.zh.md | 18 +-- packages/fs/tool-fs/src/index.ts | 2 +- packages/fs/tool-fs/src/list-render.ts | 123 +++++++++++------- packages/fs/tool-fs/src/list.ts | 58 +++++++-- packages/fs/tool-fs/tests/list-render.spec.ts | 36 +++-- packages/fs/tool-fs/tests/tools.spec.ts | 57 ++++++-- scripts/gen-tool-catalog.ts | 4 +- 46 files changed, 480 insertions(+), 219 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml index 27a498cb46..79b30290b5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.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 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md -2026-07-27-directory-listing-tool.md: 4292c173f07064ff8825462e08096780c20ad9d6 -2026-07-27-directory-listing-tool.zh.md: 4033254b08a77ba7ed9b1f3e638213f019abb692 +2026-07-27-directory-listing-tool.md: a23cdb0090f1a88b783d9717aa3f0434b6c2782e +2026-07-27-directory-listing-tool.zh.md: bab0138a79ae770ae7e841392b879940e8ac2dc4 diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md index 4292c173f0..a23cdb0090 100644 --- a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md @@ -16,13 +16,13 @@ Three properties of `glob` compose into that page: - **`--sort=modified` orders oldest first.** Unpacking an archive restores the timestamps stored inside it, which predate everything the user wrote, so a freshly unpacked subtree lands at the very front of any broad match. (Ripgrep sorts ascending; `--sortr` is the descending form and is not used here.) - **The inline page was the head of that order.** `globMaxResults` (100) paths were kept from the front. Under the first two properties, one subtree takes every slot. -Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 newest files in this workspace" from "this workspace". +Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 oldest files in this workspace" from "this workspace". ### What ordering can and cannot fix A directory's name reaches the model only as the prefix of one of its files' paths, since `rg --files` emits files and never directory entries. That is enough for ordering to matter a great deal, and not enough for `glob` to answer the question. -Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one recently-written subtree: +Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one old-timestamped subtree: | First 100 paths chosen by | Distinct top-level names visible | | --- | --- | @@ -39,31 +39,30 @@ Two changes, in the two packages that own the two halves of the failure. `@deepseek-ai/dsh-tool-fs-search`. A result within `globMaxResults` is unchanged: shown whole, in modification-time order. Only when the result is larger does the page change — and there, taking the head is what fails. -`sampleAcrossTopLevel` groups the complete result by leading path segment and fills the page round-robin: every top-level entry gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance. +`sampleAcrossTopLevel` removes the displayed search-root prefix, groups the complete result by the next path segment, and fills the page round-robin: every entry immediately beneath the actual relative or absolute root gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance. -The footer states the basis, because a page that silently stopped being "the newest N" would be a second, quieter version of the same lie: +The footer states the basis, because a page that silently stopped being "the first N in modification-time order" would be a second, quieter version of the same lie: ``` (Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched instead of taken in modification-time order. Full sorted result stored at: …) ``` -When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and points at `list`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost. +When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and tells the model to narrow `path`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost. -The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, that a fitting result is modification-time ordered while a larger one is sampled, and that `list` is the tool for a directory's contents. +The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, and that a fitting result is modification-time ordered while a larger one is sampled across top-level entries. They do not recommend sibling-package tools that may be absent from the current composition. ### `list`, in `@deepseek-ai/dsh-tool-fs` A fourth model-facing filesystem tool over the existing `ctx.fs.listDir` primitive, which until now shipped with skill discovery as its only consumer; [the seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) deferred the model-facing consumer to a separate decision, which this is. -It takes an optional `path` — defaulting to `.`, the calling agent's session workspace, so the common question needs no argument — and returns the direct children of one directory as `{ path, entries: [{ name, type }] }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth. +It takes optional `path` and 1-based `offset` arguments, defaulting to the calling agent's session workspace and entry 1, and returns one bounded page as `{ path, offset, entries: [{ name, type }], totalEntries, counts }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth. -Two presentation rules carry the decision: +Three presentation rules carry the decision: -- **Directories sort first, then files, then non-regular children, each alphabetically** — in the canonical value as well as the rendered text, so a Code Mode caller and the model see one ordering contract. The seam returns stable name order, which scatters subdirectories through the alphabet; capping such a list can drop every subdirectory and reproduce, inside `list`, the same blindness. Directory-first ordering makes truncation lose leaves, never structure. -- **The footer always states the complete listing's size and composition** — `(22 entries: 18 directories, 4 files)`, and when the view is capped at `listMaxEntries` (default 200, configurable), `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`. A partial listing therefore cannot read as a whole directory. - -Directory entries render with a trailing `/` and non-regular children with `@`, so the model can tell what it may descend into without a second call. +- **Directories sort first, then files, then non-regular children, each alphabetically** before paging, so every offset traverses one stable order and the first page keeps navigable structure. +- **The canonical value and Native result carry one recoverable page** of at most `listMaxEntries` (default 200, configurable). The footer states the complete size and composition and gives `offset=` until the final page, so omitted sibling names remain reachable. +- **Filesystem text cannot forge presentation structure.** The path and entry names render as JSON strings with envelope-significant characters escaped; directory `/` and non-regular `@` markers sit outside the quoted name, so a regular filename ending in `@` remains distinguishable. `list` emits no `fs/observed`. Seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate that `@deepseek-ai/dsh-fs-policy` enforces. It declares `isConcurrencySafe`, because it mutates nothing at all. @@ -73,7 +72,7 @@ They answer different questions and neither substitutes for the other. `list` an ## Alternatives considered -**Leave `glob` ordering alone and only warn in the footer.** This is what the first implementation did: keep the recency head and add a clause saying the head covered 1 of 22 top-level entries. Rejected once measured. A warning asks the model to distrust the only data it has and go elsewhere; a better page just is not wrong. The warning also does nothing for a model that stops reading at the paths, which is the failure being fixed. +**Leave `glob` ordering alone and only warn in the footer.** This is what the first implementation did: keep the oldest-first head and add a clause saying the head covered 1 of 22 top-level entries. Rejected once measured. A warning asks the model to distrust the only data it has and go elsewhere; a better page just is not wrong. The warning also does nothing for a model that stops reading at the paths, which is the failure being fixed. **Sample always, replacing modification-time order outright.** Rejected. Over a complete result the order answers age questions — what is stale, what was touched last — a genuinely useful and separate purpose, and a complete result is the case where the order costs nothing and means everything. Sampling only past the cap is the point where the order has already stopped describing the result: the head of a 10,030-path list is not "the oldest files worth knowing about", it is an arbitrary 1% of them. @@ -91,11 +90,11 @@ They answer different questions and neither substitutes for the other. `list` an **Make `list` recursive with a depth argument.** Rejected for now. One level composes: the model lists what it needs to descend into. Recursion reintroduces the size and truncation problems this note exists to fix, and the provider primitive is deliberately one-level. -**Spill a capped listing through `ctx.spillStore`, as `glob` does.** Rejected for v1. `glob` needs spill because a truncated path list has no cheap successor call; a truncated listing does, and the footer states the complete size and composition, so the model knows both that it is looking at part of a directory and what to do about it. +**Spill a capped listing through `ctx.spillStore`, as `glob` does.** Rejected for v1. `glob` needs spill because its schema has no offset; `list` has a cheap successor call through the exact next offset in the footer, while every page repeats the complete size and composition. ## Consequences -An over-cap `glob` result no longer returns the most recently modified paths. That is a real contract change on a hot path, and it is why the footer, the prompt section, and the schema all state the sampled basis rather than leaving the model to infer it. A result within the cap is byte-identical to before, so age-ordered reading keeps working wherever it was working. +An over-cap `glob` result no longer returns the oldest paths at the head of modification-time order. That is a real contract change on a hot path, and it is why the footer, the prompt section, and the schema all state the sampled basis rather than leaving the model to infer it. A result within the cap is byte-identical to before, so age-ordered reading keeps working wherever it was working. Balancing is by first path segment only, so a result concentrated deeper — one enormous directory inside an otherwise even tree — is still shown unevenly below the top level. Recorded in the package's Known Limitations. @@ -105,6 +104,6 @@ The shipped tool surface grows by one tool in every deployment that loads `@deep ## Testing -Package tests pin the model-visible text of both surfaces. For `glob`: `sampleAcrossTopLevel` over a concentrated result, an exhausted group handing its slots on, a page smaller than the top level, absolute paths outside the workdir, and a flat result that must reproduce the sorted head; plus end-to-end assertions that a fitting result is untouched, that an over-cap result returns the sampled page with the sampled-basis footer, that the list hint disappears once the page reaches every entry, and that a flat over-cap result keeps the plain footer. For `list`: the envelope, type markers, singular and plural footers, the empty-directory footer, and the capped footer that keeps the sole directory visible. A registry-level test asserts that a listing emits no `fs/observed` and that a following `edit` still fails `FS_NOT_OBSERVED`, so the tool cannot become an accidental read-before-write bypass. Prompt-section registration, schema registration, and HMR disposal cover the fourth tool alongside the existing three. +Package tests pin the model-visible text of both surfaces. For `glob`: sampling over a concentrated result, an explicit relative root, more top-level groups than JavaScript's argument limit, an exhausted group handing its slots on, a page smaller than the top level, absolute paths outside the workdir, and a flat result that must reproduce the sorted head. For `list`: ordering, complete composition, offset continuation and rejection, empty directories, and filesystem names containing newlines, tag text, or marker suffixes. A registry-level test asserts that a listing emits no `fs/observed` and that a following `edit` still fails `FS_NOT_OBSERVED`, so the tool cannot become an accidental read-before-write bypass. Prompt-section registration, schema registration, and HMR disposal cover the fourth tool alongside the existing three. The assembled transcript is the `fs-list` ACP scenario: a workspace whose answer is its subdirectories, where the model calls `list` with no arguments and the pinned tool result carries the directory-first envelope and its composition footer — an answer `glob` could not have produced at all. diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md index 4033254b08..bab0138a79 100644 --- a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md @@ -16,13 +16,13 @@ Status: implemented - **`--sort=modified` 按从旧到新排序。** 解包压缩档会还原档案内部保存的时间戳,它们早于用户自己写下的一切,因此新近解包的子树会落在任何宽泛匹配的最前面。(ripgrep 按升序排序;降序是 `--sortr`,此处未使用。) - **内联页面取的是该顺序的头部。** 从最前面保留 `globMaxResults`(100)条路径。在前两项性质之下,一个子树吃掉全部位置。 -单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区最新的 100 个文件」与「本工作区」。 +单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区按从旧到新顺序排在最前的 100 个文件」与「本工作区」。 ### 排序能修什么,不能修什么 由于 `rg --files` 输出文件、从不输出目录条目,一个目录的名字只能作为其下某个文件路径的前缀抵达模型。这既足以让排序变得非常重要,也不足以让 `glob` 回答那个问题。 -在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个新近写入的子树: +在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个时间戳较旧的子树: | 前 100 条路径的挑选方式 | 可见的顶层名个数 | | --- | --- | @@ -39,31 +39,30 @@ Status: implemented `@deepseek-ai/dsh-tool-fs-search`。未超过 `globMaxResults` 的结果完全不变:整体展示,按修改时间排序。只有结果更大时页面才改变——而恰恰在这种情况下,取头部是会失败的做法。 -`sampleAcrossTopLevel` 按路径首段对完整结果分组,并以轮转方式填充页面:每个顶层条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。 +`sampleAcrossTopLevel` 移除所显示的搜索根前缀,再按下一个路径段对完整结果分组,并以轮转方式填充页面:实际相对或绝对搜索根正下方的每个条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。 -footer 会说明取用依据,因为一个悄悄不再是「最新 N 条」的页面,只会成为同一个谎言更安静的版本: +footer 会说明取用依据,因为一个悄悄不再是「按修改时间排序的前 N 条」的页面,只会成为同一个谎言更安静的版本: ``` (Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched instead of taken in modification-time order. Full sorted result stored at: …) ``` -当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布并指向 `list`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。 +当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布,并要求模型缩小 `path`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。 -同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序而更大的结果为取样所得、目录内容请用 `list`。 +同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序,而更大的结果跨顶层条目取样。它们不会推荐当前组合中可能不存在的兄弟包工具。 ### `list`,位于 `@deepseek-ai/dsh-tool-fs` 在既有的 `ctx.fs.listDir` 原语之上新增第四个面向模型的文件系统工具;该原语此前交付时唯一的消费方是 skill(技能)发现,[seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) 把面向模型的消费方推迟为一项单独的决策,也就是本文。 -它接受可选的 `path`,默认为 `.`,即调用 agent 的会话工作区,因此那个最常见的问题不需要任何参数;返回单个目录的直接子项,形如 `{ path, entries: [{ name, type }] }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。 +它接受可选的 `path` 和从 1 开始的 `offset` 参数,默认取调用 agent 的会话工作区和第 1 个条目,并返回一个有界页面,形如 `{ path, offset, entries: [{ name, type }], totalEntries, counts }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。 -有两条展示规则承载了这个决策: +有三条展示规则承载了这个决策: -- **先目录、再文件、最后非常规子项,各组内按字母序** —— 规范值与渲染文本采用同一顺序,使 Code Mode 调用方和模型看到同一份顺序契约。seam 返回的是稳定名称序,会把子目录散落在字母表各处;对这样的列表设上限可能丢掉全部子目录,在 `list` 内部重演同一种盲区。目录优先的顺序让截断只丢叶子,绝不丢结构。 -- **footer 始终说明完整列表的规模与构成** —— 例如 `(22 entries: 18 directories, 4 files)`;视图受 `listMaxEntries`(默认 200,可配置)截断时则为 `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`。因此部分列出结果不可能被读成整个目录。 - -目录条目渲染带尾部 `/`,非常规子项带 `@`,使模型无需第二次调用就能判断哪些可以继续进入。 +- **先目录、再文件、最后非常规子项,各组内按字母序**,然后再分页,使每个 offset 都遍历同一稳定顺序,且第一页保留可导航的结构。 +- **规范值和 Native 结果携带一个可继续取回的页面**,最多包含 `listMaxEntries` 个条目(默认 200,可配置)。footer 会说明完整规模与构成,并在最后一页之前给出 `offset=`,因此被省略的同级名称仍可取回。 +- **文件系统文本无法伪造展示结构。** 路径和条目名渲染为 JSON 字符串,并转义对包络有意义的字符;目录 `/` 与非常规子项 `@` 标记位于带引号名称之外,因此以 `@` 结尾的常规文件名仍可区分。 `list` 不发出 `fs/observed`。看到文件名不等于读过文件,列出绝不能满足 `@deepseek-ai/dsh-fs-policy` 施加的编辑前读取门禁。它声明 `isConcurrencySafe`,因为它完全不做任何变更。 @@ -73,7 +72,7 @@ instead of taken in modification-time order. Full sorted result stored at: …) ## Alternatives considered -**不动 `glob` 排序,只在 footer 里警告。** 这正是第一版实现的做法:保留新近序头部,加一句「该头部只覆盖 22 个顶层条目中的 1 个」。实测之后否决。一句警告是在要求模型不信任它手上唯一的数据并另寻他处;而一个更好的页面本身就不是错的。对于读到路径就停下的模型,警告也毫无作用——而那正是本次要修的失败。 +**不动 `glob` 排序,只在 footer 里警告。** 这正是第一版实现的做法:保留从旧到新的头部,加一句「该头部只覆盖 22 个顶层条目中的 1 个」。实测之后否决。一句警告是在要求模型不信任它手上唯一的数据并另寻他处;而一个更好的页面本身就不是错的。对于读到路径就停下的模型,警告也毫无作用——而那正是本次要修的失败。 **一律取样,彻底取消按修改时间排序。** 已否决。在完整结果上,该顺序回答的是与新旧有关的问题——哪些已经陈旧、哪些最后被动过——这是一个确实有用且独立的用途;而未超上限的完整结果恰恰是该顺序毫无代价、意义最大的场景。只在超过上限后取样,正好落在该顺序已经不再描述结果的那个点上:一份 10030 条列表的头部不是「最值得知道的最旧文件」,而是其中任意的 1%。 @@ -91,11 +90,11 @@ instead of taken in modification-time order. Full sorted result stored at: …) **让 `list` 支持递归和深度参数。** 暂时否决。单层是可组合的:模型列出它需要进入的那一层即可。递归会重新引入本 Agent Note 要解决的规模与截断问题,而且提供方原语本身就有意只做一层。 -**像 `glob` 那样,把达到上限的列出结果通过 `ctx.spillStore` 落盘。** v1 已否决。`glob` 需要 spill,是因为被截断的路径列表没有廉价的后继调用;被截断的列出结果有,而且 footer 已说明完整规模与构成,模型既知道自己只看到目录的一部分,也知道该怎么办。 +**像 `glob` 那样,把达到上限的列出结果通过 `ctx.spillStore` 落盘。** v1 已否决。`glob` 需要 spill,是因为其 schema 没有 offset;`list` 可以通过 footer 中精确的下一 offset 廉价地继续调用,而且每一页都会重复完整规模与构成。 ## Consequences -超过上限的 `glob` 结果不再返回修改时间最新的那些路径。这是热路径上一项真实的契约变更,也正因如此,footer、提示词段和 schema 都写明了取样这一依据,而不是留给模型去推断。未超上限的结果与此前逐字节相同,因此按新旧顺序阅读结果的用法在原本能用的地方继续能用。 +超过上限的 `glob` 结果不再返回按修改时间从旧到新排序时位于头部的那些路径。这是热路径上一项真实的契约变更,也正因如此,footer、提示词段和 schema 都写明了取样这一依据,而不是留给模型去推断。未超上限的结果与此前逐字节相同,因此按新旧顺序阅读结果的用法在原本能用的地方继续能用。 均衡只按路径首段进行,因此集中在更深层的结果——一棵总体均匀的树里某个特别庞大的目录——在顶层以下仍然分布不均。已记入该包的已知限制。 @@ -105,6 +104,6 @@ instead of taken in modification-time order. Full sorted result stored at: …) ## Testing -包测试钉住两个接口面向模型的文本。`glob` 方面:`sampleAcrossTopLevel` 在集中结果、某分组用尽后交出份额、页面小于顶层条目数、工作目录之外的绝对路径,以及必须复现排序头部的扁平结果上的行为;另有端到端断言——未超上限的结果原样不动、超上限结果返回取样页面并带取样依据 footer、页面覆盖全部条目后 list 提示消失、扁平的超上限结果保留朴素 footer。`list` 方面:包络、类型标记、单复数 footer、空目录 footer,以及让唯一那个目录留在视野内的截断 footer。一项注册表层级的测试断言列出不会发出 `fs/observed`,且随后的 `edit` 仍以 `FS_NOT_OBSERVED` 失败,使该工具不会变成意外的编辑前读取绕过口。提示词段注册、schema 注册与 HMR(热模块替换)dispose(资源释放)覆盖了这第四个工具与既有三个工具。 +包测试钉住两个接口面向模型的文本。`glob` 方面:对集中结果取样、显式相对根、顶层分组数量超过 JavaScript 参数上限、某分组用尽后交出份额、页面小于顶层条目数、工作目录之外的绝对路径,以及必须复现排序头部的扁平结果。`list` 方面:顺序、完整构成、offset 续页与拒绝、空目录,以及包含换行、标签文本或标记后缀的文件系统名称。一项注册表层级的测试断言列出不会发出 `fs/observed`,且随后的 `edit` 仍以 `FS_NOT_OBSERVED` 失败,使该工具不会变成意外的编辑前读取绕过口。提示词段注册、schema 注册与 HMR(热模块替换)dispose(资源释放)覆盖了这第四个工具与既有三个工具。 组装后的 transcript(文本记录)由 `fs-list` ACP 场景承担:该工作区的答案就是它的子目录,模型不带任何参数调用 `list`,被钉住的工具结果携带目录优先的包络及其构成 footer —— 这个答案 `glob` 根本无法给出。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0b0045b668..e87fc92c05 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1449,7 +1449,7 @@ Requires: `tools` · `fs` · `systemPrompt` ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { - /** Maximum entries one `list` call renders inline; the footer still reports the complete count. */ + /** Maximum entries one `list` page returns; the footer still reports the complete count. */ listMaxEntries?: number /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 0884910bde..97bf0cac1d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,8 +20,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. | -| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | @@ -318,7 +318,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts ### `list` -List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. +List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. ```json { @@ -327,6 +327,10 @@ List the direct children of one directory, with their type. Entries are director "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } @@ -389,13 +393,13 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. ## `@deepseek-ai/dsh-tool-fs-search` ### `glob` -Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level directories, says so, and reports where the complete sorted list was saved. To see what a directory contains, use the list tool instead. +Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries. ```json { @@ -447,7 +451,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. +glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. ## `@deepseek-ai/dsh-tool-pty` diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index c78b763fec..94bc0823bb 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. @@ -99,10 +99,12 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ list: { /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ path?: string; + /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ + offset?: number; } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { @@ -307,10 +309,17 @@ interface ToolOutputMap { }; list: { path: string; + offset: number; entries: ({ name: string; type: "file" | "directory" | "other"; })[]; + totalEntries: number; + counts: { + directories: number; + files: number; + other: number; + }; }; ralph: { runId: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index f9aea468b5..dcacaad8ff 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -174,13 +174,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index ede2684ae4..1a8589a47b 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. @@ -82,10 +82,12 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ list: { /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ path?: string; + /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ + offset?: number; } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { @@ -278,10 +280,17 @@ interface ToolOutputMap { }; list: { path: string; + offset: number; entries: ({ name: string; type: "file" | "directory" | "other"; })[]; + totalEntries: number; + counts: { + directories: number; + files: number; + other: number; + }; }; ralph: { runId: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 1333345b48..9f55d9291f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index ede2684ae4..1a8589a47b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. @@ -82,10 +82,12 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ list: { /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ path?: string; + /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ + offset?: number; } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { @@ -278,10 +280,17 @@ interface ToolOutputMap { }; list: { path: string; + offset: number; entries: ({ name: string; type: "file" | "directory" | "other"; })[]; + totalEntries: number; + counts: { + directories: number; + files: number; + other: number; + }; }; ralph: { runId: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ede2684ae4..1a8589a47b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. @@ -82,10 +82,12 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */ + /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ list: { /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ path?: string; + /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ + offset?: number; } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { @@ -278,10 +280,17 @@ interface ToolOutputMap { }; list: { path: string; + offset: number; entries: ({ name: string; type: "file" | "directory" | "other"; })[]; + totalEntries: number; + counts: { + directories: number; + files: number; + other: number; + }; }; ralph: { runId: string; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md index edca40eed4..cd3c81f6f8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index ffb310c278..7094daf44e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/fs-list/session.jsonl b/examples/acp-agent/tests/snapshots/fs-list/session.jsonl index 0a05e2b1fe..533571ee3f 100644 --- a/examples/acp-agent/tests/snapshots/fs-list/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-list/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":47,"time":1785159115922,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":48,"time":1785159115926,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."},{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"} {"type":"tool/call","seq":49,"time":1785159115927,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}} -{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\ndirectory\n\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} +{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"\"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\"\ndirectory\n\n\"docs\"/\n\"src\"/\n\"package.json\"\n\"README.txt\"\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} {"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}} {"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index 5a5df6233c..d02b6d0859 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 3a0e7c1408..ab1bda91f3 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index 70dd754517..c5496aeed5 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index c529ad9077..0d20d22762 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md index 658fa4e07c..7d13160613 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 4eff11591a..caee8e049e 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 6d926a90a8..e6ab6361db 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index ffb310c278..7094daf44e 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 6d926a90a8..e6ab6361db 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index ffb310c278..7094daf44e 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md index 4c20e18272..451746f412 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 59e3baf6b1..6c389246e2 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 6c5155f262..fdacca4b7f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index ffb310c278..7094daf44e 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -117,13 +117,17 @@ }, { "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.", + "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." + }, + "offset": { + "type": "number", + "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." } } } diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index c0f80e3cdf..0471db78e0 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */\n offset?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n offset: number;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n totalEntries: number;\n counts: {\n directories: number;\n files: number;\n other: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."},"offset":{"type":"number","description":"1-based first entry to return. Defaults to 1; use the footer value to continue."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 2bf8be7bfb..f0cad5bcd7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */\n offset?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n offset: number;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n totalEntries: number;\n counts: {\n directories: number;\n files: number;\n other: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."},"offset":{"type":"number","description":"1-based first entry to return. Defaults to 1; use the footer value to continue."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 8f531cf789..883feaf563 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */\n list: {\n /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */\n path?: string;\n /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */\n offset?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n list: {\n path: string;\n offset: number;\n entries: ({\n name: string;\n type: \"file\" | \"directory\" | \"other\";\n })[];\n totalEntries: number;\n counts: {\n directories: number;\n files: number;\n other: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."},"offset":{"type":"number","description":"1-based first entry to return. Defaults to 1; use the footer value to continue."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 2cde7d9e42..fd122fc594 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 are returned inline and the footer reports the complete count. Unlike glob, this shows subdirectories, so it is how to see what a directory contains.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"list","description":"List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Directory to list. Defaults to the session workspace; a relative path resolves against it."},"offset":{"type":"number","description":"1-based first entry to return. Defaults to 1; use the footer value to continue."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index ec8f6f8968..68c621949d 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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 packages/fs/tool-fs-search/README.md -README.md: 33792a6f4b72baa2626c6e8d37c672fb39681554 -README.zh.md: 5d13a7ff2cb3ddfda8168a34e4a4a897d3413e11 +README.md: 51b3fa5385330cdaba0b36dd71a6efe4fd3d0db5 +README.zh.md: 855db224652b818d5f3dadffd275581b5b760007 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 33792a6f4b..51b3fa5385 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -34,14 +34,14 @@ All keys are optional; the defaults are the shipped search caps. | Tool | Arguments | Behavior | |---|---|---| -| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line, modification-time ordered — `rg --files` never emits a directory, so no pattern makes `glob` describe a directory's contents; that is [`dsh-tool-fs`](../tool-fs/)'s `list`. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. | +| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line, modification-time ordered; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. | | `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. | Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint. ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. +Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors @@ -58,7 +58,7 @@ After the load-time `rg` probe succeeds, every request in this plugin's registra ##### Glob guidance ```markdown -Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains. +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree. ``` ##### Grep guidance @@ -93,7 +93,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration #### What the model sees -`glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. An over-cap `glob` result does not show the head of the sorted list: its inline page takes paths round-robin across the complete result's top-level entries, so one recently-written subtree cannot own every slot, and the footer says the page was sampled rather than taken in modification-time order, together with how many top-level entries it reached. When it could not reach them all, the footer also points at `list`. A result that fits inline is untouched, and a flat result — every path its own top-level entry — keeps the plain footer, because there the sample IS the recency-ordered head. The spill artifact always holds the complete list in modification-time order. +`glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. An over-cap `glob` result does not show the head of the sorted list: its inline page takes paths round-robin across entries immediately beneath the actual search root, so one old-timestamped subtree cannot own every slot, and the footer states the sampled basis and how many top-level entries it reached. When it cannot reach them all, the footer tells the model to narrow `path`. A result that fits inline is untouched, and a flat result — every path its own top-level entry — keeps the plain footer, because there the sample is the modification-time-ordered head. The spill artifact always holds the complete list in modification-time order. #### Token effect @@ -122,4 +122,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. - **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. -- **Sampling groups by first path segment only** — an over-cap `glob` page balances across top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. +- **Sampling groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 5d13a7ff2c..855db22465 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -34,14 +34,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | 工具 | 参数 | 行为 | |---|---|---| -| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目,因此任何 pattern 都无法让 `glob` 描述一个目录的内容,那是 [`dsh-tool-fs`](../tool-fs/) 的 `list`。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 | +| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 | | `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录** 目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: ` 的匹配。 | 常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。 ## 两类预算、两类产物 -原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置政策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;借助 `root`,Native 渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置政策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 @@ -58,7 +58,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ##### Glob 指导 ```markdown -Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains. +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree. ``` ##### Grep 指导 @@ -93,7 +93,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### 模型看到的内容 -`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。超过上限的 `glob` 结果不再展示排序列表的头部:其内联页面按轮转方式跨完整结果的顶层条目取样,因此单个新近写入的子树无法占满所有位置;footer 会说明该页面是取样得到而非按修改时间取用,并给出它触达了多少个顶层条目。未能触达全部时,footer 还会指向 `list`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer,因为此时取样结果就等于按新近度排序的头部。spill 产物始终保存按修改时间排序的完整列表。 +`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。超过上限的 `glob` 结果不再展示排序列表的头部:其内联页面按轮转方式跨实际搜索根正下方的条目取样,因此单个时间戳较旧的子树无法占满所有位置;footer 会说明取样依据及其触达的顶层条目数。无法触达全部时,footer 会要求模型缩小 `path`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer,因为此时取样结果就是按修改时间排序的头部。spill 产物始终保存按修改时间排序的完整列表。 #### Token 影响 @@ -122,4 +122,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read - **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。 - **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。 - **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。 -- **取样只按路径首段分组**:超过上限的 `glob` 页面在顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。 +- **取样只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index d46b1d352d..94d9b0e627 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -107,7 +107,7 @@ export function buildGlobCommand(input: GlobInput): string { * result's top level it reaches. */ export interface GlobSample { - /** Paths to show inline: grouped by top-level entry, recency-ordered within each group. */ + /** Paths to show inline: grouped by top-level entry, modification-time ordered within each group. */ items: string[] /** Distinct top-level entries the shown paths reach. */ shown: number @@ -115,6 +115,18 @@ export interface GlobSample { total: number } +/** Remove the displayed search-root prefix before choosing a top-level group. */ +function relativeToSearchRoot(path: string, root: string): string { + if (root === '.') return path.replace(/^\.[\\/]/, '') + const trimmedRoot = root.replace(/[\\/]+$/, '') + if (trimmedRoot.length === 0) return path.replace(/^[\\/]+/, '') + if (path === trimmedRoot) return '' + if (path.startsWith(`${trimmedRoot}/`) || path.startsWith(`${trimmedRoot}\\`)) { + return path.slice(trimmedRoot.length + 1) + } + return path +} + /** * The leading path segment of one display path — the top-level entry, relative * to the search root, that the path sits under. A path with no separator is its @@ -149,18 +161,19 @@ function topLevelSegment(path: string): string { * * @param paths - the complete result, in ripgrep's modification-time order. * @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`. + * @param root - the search root in the same display-path space as `paths`. * @returns the page grouped by top-level entry, with the shown/total top-level spread. */ -export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number): GlobSample { +export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample { const groups = new Map() for (const path of paths) { - const group = groups.get(topLevelSegment(path)) - if (group === undefined) groups.set(topLevelSegment(path), [path]) + const key = topLevelSegment(relativeToSearchRoot(path, root)) + const group = groups.get(key) + if (group === undefined) groups.set(key, [path]) else group.push(path) } - // Bounding the rounds by the largest group makes termination structural: the - // page can only fill or the groups run out, never spin on empty rounds. - const rounds = Math.max(0, ...[...groups.values()].map(group => group.length)) + let rounds = 0 + for (const group of groups.values()) rounds = Math.max(rounds, group.length) const taken = new Map() let count = 0 for (let round = 0; round < rounds && count < maxItems; round += 1) { @@ -186,7 +199,7 @@ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number) * here — it is emitted verbatim, in ripgrep's order. * * A result whose every path is its own top-level entry keeps the plain footer: - * the sample is the recency-ordered head, and naming a spread would only + * the sample is the modification-time-ordered head, and naming a spread would only * restate the path counts already there. * * @param sample - the inline page and its top-level spread. @@ -202,17 +215,17 @@ export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: Spi const basis = sample.total === seen ? '.' : `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.` - + (sample.shown < sample.total ? ' Use the list tool to see what a directory contains.' : '') + + (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '') return `${body}\n\n(Showing ${sample.items.length} of ${seen} paths${basis} ${recovery})` } -/** Bound and format one canonical path list for the Native surface. */ -function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string { +/** Bound and format one canonical path list for the Native surface relative to its search root. */ +function renderGlobPaths(paths: string[], maxResults: number, root: string, spillRef?: SpillRef): string { if (paths.length === 0) return 'No files found' // A result that fits is shown whole, untouched: modification-time order is the // tool's contract, and over a complete result it is what answers age questions. if (paths.length <= maxResults) return paths.join('\n') - return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults), paths.length, spillRef) + return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults, root), paths.length, spillRef) } /** @@ -238,16 +251,16 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { name: 'tool:glob', order: 103, text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. ' - + 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, ' - + 'so it spans the tree instead of one subtree. Use the list tool to see what a directory contains.', + + 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, ' + + 'so it spans the tree instead of one subtree.', }) const tool = defineTool({ name: 'glob', description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — ' + 'including hidden and ignored files (VCS metadata directories are excluded). ' - + `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level directories, ` - + 'says so, and reports where the complete sorted list was saved. To see what a directory contains, use the list tool instead.', + + `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level entries, ` + + 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.', parameters: { pattern: { type: 'string', @@ -263,15 +276,17 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { type: 'object', additionalProperties: false, properties: { + root: { type: 'string', required: true }, paths: { type: 'array', required: true, items: { type: 'string' } }, }, }, - render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }], + render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults, value.root) }], }, async execute(args, exec) { const input = parseGlobArgs(args) const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) - if (run.noMatches) return { paths: [] } + const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir) + if (run.noMatches) return { root, paths: [] } const all: string[] = [] for (const line of run.stdout.split('\n')) { @@ -279,7 +294,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { const displayPath = toWorkdirRelative(line, run.workdir) all.push(displayPath) } - return { paths: all } + return { root, paths: all } }, presentCall: presentGlobCall, }) @@ -287,14 +302,14 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { ctx.on('tools/post-execute', async (exec, result, next) => { const decision = await next() - const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined + const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { root: string; paths: string[] } | undefined if (value === undefined) return decision const paths = value.paths if (paths.length <= caps.maxResults) return decision const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n')) return { kind: 'accept', - content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }], + content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, value.root, spillRef) }], ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, } }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 0bbe0e0bf5..df49ce33aa 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -185,6 +185,10 @@ describe('registration', () => { const prompt = renderPrompt(await ctx.systemPrompt.assemble()) expect(prompt).toContain('Use the glob tool') expect(prompt).toContain('Use the grep tool') + expect(prompt).toContain('sampled across top-level entries') + expect(prompt).not.toContain('sampled across top-level directories') + const glob = ctx.tools.schemas().find(schema => schema.name === 'glob') + expect(glob?.description).toContain('sampled across top-level entries') }) it('does not register glob or grep when the bash executor cannot find rg', async () => { @@ -526,9 +530,37 @@ describe('cross-directory sampling', () => { .toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 }) }) - it('reproduces the recency-ordered head for a flat result', () => { + it('reproduces the modification-time-ordered head for a flat result', () => { expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 }) }) + + it('groups paths relative to an explicit search root', () => { + expect(sampleAcrossTopLevel([ + 'workspace/vendor/a.ts', + 'workspace/vendor/b.ts', + 'workspace/source/c.ts', + 'workspace/guides/d.md', + ], 3, 'workspace')).toEqual({ + items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'], + shown: 3, + total: 3, + }) + expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.')) + .toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/')) + .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['C:\\root\\a\\one', 'C:\\root\\b\\two'], 2, 'C:\\root')) + .toEqual({ items: ['C:\\root\\a\\one', 'C:\\root\\b\\two'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src')) + .toEqual({ items: ['other/a.ts'], shown: 1, total: 1 }) + expect(sampleAcrossTopLevel(['src'], 1, 'src')) + .toEqual({ items: ['src'], shown: 1, total: 1 }) + }) + + it('handles more top-level groups than the JavaScript argument limit', () => { + const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`) + expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 }) + }) }) describe('glob results', () => { @@ -537,7 +569,7 @@ describe('glob results', () => { bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) if (result.isError) throw new Error('expected glob success') - expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] }) + expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] }) expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`) }) @@ -565,7 +597,7 @@ describe('glob results', () => { const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected glob success') - expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) + expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)') expect(spill?.saves).toHaveLength(1) expect(spill?.saves[0]).toMatchObject({ @@ -587,11 +619,37 @@ describe('glob results', () => { const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched ' - + 'instead of taken in modification-time order. Use the list tool to see what a directory contains. ' + + 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. ' + 'The complete result could not be saved; narrow pattern or path to see more.)') }) - it('drops the list hint when the sample does reach every top-level entry', async () => { + it('samples relative to the explicit search root instead of its workdir prefix', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) + bash.handler = () => runResult([ + 'workspace/vendor/a.ts', + 'workspace/vendor/b.ts', + 'workspace/source/c.ts', + 'workspace/guides/d.md', + ].join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') }) + expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') + }) + + it('samples relative to an absolute search root after workdir display conversion', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) + bash.handler = () => runResult([ + '/w/workspace/vendor/a.ts', + '/w/workspace/vendor/b.ts', + '/w/workspace/source/c.ts', + '/w/workspace/guides/d.md', + ].join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') }) + expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') + }) + + it('drops the narrowing hint when the sample reaches every top-level entry', async () => { const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) @@ -608,7 +666,7 @@ describe('glob results', () => { .toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts') }) - it('keeps the plain footer for a flat result, where the sample IS the recency head', async () => { + it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => { const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) @@ -627,14 +685,14 @@ describe('glob results', () => { const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - value: { paths: ['replacement-a.ts', 'replacement-b.ts'] }, + value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] }, })) bash.handler = () => runResult('old-a.ts\nold-b.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) if (result.isError) throw new Error('expected glob replacement success') - expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] }) + expect(result.value).toEqual({ root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] }) expect(text(result)).toContain('replacement-a.ts') expect(text(result)).not.toContain('old-a.ts') expect(spill?.saves).toHaveLength(0) @@ -648,7 +706,7 @@ describe('glob results', () => { parent: Symbol('run_code') as ToolExecutionToken, }) if (result.isError) throw new Error('expected glob success') - expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) + expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)') expect(spill?.saves).toHaveLength(0) }) diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index e6f4ccf9c0..687b72d93e 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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 packages/fs/tool-fs/README.md -README.md: 08a4f74b928a38b92fdce5f6a03dd7c6c8f0af7c -README.zh.md: cdb45a12eb644e4882f7b92ac77b5bc7fc33f906 +README.md: 3917356b0e4cf48708f2769a6387249f795115ca +README.zh.md: b1ea0b42ed146241ee35d628825e0152c1bc1669 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 08a4f74b92..3917356b0e 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -19,7 +19,7 @@ All keys are optional; the defaults are the shipped listing and read caps. | Key | Default | Meaning | |---|---|---| -| `listMaxEntries` | `200` | Entries one `list` call renders inline; the footer still reports the complete directory's size and composition. | +| `listMaxEntries` | `200` | Maximum entries one `list` page returns; the footer reports complete size and composition plus a next offset when more remain. | | `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | | `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | | `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | @@ -29,14 +29,14 @@ All keys are optional; the defaults are the shipped listing and read caps. | Tool | Arguments | Behavior | |---|---|---| -| `list` | `path?` | Direct children of one directory with their type, defaulting to the session workspace. Ordered directories first, then files, then non-regular children, each alphabetical, and capped at the configured `listMaxEntries` (200). | +| `list` | `path?`, `offset?` | One page of direct children with their type, defaulting to the session workspace and entry 1. Ordered directories first, then files, then non-regular children, each alphabetical; when more remain, continue from the footer's next offset. | | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`, `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`, `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. `list.entries` is one bounded page; `type` is `file`, `directory`, or `other`, while its totals describe the complete directory. Native renderers preserve the listing/read envelopes and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. ## The tool is the executor; policy is an event gate @@ -68,7 +68,7 @@ Every request in this plugin's registration scope receives the independently reg ##### List guidance ```markdown -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. ``` ##### Read guidance @@ -115,11 +115,11 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist #### What the model sees -A successful listing is exactly ``, newline, `directory`, newline, ``, one line per entry, a blank line, one footer, and ``. A directory entry carries a trailing `/` and a non-regular child a trailing `@`; a regular file carries neither. The footer is exactly `(Empty directory)`, `( entries: directories, files)` — with `, other` appended only when such a child exists, and singulars where the count is one — or, when the view is capped, `(Showing of entries: directories, files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`. The complete count and composition are stated whether or not the view was capped, so a partial listing can never read as a whole directory. +A successful listing is ``, newline, `directory`, newline, ``, one line per page entry, a blank line, one footer, and ``. Each entry name is a JSON string with `<`, `>`, and `&` additionally Unicode-escaped so filesystem text cannot forge the envelope; a directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. The footer is `(Empty directory)`, `( entries: directories, files)` with optional `, other`, or `(Showing entries - of : . Use offset= to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition. #### Token effect -Listing output is capped by `listMaxEntries`; the retained call and result are resent until compaction. +Listing output and its canonical `entries` page are capped by `listMaxEntries`; the retained call and result are resent until compaction. #### KV Cache effect @@ -157,7 +157,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `path must be a non-empty string when given`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `path must be a non-empty string when given`, `offset must be a positive integer`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( entries)`, and the corresponding ` lines` read error; provider and policy templates are quoted in their package READMEs. #### Token effect @@ -169,6 +169,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **`list` reads one directory level and has no spill path** — recursion, pagination, and per-directory child counts are absent, and a listing past `listMaxEntries` is summarized by its footer rather than saved anywhere retrievable; the model lists a subdirectory instead. +- **`list` reads one directory level** — recursion and per-directory child counts are absent; offset pagination traverses only the current directory's ordered direct children. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. -- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). +- **No timeout surface** — `list`/`read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index cdb45a12eb..b1ea0b42ed 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -19,7 +19,7 @@ await ctx.plugin(ToolFs) // this package — re | 键 | 默认值 | 含义 | |---|---|---| -| `listMaxEntries` | `200` | 一次 `list` 调用内联渲染的条目数;footer 仍会报告整个目录的规模与构成。 | +| `listMaxEntries` | `200` | 单个 `list` 页面返回的最大条目数;footer 会报告完整规模与构成,并在仍有条目时给出下一 offset。 | | `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 | | `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 | | `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 | @@ -29,14 +29,14 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| -| `list` | `path?` | 单个目录的直接子项及其类型,默认取会话工作区。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列,并受配置的 `listMaxEntries`(200)限制。 | +| `list` | `path?`、`offset?` | 单个目录的一页直接子项及其类型,默认取会话工作区并从第 1 个条目开始。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列;仍有条目时按 footer 给出的下一 offset 继续。 | | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有政策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有政策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`,`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 +规范成功值分别为:`list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`,`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。`list.entries` 是一个有界页面;`type` 为 `file`、`directory` 或 `other`,而总计信息描述的是完整目录。Native 渲染器会保留下方的列出/读取包络和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 ## 工具就是执行器;政策是事件门禁 @@ -68,7 +68,7 @@ await ctx.plugin(ToolFs) // this package — re ##### List 指导 ```markdown -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. Reach for glob or grep once you know the path pattern or the text you are looking for. +Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. ``` ##### Read 指导 @@ -115,11 +115,11 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功列出结果精确为 ``、换行、`directory`、换行、``、每个条目一行、一个空行、一条 footer 和 ``。目录条目带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 精确为 `(Empty directory)`、`( entries: directories, files)`(仅当存在此类子项时才追加 `, other`,计数为一时使用单数形式),或在视图被截断时为 `(Showing of entries: directories, files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`。无论视图是否被截断,都会说明完整计数与构成,因此部分列出结果绝不会被读成整个目录。 +成功列出结果为 ``、换行、`directory`、换行、``、页面中的每个条目一行、一个空行、一条 footer 和 ``。每个条目名都是 JSON 字符串,并额外对 `<`、`>` 和 `&` 做 Unicode 转义,使文件系统文本无法伪造包络;目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 为 `(Empty directory)`、`( entries: directories, files)`(可选追加 `, other`),或 `(Showing entries - of : . Use offset= to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。 #### Token 影响 -列出输出受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。 +列出输出及其规范 `entries` 页面受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。 #### KV Cache 影响 @@ -157,7 +157,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`path must be a non-empty string when given`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和政策模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`path must be a non-empty string when given`、`offset must be a positive integer`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( entries)`,以及对应的 ` lines` 读取错误;提供方和政策模板在各自包的 README 中逐字列出。 #### Token 影响 @@ -169,6 +169,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## 已知限制与延期工作 -- **`list` 只读取一层目录,且没有溢出落盘路径**:不提供递归、分页和逐目录子项计数,超出 `listMaxEntries` 的部分只由 footer 概括,不会保存到任何可取回的位置;模型改为列出对应子目录。 +- **`list` 只读取一层目录**:不提供递归和逐目录子项计数;offset 分页只遍历当前目录中按顺序排列的直接子项。 - **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 -- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 +- **没有超时接口**:`list`/`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index c8376ed123..7890d5084d 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -25,7 +25,7 @@ export const inject = ['tools', 'fs', 'systemPrompt'] /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { - /** Maximum entries one `list` call renders inline; the footer still reports the complete count. */ + /** Maximum entries one `list` page returns; the footer still reports the complete count. */ listMaxEntries?: number /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number diff --git a/packages/fs/tool-fs/src/list-render.ts b/packages/fs/tool-fs/src/list-render.ts index d7a65cbb27..eef3392ec0 100644 --- a/packages/fs/tool-fs/src/list-render.ts +++ b/packages/fs/tool-fs/src/list-render.ts @@ -1,80 +1,111 @@ /** - * Pure listing presentation: order one directory's direct children so a capped - * view still shows the navigable structure, and render the model-facing - * envelope. Cordis-free and independently unit-tested, mirroring - * {@link module:@deepseek-ai/dsh-tool-fs/read-render}. + * Pure directory-listing presentation: order direct children, count complete + * composition, and render a bounded page without allowing filesystem text to + * forge the result envelope. * @module @deepseek-ai/dsh-tool-fs/list-render */ -/** Default and maximum number of entries one `list` call renders inline (the `listMaxEntries` config). */ +/** Default and maximum number of entries one `list` call returns (the `listMaxEntries` config). */ export const LIST_MAX_ENTRIES = 200 -/** One direct child in a rendered listing — the canonical entry shape the tool returns. */ +/** One direct child in a directory listing. */ export interface ListedEntry { /** Basename of the child inside the listed directory. */ name: string - /** Whether the child is a regular file, a directory, or something else (symlink, socket, device). */ + /** Whether the child is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' } +/** Complete-listing composition retained on every page. */ +export interface ListCounts { + directories: number + files: number + other: number +} + +/** Canonical bounded result returned by one `list` call. */ +export interface ListPage { + /** Backend display path of the listed directory. */ + path: string + /** 1-based index of the first returned entry. */ + offset: number + /** Current page in directory-first, name-sorted order. */ + entries: ListedEntry[] + /** Number of direct children in the complete listing. */ + totalEntries: number + /** Composition of the complete listing, not only this page. */ + counts: ListCounts +} + /** - * Order direct children so truncation cannot hide the directory tree: - * directories first, then files, then everything else, each group by name. - * - * The provider seam returns children in stable name order, which puts a - * subdirectory wherever the alphabet puts it; capping such a list can drop every - * subdirectory and leave the model believing a directory holds only files. This - * is the listing counterpart of the `glob` coverage footer. - * - * @param entries - the seam's direct children, in any order. - * @returns a new array in directory-first display order; the input is not mutated. + * Sort directories before files before other entries, each group by name. + * @param entries - direct children in provider order. + * @returns a new directory-first array without mutating `entries`. */ export function orderEntries(entries: readonly T[]): T[] { const rank = { directory: 0, file: 1, other: 2 } return [...entries].sort((a, b) => rank[a.type] - rank[b.type] || a.name.localeCompare(b.name)) } -/** `1 directory` / `4 directories` — a count the model reads as prose, not as `1 directorie(s)`. */ +/** + * Count every entry type in a complete listing. + * @param entries - every direct child in the listed directory. + * @returns the complete directory/file/other composition. + */ +export function countEntries(entries: readonly ListedEntry[]): ListCounts { + const counts: ListCounts = { directories: 0, files: 0, other: 0 } + for (const entry of entries) { + if (entry.type === 'directory') counts.directories += 1 + else if (entry.type === 'file') counts.files += 1 + else counts.other += 1 + } + return counts +} + +/** `1 directory` / `4 directories`. */ function count(n: number, singular: string, plural: string): string { return `${n} ${n === 1 ? singular : plural}` } -/** The ` directories, files[, other]` breakdown; the `other` clause appears only when non-empty. */ -function breakdown(entries: readonly ListedEntry[]): string { - const directories = entries.filter(entry => entry.type === 'directory').length - const other = entries.filter(entry => entry.type === 'other').length - const files = entries.length - directories - other - const parts = [count(directories, 'directory', 'directories'), count(files, 'file', 'files')] - if (other > 0) parts.push(`${other} other`) +/** Complete-listing composition as model-facing prose. */ +function breakdown(counts: ListCounts): string { + const parts = [ + count(counts.directories, 'directory', 'directories'), + count(counts.files, 'file', 'files'), + ] + if (counts.other > 0) parts.push(`${counts.other} other`) return parts.join(', ') } +/** JSON-string encode untrusted filesystem text and neutralize envelope tags. */ +function encodeFilesystemText(value: string): string { + return JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026') +} + /** - * Render the model-facing `list` result: the displayed entries, then a footer - * that always states the COMPLETE listing's size and composition, so a capped - * view can never read as the whole directory. + * Render one bounded listing page. Entry names are JSON strings followed by `/` + * for directories or `@` for non-regular children; regular files have no suffix. + * The footer carries complete composition and an exact continuation offset. * - * Directories carry a trailing `/` and non-regular children a trailing `@`, so - * the model can tell what it may descend into without a second call. - * - * @param displayPath - the resolved directory as the backend displays it. - * @param entries - the complete listing, already in {@link orderEntries} order. - * @param maxEntries - how many entries to show inline; the rest are summarized by the footer. - * @returns the model-facing text. + * @param page - the canonical listing page. + * @returns the model-facing directory envelope. */ -export function formatListOutput(displayPath: string, entries: readonly ListedEntry[], maxEntries: number): string { - const shown = entries.slice(0, maxEntries) +export function formatListOutput(page: ListPage): string { const suffix = { directory: '/', file: '', other: '@' } - const footer = shown.length < entries.length - ? `(Showing ${shown.length} of ${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)}. ` - + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)' - : entries.length === 0 - ? '(Empty directory)' - : `(${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)})` - const body = shown.length > 0 - ? `${shown.map(entry => `${entry.name}${suffix[entry.type]}`).join('\n')}\n\n${footer}` + const end = page.entries.length === 0 ? 0 : page.offset + page.entries.length - 1 + const footer = page.totalEntries === 0 + ? '(Empty directory)' + : page.offset > 1 || page.entries.length < page.totalEntries + ? `(Showing entries ${page.offset}-${end} of ${page.totalEntries}: ${breakdown(page.counts)}.` + + (end < page.totalEntries ? ` Use offset=${end + 1} to continue.)` : ')') + : `(${count(page.totalEntries, 'entry', 'entries')}: ${breakdown(page.counts)})` + const body = page.entries.length > 0 + ? `${page.entries.map(entry => `${encodeFilesystemText(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}` : footer - return `${displayPath} + return `${encodeFilesystemText(page.path)} directory ${body} diff --git a/packages/fs/tool-fs/src/list.ts b/packages/fs/tool-fs/src/list.ts index de6c6d1ddd..270e2803b8 100644 --- a/packages/fs/tool-fs/src/list.ts +++ b/packages/fs/tool-fs/src/list.ts @@ -13,14 +13,15 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { formatListOutput, orderEntries } from './list-render.ts' +import { countEntries, formatListOutput, orderEntries } from './list-render.ts' import { sessionResolveOptions } from './session-cwd.ts' /** Resolved list-tool caps — plugin config after defaulting (see `Config` in index.ts). */ export interface ListToolCaps { - /** Maximum entries rendered inline; the footer still reports the complete listing's size. */ + /** Maximum entries returned on one page; the footer still reports complete size and composition. */ maxEntries: number } @@ -28,6 +29,8 @@ export interface ListToolCaps { export interface ListInput { /** Directory to list; `.` means the calling agent's session workspace. */ path: string + /** 1-based first entry to return from the directory-first ordering. */ + offset: number } /** @@ -36,23 +39,26 @@ export interface ListInput { * needs no argument at all. * * @param args - the schema-validated `list` arguments. - * @returns the accepted input with `path` defaulted. + * @returns the accepted input with `path` and `offset` defaulted. */ -export function parseListArgs(args: { path?: string }): ListInput { +export function parseListArgs(args: { path?: string; offset?: number }): ListInput { if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') - return { path: args.path ?? '.' } + const offset = args.offset ?? 1 + if (!Number.isInteger(offset) || offset < 1) throw new Error('offset must be a positive integer') + return { path: args.path ?? '.', offset } } /** * Pending-call presentation: a generic card titled by the directory, with a * follow-along location so a capable editor can reveal it. * - * @param args - the raw tool arguments; only `path` is read. + * @param args - the raw tool arguments; `path` and `offset` feed the title. * @returns the generic card view shown while the call runs. */ -export function presentListCall(args: { path?: string }): GenericCallView { +export function presentListCall(args: { path?: string; offset?: number }): GenericCallView { const path = args.path ?? '.' - return { card: 'generic', title: `List ${path}`, kind: 'read', locations: [{ path }] } + const window = args.offset !== undefined ? ` (from entry ${args.offset})` : '' + return { card: 'generic', title: `List ${path}${window}`, kind: 'read', locations: [{ path }] } } /** @@ -67,16 +73,17 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void { order: 99, text: 'Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, ' + 'and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. ' - + 'Reach for glob or grep once you know the path pattern or the text you are looking for.', + + 'When a result is capped, continue with the offset named in its footer.', }) ctx.tools.register(defineTool({ name: 'list', description: 'List the direct children of one directory, with their type. ' - + `Entries are directories first, then files, each alphabetical; the first ${caps.maxEntries} are returned inline and the footer reports the complete count. ` - + 'Unlike glob, this shows subdirectories, so it is how to see what a directory contains.', + + `Entries are directories first, then files, each alphabetical; up to ${caps.maxEntries} are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. ` + + 'It includes subdirectories and is the tool for seeing one directory\'s contents.', parameters: { path: { type: 'string', description: 'Directory to list. Defaults to the session workspace; a relative path resolves against it.' }, + offset: { type: 'number', description: '1-based first entry to return. Defaults to 1; use the footer value to continue.' }, }, output: { schema: { @@ -84,6 +91,7 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void { additionalProperties: false, properties: { path: { type: 'string', required: true }, + offset: { type: 'integer', required: true }, entries: { type: 'array', required: true, @@ -96,9 +104,20 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void { }, }, }, + totalEntries: { type: 'integer', required: true }, + counts: { + type: 'object', + required: true, + additionalProperties: false, + properties: { + directories: { type: 'integer', required: true }, + files: { type: 'integer', required: true }, + other: { type: 'integer', required: true }, + }, + }, }, }, - render: (_args, value) => [{ type: 'text', text: formatListOutput(value.path, value.entries, caps.maxEntries) }], + render: (_args, value) => [{ type: 'text', text: formatListOutput(value) }], }, // Listing reads directory metadata only: no content, no version recorded, // nothing a concurrent call could observe out of order. @@ -109,10 +128,21 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void { // No stat first: the seam already answers absence with FS_NOT_FOUND and a // non-directory target with FS_NOT_DIRECTORY, so a probe would only add a // round-trip and a second source of truth. (0 stat.) - const entries = await ctx.fs.listDir(target, exec.signal) + const entries = orderEntries(await ctx.fs.listDir(target, exec.signal)) + if (input.offset > entries.length && !(entries.length === 0 && input.offset === 1)) { + throw new FsError( + `offset ${input.offset} is out of range for "${target.displayPath}" (${entries.length} entries)`, + 'FS_NOT_FOUND', + ) + } return { path: target.displayPath, - entries: orderEntries(entries).map(({ name, type }) => ({ name, type })), + offset: input.offset, + entries: entries + .slice(input.offset - 1, input.offset - 1 + caps.maxEntries) + .map(({ name, type }) => ({ name, type })), + totalEntries: entries.length, + counts: countEntries(entries), } }, presentCall: presentListCall, diff --git a/packages/fs/tool-fs/tests/list-render.spec.ts b/packages/fs/tool-fs/tests/list-render.spec.ts index 3d0a3ac5ce..2c26c1be2c 100644 --- a/packages/fs/tool-fs/tests/list-render.spec.ts +++ b/packages/fs/tool-fs/tests/list-render.spec.ts @@ -4,11 +4,22 @@ */ import { describe, expect, it } from 'vitest' -import { formatListOutput, orderEntries } from '../src/list-render.ts' -import type { ListedEntry } from '../src/list-render.ts' +import { countEntries, formatListOutput, orderEntries } from '../src/list-render.ts' +import type { ListedEntry, ListPage } from '../src/list-render.ts' const entry = (name: string, type: ListedEntry['type'] = 'file'): ListedEntry => ({ name, type }) +function page(entries: ListedEntry[], options: { offset?: number; totalEntries?: number; all?: ListedEntry[] } = {}): ListPage { + const all = options.all ?? entries + return { + path: '/w', + offset: options.offset ?? 1, + entries, + totalEntries: options.totalEntries ?? all.length, + counts: countEntries(all), + } +} + describe('orderEntries', () => { it('groups directories, then files, then other, each by name', () => { const ordered = orderEntries([ @@ -31,36 +42,35 @@ describe('orderEntries', () => { describe('formatListOutput', () => { it('marks directories and non-regular children, and counts the whole listing', () => { - expect(formatListOutput('/w', [entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')], 10)).toBe(`/w + expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`"/w" directory -src/ -a.txt -sock@ +"src"/ +"a.txt" +"sock"@ (3 entries: 1 directory, 1 file, 1 other) `) }) it('omits the "other" clause when every child is a file or a directory', () => { - expect(formatListOutput('/w', [entry('a.txt'), entry('b.txt')], 10)).toContain('(2 entries: 0 directories, 2 files)') + expect(formatListOutput(page([entry('a.txt'), entry('b.txt')]))).toContain('(2 entries: 0 directories, 2 files)') }) it('says a one-entry listing in the singular', () => { - expect(formatListOutput('/w', [entry('only', 'directory')], 10)).toContain('(1 entry: 1 directory, 0 files)') + expect(formatListOutput(page([entry('only', 'directory')]))).toContain('(1 entry: 1 directory, 0 files)') }) it('states the complete size and composition when the view is capped', () => { const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))] - const rendered = formatListOutput('/w', entries, 2) - expect(rendered).toContain('src/\nf0.txt\n') + const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries })) + expect(rendered).toContain('"src"/\n"f0.txt"\n') expect(rendered).not.toContain('f2.txt') - expect(rendered).toContain('(Showing 2 of 6 entries: 1 directory, 5 files. ' - + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)') + expect(rendered).toContain('(Showing entries 1-2 of 6: 1 directory, 5 files. Use offset=3 to continue.)') }) it('renders an empty directory as a footer alone', () => { - expect(formatListOutput('/w', [], 10)).toBe(`/w + expect(formatListOutput(page([]))).toBe(`"/w" directory (Empty directory) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 66f88a6348..74268b72ca 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -162,6 +162,7 @@ describe('registration', () => { const { ctx } = await setup() const prompt = renderPrompt(await ctx.systemPrompt.assemble()) expect(prompt).toContain('Use the list tool') + expect(prompt).not.toContain('glob or grep') expect(prompt).toContain('Use the read tool') expect(prompt).toContain('Use the write tool') expect(prompt).toContain('Use the edit tool') @@ -220,20 +221,23 @@ describe('list tool', () => { // model see the same ordering contract. expect(result.value).toEqual({ path: '/abs/.', + offset: 1, entries: [ { name: 'archive', type: 'directory' }, { name: 'zeroomega-3.3.23', type: 'directory' }, { name: 'notes.md', type: 'file' }, { name: 'link-to-nowhere', type: 'other' }, ], + totalEntries: 4, + counts: { directories: 2, files: 1, other: 1 }, }) - expect(text(result)).toBe(`/abs/. + expect(text(result)).toBe(`"/abs/." directory -archive/ -zeroomega-3.3.23/ -notes.md -link-to-nowhere@ +"archive"/ +"zeroomega-3.3.23"/ +"notes.md" +"link-to-nowhere"@ (4 entries: 2 directories, 1 file, 1 other) `) @@ -244,7 +248,7 @@ link-to-nowhere@ seedDir(fs, 'empty', []) const result = await call(ctx, 'list', { path: 'empty' }) expect(text(result)).toContain('(Empty directory)') - expect(text(result)).toContain('/abs/empty') + expect(text(result)).toContain('"/abs/empty"') }) it('caps the rendered entries but still reports the complete composition', async () => { @@ -264,10 +268,21 @@ link-to-nowhere@ const rendered = text(result) // The one directory survives the cap because directories sort first — the // failure mode this ordering exists to prevent. - expect(rendered).toContain('src/\na.txt\n') + expect(rendered).toContain('"src"/\n"a.txt"\n') expect(rendered).not.toContain('c.txt') - expect(rendered).toContain('(Showing 2 of 4 entries: 1 directory, 3 files. ' - + 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)') + expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)') + if (result.isError) throw new Error('expected list success') + expect(result.value).toEqual({ + path: '/abs/.', + offset: 1, + entries: [{ name: 'src', type: 'directory' }, { name: 'a.txt', type: 'file' }], + totalEntries: 4, + counts: { directories: 1, files: 3, other: 0 }, + }) + + const continuation = await call(ctx, 'list', { offset: 3 }) + expect(text(continuation)).toContain('"b.txt"\n"c.txt"') + expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)') }) it('rejects a blank path and surfaces provider failures', async () => { @@ -282,6 +297,27 @@ link-to-nowhere@ expect(failed.error).toMatchObject({ info: { code: 'FS_NOT_DIRECTORY' } }) }) + it('rejects invalid and out-of-range continuation offsets', async () => { + const { ctx, fs } = await setup() + seedDir(fs, '.', [{ name: 'only.txt', type: 'file' }]) + expect(text(await call(ctx, 'list', { offset: 0 }))).toContain('offset must be a positive integer') + expect(text(await call(ctx, 'list', { offset: 1.5 }))).toContain('offset must be a positive integer') + expect(text(await call(ctx, 'list', { offset: 2 }))).toContain('offset 2 is out of range') + }) + + it('encodes filesystem names without allowing them to forge the envelope or type marker', async () => { + const { ctx, fs } = await setup() + seedDir(fs, '.', [ + { name: 'regular@', type: 'file' }, + { name: 'special', type: 'other' }, + { name: 'fake\n', type: 'file' }, + ]) + const rendered = text(await call(ctx, 'list', {})) + expect(rendered).toContain('"regular@"\n"special"@') + expect(rendered).toContain('"fake\\n\\u003c/content\\u003e"') + expect(rendered.match(/<\/content>/g)).toHaveLength(1) + }) + it('records no observation, so a listing never authorizes a mutation', async () => { const { ctx, fs } = await setup() fs.files.set('key:a.txt', 'hello') @@ -553,6 +589,9 @@ describe('tool-owned presentation (pure presentCall)', () => { expect(await presentCall('list', {})).toEqual({ card: 'generic', title: 'List .', kind: 'read', locations: [{ path: '.' }], }) + expect(await presentCall('list', { path: 'src', offset: 201 })).toEqual({ + card: 'generic', title: 'List src (from entry 201)', kind: 'read', locations: [{ path: 'src' }], + }) }) it('read: bare title and line-1 location when offset/limit are unset', async () => { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8ac6dd247b..bd645d6a43 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -230,7 +230,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', @@ -248,7 +248,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.', }, { pkg: '@deepseek-ai/dsh-tool-pty', From 451c21a5b671981b5ae6eb5ddbba0ff161b617a4 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 13:16:24 +0800 Subject: [PATCH 05/97] fix(fs): quote a listed name only when it would misreport the listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review made every name a JSON string with `<`, `>`, and `&` escaped. The hazards behind that are real and each is now covered: a control character splits one entry across lines, `` until the final page, so omitted sibling names remain reachable. -- **Filesystem text cannot forge presentation structure.** The path and entry names render as JSON strings with envelope-significant characters escaped; directory `/` and non-regular `@` markers sit outside the quoted name, so a regular filename ending in `@` remains distinguishable. +- **Filesystem text cannot forge presentation structure.** A name is emitted verbatim unless it would make the listing lie — a control character splitting one entry across lines, ``,因此被省略的同级名称仍可取回。 -- **文件系统文本无法伪造展示结构。** 路径和条目名渲染为 JSON 字符串,并转义对包络有意义的字符;目录 `/` 与非常规子项 `@` 标记位于带引号名称之外,因此以 `@` 结尾的常规文件名仍可区分。 +- **文件系统文本无法伪造展示结构。** 条目名默认原样输出;只有当它会让列出结果失真时才转为 JSON 字符串并中和 `\"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\"\ndirectory\n\n\"docs\"/\n\"src\"/\n\"package.json\"\n\"README.txt\"\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} +{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\ndirectory\n\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} {"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}} {"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 687b72d93e..3e7c989373 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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 packages/fs/tool-fs/README.md -README.md: 3917356b0e4cf48708f2769a6387249f795115ca -README.zh.md: b1ea0b42ed146241ee35d628825e0152c1bc1669 +README.md: 8fdd54fb36353ca6c1dbcf71cdad38c4bcf26b7d +README.zh.md: 567c8e0df58950369c59785859948e32cbccdef4 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 3917356b0e..8fdd54fb36 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -115,7 +115,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist #### What the model sees -A successful listing is ``, newline, `directory`, newline, ``, one line per page entry, a blank line, one footer, and ``. Each entry name is a JSON string with `<`, `>`, and `&` additionally Unicode-escaped so filesystem text cannot forge the envelope; a directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. The footer is `(Empty directory)`, `( entries: directories, files)` with optional `, other`, or `(Showing entries - of : . Use offset= to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition. +A successful listing is ``, newline, `directory`, newline, ``, one line per page entry, a blank line, one footer, and ``. A directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. A name is emitted verbatim unless it could make the listing say something untrue — a control character, a leading `"`, a backslash, ` entries: directories, files)` with optional `, other`, or `(Showing entries - of : . Use offset= to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index b1ea0b42ed..567c8e0df5 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -115,7 +115,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功列出结果为 ``、换行、`directory`、换行、``、页面中的每个条目一行、一个空行、一条 footer 和 ``。每个条目名都是 JSON 字符串,并额外对 `<`、`>` 和 `&` 做 Unicode 转义,使文件系统文本无法伪造包络;目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 为 `(Empty directory)`、`( entries: directories, files)`(可选追加 `, other`),或 `(Showing entries - of : . Use offset= to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。 +成功列出结果为 ``、换行、`directory`、换行、``、页面中的每个条目一行、一个空行、一条 footer 和 ``。目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。条目名默认原样输出,只有当它可能让列出结果失真时才转为 JSON 字符串并中和 ` entries: directories, files)`(可选追加 `, other`),或 `(Showing entries - of : . Use offset= to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/list-render.ts b/packages/fs/tool-fs/src/list-render.ts index eef3392ec0..86b57d02b3 100644 --- a/packages/fs/tool-fs/src/list-render.ts +++ b/packages/fs/tool-fs/src/list-render.ts @@ -77,17 +77,41 @@ function breakdown(counts: ListCounts): string { return parts.join(', ') } -/** JSON-string encode untrusted filesystem text and neutralize envelope tags. */ -function encodeFilesystemText(value: string): string { - return JSON.stringify(value) - .replaceAll('<', '\\u003c') - .replaceAll('>', '\\u003e') - .replaceAll('&', '\\u0026') +/** + * Names this renderer cannot emit verbatim, because POSIX allows every byte but + * `/` and NUL in a name and each of these would make the listing say something + * untrue: + * + * - a control character (a newline above all) splits one entry across lines; + * - ` 0 - ? `${page.entries.map(entry => `${encodeFilesystemText(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}` + ? `${page.entries.map(entry => `${renderName(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}` : footer - return `${encodeFilesystemText(page.path)} + return `${renderName(page.path)} directory ${body} diff --git a/packages/fs/tool-fs/tests/list-render.spec.ts b/packages/fs/tool-fs/tests/list-render.spec.ts index 2c26c1be2c..792c91bd18 100644 --- a/packages/fs/tool-fs/tests/list-render.spec.ts +++ b/packages/fs/tool-fs/tests/list-render.spec.ts @@ -42,12 +42,12 @@ describe('orderEntries', () => { describe('formatListOutput', () => { it('marks directories and non-regular children, and counts the whole listing', () => { - expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`"/w" + expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`/w directory -"src"/ -"a.txt" -"sock"@ +src/ +a.txt +sock@ (3 entries: 1 directory, 1 file, 1 other) `) @@ -64,13 +64,13 @@ describe('formatListOutput', () => { it('states the complete size and composition when the view is capped', () => { const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))] const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries })) - expect(rendered).toContain('"src"/\n"f0.txt"\n') + expect(rendered).toContain('src/\nf0.txt\n') expect(rendered).not.toContain('f2.txt') expect(rendered).toContain('(Showing entries 1-2 of 6: 1 directory, 5 files. Use offset=3 to continue.)') }) it('renders an empty directory as a footer alone', () => { - expect(formatListOutput(page([]))).toBe(`"/w" + expect(formatListOutput(page([]))).toBe(`/w directory (Empty directory) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 74268b72ca..1ab91fb665 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -231,13 +231,13 @@ describe('list tool', () => { totalEntries: 4, counts: { directories: 2, files: 1, other: 1 }, }) - expect(text(result)).toBe(`"/abs/." + expect(text(result)).toBe(`/abs/. directory -"archive"/ -"zeroomega-3.3.23"/ -"notes.md" -"link-to-nowhere"@ +archive/ +zeroomega-3.3.23/ +notes.md +link-to-nowhere@ (4 entries: 2 directories, 1 file, 1 other) `) @@ -248,7 +248,7 @@ describe('list tool', () => { seedDir(fs, 'empty', []) const result = await call(ctx, 'list', { path: 'empty' }) expect(text(result)).toContain('(Empty directory)') - expect(text(result)).toContain('"/abs/empty"') + expect(text(result)).toContain('/abs/empty') }) it('caps the rendered entries but still reports the complete composition', async () => { @@ -268,7 +268,7 @@ describe('list tool', () => { const rendered = text(result) // The one directory survives the cap because directories sort first — the // failure mode this ordering exists to prevent. - expect(rendered).toContain('"src"/\n"a.txt"\n') + expect(rendered).toContain('src/\na.txt\n') expect(rendered).not.toContain('c.txt') expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)') if (result.isError) throw new Error('expected list success') @@ -281,7 +281,7 @@ describe('list tool', () => { }) const continuation = await call(ctx, 'list', { offset: 3 }) - expect(text(continuation)).toContain('"b.txt"\n"c.txt"') + expect(text(continuation)).toContain('b.txt\nc.txt') expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)') }) @@ -313,8 +313,10 @@ describe('list tool', () => { { name: 'fake\n', type: 'file' }, ]) const rendered = text(await call(ctx, 'list', {})) - expect(rendered).toContain('"regular@"\n"special"@') - expect(rendered).toContain('"fake\\n\\u003c/content\\u003e"') + // A regular file really named `regular@` must not read as a socket named + // `regular`, and a newline in a name must not become a second entry. + expect(rendered).toContain('"regular@"\nspecial@') + expect(rendered).toContain('"fake\\n<\\/content>"') expect(rendered.match(/<\/content>/g)).toHaveLength(1) }) From 57cf1379186ef04c56c2688dbb48ebed8bc2d0c8 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 14:42:59 +0800 Subject: [PATCH 06/97] fix(fs-search): keep broad glob samples representative Remove the duplicate model-facing list tool from this branch; directory orientation remains available through bash ls. Keep the glob sampling fix, add a real ACP composition snapshot, and narrow the decision record to the shipped bug fix. --- .../2026-07-27-glob-sampling.i18n.yaml} | 6 +- .../bug-fix/2026-07-27-glob-sampling.md | 45 ++ .../bug-fix/2026-07-27-glob-sampling.zh.md | 45 ++ .../2026-07-27-directory-listing-tool.md | 109 ---- .../2026-07-27-directory-listing-tool.zh.md | 109 ---- docs/config-catalog.md | 4 +- docs/tool-catalog.md | 4 +- .../acp-agent/fs-search.cordis.snapshot.yml | 24 + examples/acp-agent/fs-search.cordis.yml | 13 + examples/acp-agent/tests/acp.snapshot.ts | 19 +- .../acp-agent/tests/fixtures/fs-search-bin/rg | 8 + .../system-prompt.expected.md | 23 - .../tool-schemas.expected.json | 17 - .../both-mode-turn/system-prompt.expected.md | 23 - .../both-mode-turn/tool-schemas.expected.json | 17 - .../code-mode-turn/system-prompt.expected.md | 23 - .../system-prompt.expected.md | 23 - .../system-prompt.expected.md | 2 - .../tool-schemas.expected.json | 17 - .../snapshots/fs-glob-sampling/input.json | 7 + .../snapshots/fs-glob-sampling/session.jsonl | 24 + .../stdout.expected.jsonl | 2 +- .../system-prompt.expected.md | 29 + .../tool-schemas.expected.json | 517 ++++++++++++++++++ .../tests/snapshots/fs-list/input.json | 7 - .../tests/snapshots/fs-list/session.jsonl | 32 -- .../snapshots/fs-list/workspace/README.txt | 1 - .../fs-list/workspace/docs/guide.txt | 1 - .../snapshots/fs-list/workspace/package.json | 1 - .../snapshots/fs-list/workspace/src/index.txt | 1 - .../lsp-definition/system-prompt.expected.md | 2 - .../lsp-definition/tool-schemas.expected.json | 17 - .../pty-tools/system-prompt.expected.md | 2 - .../pty-tools/tool-schemas.expected.json | 17 - .../system-prompt.expected.md | 2 - .../tool-schemas.expected.json | 17 - .../skill-load/system-prompt.expected.md | 2 - .../skill-load/tool-schemas.expected.json | 17 - .../text-turn/system-prompt.expected.md | 2 - .../text-turn/tool-schemas.expected.json | 17 - .../web-fetch/system-prompt.expected.md | 2 - .../web-fetch/tool-schemas.expected.json | 17 - .../system-prompt.expected.md | 2 - .../tool-schemas.expected.json | 17 - .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/fs/tool-fs/README.i18n.yaml | 6 +- packages/fs/tool-fs/README.md | 45 +- packages/fs/tool-fs/README.zh.md | 45 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-fs/src/index.ts | 18 +- packages/fs/tool-fs/src/list-render.ts | 137 ----- packages/fs/tool-fs/src/list.ts | 150 ----- packages/fs/tool-fs/tests/list-render.spec.ts | 79 --- packages/fs/tool-fs/tests/tools.spec.ts | 172 +----- scripts/gen-tool-catalog.ts | 4 +- 55 files changed, 775 insertions(+), 1171 deletions(-) rename .agents/notes/implemented/{feature/2026-07-27-directory-listing-tool.i18n.yaml => bug-fix/2026-07-27-glob-sampling.i18n.yaml} (58%) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md delete mode 100644 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.zh.md create mode 100644 examples/acp-agent/fs-search.cordis.snapshot.yml create mode 100644 examples/acp-agent/fs-search.cordis.yml create mode 100755 examples/acp-agent/tests/fixtures/fs-search-bin/rg create mode 100644 examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl rename examples/acp-agent/tests/snapshots/{fs-list => fs-glob-sampling}/stdout.expected.jsonl (90%) create mode 100644 examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/input.json delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/session.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/package.json delete mode 100644 examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt delete mode 100644 packages/fs/tool-fs/src/list-render.ts delete mode 100644 packages/fs/tool-fs/src/list.ts delete mode 100644 packages/fs/tool-fs/tests/list-render.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml index 9bf1c8cb73..88e4ec9fc8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md -2026-07-27-directory-listing-tool.md: fd8e8fb22d0b75a4d457e800296fa3ce2c2b5bd5 -2026-07-27-directory-listing-tool.zh.md: 7d4e797878858ea1ca09f1eea6c6a3953c9abae6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md +2026-07-27-glob-sampling.md: c583f0cd8110684a94d0cd04f5cf2ae861ce7aa3 +2026-07-27-glob-sampling.zh.md: 97025a7714237fc716dee745a858140cd783ba2d diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md new file mode 100644 index 0000000000..c583f0cd81 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md @@ -0,0 +1,45 @@ +# Agent Note: Sample over-cap glob results across the tree + +Status: implemented + +English | [中文](2026-07-27-glob-sampling.zh.md) + +## Problem + +Asked what a workspace contained, an agent described one subfolder as if it were the whole project. The workspace held 22 top-level entries and 11,485 files. `glob {"pattern":"*"}` matched 10,030 paths, but all 100 inline paths sat under one recently unpacked subtree, so the model never saw the other 21 entries. + +Three individually valid behaviors composed into the false impression. A glob without `/` matches basenames at any depth, so `*` means every file in the tree rather than the shell's current-directory expansion. Ripgrep's `--sort=modified` is ascending, so an archive's restored old timestamps put that subtree first. The inline page then took the head of that order without saying that it represented only one concentrated slice. + +## Decision + +A result that fits within `globMaxResults` remains complete and byte-for-byte modification-time ordered. An over-cap result is sampled round-robin across the complete result's top-level entries: every entry receives one slot before any receives a second, exhausted groups drop out, and relative order remains stable within each group. Grouping is relative to the actual search root, including an explicit `path`. + +The footer states that the page is a cross-entry sample rather than the modification-time head, reports how many top-level entries it reaches when that fact adds information, and preserves the complete sorted list in the spill artifact. When more top-level entries exist than inline slots, it tells the model to narrow `path`. + +The prompt and schema also state that a pattern without `/` matches at any depth and that glob returns files, never directory entries. Directory orientation remains ordinary shell work in deployments that expose the model-facing bash tool: use `ls` for one directory, and glob for a named file-path pattern across the tree. `ctx.fs.listDir` remains an internal provider primitive used by skill discovery; this decision adds no model-facing `list` tool. + +## Alternatives considered + +**Keep the modification-time head and only warn about concentration.** Rejected after measuring the failure shape. A warning asks the model to distrust the only paths it received; representative data fixes the answer directly. + +**Sample every result.** Rejected. A complete result loses nothing to truncation, so modification-time order remains useful for age-oriented questions. Sampling begins only when the head stops describing the whole. + +**Switch to newest-first order.** Rejected. It merely changes which concentrated subtree can dominate and removes the existing oldest-first contract without making a capped page representative. + +**Sample only past a skew threshold.** Rejected. No current evidence supports a deployment-wide threshold, and the model could not know which ordering contract applied. The existing cap is the explainable transition. + +**Balance recursively below the top level.** Deferred. First-segment balance fixes the observed failure; deeper balancing needs an unsupported depth-versus-breadth policy. + +**Add a model-facing `list` tool.** Rejected after implementation review. The default coding composition already exposes general bash and the model understands `ls`; a duplicate tool would add permanent schema/prompt tokens plus ordering, pagination, symlink, escaping, UI, and snapshot contracts without a distinct security or policy benefit. Thin deployments without a model-facing bash tool do not gain directory orientation from this change. + +**Reject `*` or silently anchor separator-free patterns.** Rejected. The same basename-at-any-depth behavior makes `*.ts` useful across a tree. Documenting the rule preserves working ripgrep semantics. + +## Consequences + +An over-cap glob page no longer answers age-order questions from its inline paths; its footer says so, and the spill artifact retains the complete sorted view. Sampling balances only the first segment beneath the search root, so a deeper hot subtree can still dominate within one top-level entry. + +The tool surface does not grow. The fix changes glob's prompt, schema description, canonical output (`root` records the sampling basis), and over-cap Native rendering while leaving fitting results unchanged. + +## Testing + +Package tests pin concentrated and flat results, explicit roots, more groups than the JavaScript argument limit, exhausted groups, fewer slots than groups, and paths outside the workdir. The `fs-glob-sampling` ACP scenario boots the real Loader/app/sandbox-bash composition and executes the real search plugin against a deterministic `rg` process fixture; its result spans four top-level entries instead of returning one subtree's head. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md new file mode 100644 index 0000000000..97025a7714 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 跨目录树采样超出上限的 glob 结果 + +Status: implemented + +[English](2026-07-27-glob-sampling.md) | 中文 + +## 问题 + +用户询问工作区包含什么内容时,一个 agent(智能体)把某个子文件夹描述成了整个项目。该工作区有 22 个顶层条目和 11,485 个文件。`glob {"pattern":"*"}` 匹配到 10,030 条路径,但内联显示的 100 条路径全部位于一棵近期解压的子树中,因此模型完全没有看到其余 21 个条目。 + +三个单独看都合理的行为叠加后造成了错误印象。不含 `/` 的 glob 会匹配任意深度的文件名,因此 `*` 表示目录树中的每个文件,而不是 shell 对当前目录执行的展开。Ripgrep 的 `--sort=modified` 按升序排列,因此归档包还原出的旧时间戳会让该子树排在最前。随后,内联页面直接截取这一顺序的前部,却没有说明它只代表集中于一处的切片。 + +## 决策 + +未超过 `globMaxResults` 的结果仍保持完整,且按修改时间排序的内容逐字节不变。超过上限时,系统会在完整结果的顶层条目之间按轮转方式采样:每个条目都先获得一个位置,之后才有条目获得第二个位置;已经用尽的分组会退出轮转;各组内部的相对顺序保持稳定。分组始终以实际搜索根为基准,显式指定 `path` 时也如此。 + +footer 会说明当前页面是跨条目的样本,而不是按修改时间排序的前部;当顶层条目覆盖数能提供额外信息时,还会报告该数量;完整排序列表仍保存在 spill 产物中。若顶层条目数量超过内联位置数,footer 会要求模型缩小 `path`。 + +提示词与 schema 还会说明:不含 `/` 的模式会匹配任意深度,glob 只返回文件,绝不返回目录条目。在向模型暴露 bash 工具的部署中,目录定位仍由普通 shell 操作完成:查看一个目录使用 `ls`,跨目录树按指定文件路径模式查找则使用 glob。skill(技能)发现流程仍将 `ctx.fs.listDir` 作为内部提供方原语使用;本决策不会新增面向模型的 `list` 工具。 + +## 考虑过的替代方案 + +**保留按修改时间排序的前部,只警告结果过于集中。** 测量实际故障形态后否决。警告只会要求模型怀疑自己拿到的唯一一批路径;具有代表性的数据能直接修正答案。 + +**对所有结果采样。** 否决。完整结果没有因截断损失任何信息,因此按修改时间排序仍有助于回答关注新旧时间的问题。只有当截取前部已经无法描述整体时,才开始采样。 + +**改为最新优先排序。** 否决。这只会改变哪一棵结果集中的子树可能占据主导;既取消了现有的最旧优先契约,也没有让受限页面更具代表性。 + +**仅在偏斜超过阈值时采样。** 否决。目前没有证据支持适用于所有部署的统一阈值,模型也无法判断当前采用的是哪一种排序契约。现有上限是可以清楚解释的切换点。 + +**在顶层以下递归平衡。** 暂缓。按第一路径段做平衡已经修复观测到的故障;更深层的平衡需要一套尚无依据的深度与广度取舍策略。 + +**新增面向模型的 `list` 工具。** 实现评审后否决。默认编程组合已经提供通用 bash,模型也理解 `ls`;重复工具会永久增加 schema 与提示词所占的 token,并引入排序、分页、符号链接、转义、UI 与快照契约,却没有独立的安全或策略收益。不向模型提供 bash 工具的精简部署也不会因本次改动获得目录定位能力。 + +**拒绝 `*`,或在不含分隔符的模式前静默加上根目录锚点。** 否决。同样的「在任意深度匹配文件名」行为使 `*.ts` 可以有效地跨目录树搜索。记录这条规则能够保留正常工作的 Ripgrep 语义。 + +## 影响 + +超过上限的 glob 页面无法再根据内联路径回答按时间判断新旧的问题;footer 会明确说明这一点,spill 产物仍保留完整的排序视图。采样只平衡搜索根下的第一路径段,因此某个顶层条目内部较深处、结果密集的子树仍可能占据主导。 + +工具接口不会扩大。此修复会更改 glob 的提示词、schema 描述、规范输出(`root` 记录采样基准)以及超过上限时的 Native 渲染,未超过上限的结果保持不变。 + +## 测试 + +包测试锁定了结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会启动真实的 Loader/app/sandbox-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 diff --git a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md b/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md deleted file mode 100644 index fd8e8fb22d..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md +++ /dev/null @@ -1,109 +0,0 @@ -# Agent Note: Sample over-cap glob results across the tree, and give the model a directory-listing tool - -Status: implemented - -English | [中文](2026-07-27-directory-listing-tool.zh.md) - -## Problem - -Asked what a workspace contained, an agent described one subfolder as if it were the whole project. - -The session log shows exactly how. The workspace held 22 top-level entries and 11,485 files. The model called `glob {"pattern": "*"}`, which matched 10,030 paths; the tool showed the first 100, and all 100 sat under a single recently-unpacked subdirectory holding 355 of those files. The model never saw the other 21 top-level entries and answered from the one it did see. The session cwd was correct throughout — nothing was misconfigured, and every number the tool printed was true. - -Three properties of `glob` compose into that page: - -- **A pattern with no `/` matches at any depth.** The pattern goes to ripgrep as `--glob=`, where a glob without a separator matches the basename anywhere in the tree. `*` therefore means "every file in the workspace", not "the top level" — the opposite of what it means in a shell. The tool said nothing about this, and every example in its schema was `**/…`, so nothing suggested the plain form was recursive. -- **`--sort=modified` orders oldest first.** Unpacking an archive restores the timestamps stored inside it, which predate everything the user wrote, so a freshly unpacked subtree lands at the very front of any broad match. (Ripgrep sorts ascending; `--sortr` is the descending form and is not used here.) -- **The inline page was the head of that order.** `globMaxResults` (100) paths were kept from the front. Under the first two properties, one subtree takes every slot. - -Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 oldest files in this workspace" from "this workspace". - -### What ordering can and cannot fix - -A directory's name reaches the model only as the prefix of one of its files' paths, since `rg --files` emits files and never directory entries. That is enough for ordering to matter a great deal, and not enough for `glob` to answer the question. - -Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one old-timestamped subtree: - -| First 100 paths chosen by | Distinct top-level names visible | -| --- | --- | -| modification time, oldest first (the shipped behavior) | 7 | -| round-robin across top-level entries | 21 | - -So a differently chosen page does surface most of the missing names, and the original diagnosis that ordering could not have helped was wrong. What no ordering fixes: an entry with no files beneath it never appears at all (the reproduction's empty directory is absent from the complete 716-path output), and nothing in the output says which names are directories or how many entries a directory holds. `glob` can therefore convey a tree's rough shape; it cannot state a directory's contents. - -## Decision - -Two changes, in the two packages that own the two halves of the failure. - -### The inline page of an over-cap `glob` result is sampled, not taken from the head - -`@deepseek-ai/dsh-tool-fs-search`. A result within `globMaxResults` is unchanged: shown whole, in modification-time order. Only when the result is larger does the page change — and there, taking the head is what fails. - -`sampleAcrossTopLevel` removes the displayed search-root prefix, groups the complete result by the next path segment, and fills the page round-robin: every entry immediately beneath the actual relative or absolute root gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance. - -The footer states the basis, because a page that silently stopped being "the first N in modification-time order" would be a second, quieter version of the same lie: - -``` -(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched -instead of taken in modification-time order. Full sorted result stored at: …) -``` - -When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and tells the model to narrow `path`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost. - -The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, and that a fitting result is modification-time ordered while a larger one is sampled across top-level entries. They do not recommend sibling-package tools that may be absent from the current composition. - -### `list`, in `@deepseek-ai/dsh-tool-fs` - -A fourth model-facing filesystem tool over the existing `ctx.fs.listDir` primitive, which until now shipped with skill discovery as its only consumer; [the seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) deferred the model-facing consumer to a separate decision, which this is. - -It takes optional `path` and 1-based `offset` arguments, defaulting to the calling agent's session workspace and entry 1, and returns one bounded page as `{ path, offset, entries: [{ name, type }], totalEntries, counts }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth. - -Three presentation rules carry the decision: - -- **Directories sort first, then files, then non-regular children, each alphabetically** before paging, so every offset traverses one stable order and the first page keeps navigable structure. -- **The canonical value and Native result carry one recoverable page** of at most `listMaxEntries` (default 200, configurable). The footer states the complete size and composition and gives `offset=` until the final page, so omitted sibling names remain reachable. -- **Filesystem text cannot forge presentation structure.** A name is emitted verbatim unless it would make the listing lie — a control character splitting one entry across lines, `` 交给 ripgrep,而不含分隔符的 glob 匹配树中任何位置的基名。因此 `*` 的含义是「工作区里的每个文件」,而不是「顶层」——与它在 shell 里的含义正好相反。工具对此只字未提,schema 里的示例又全是 `**/…`,没有任何线索表明朴素写法是递归的。 -- **`--sort=modified` 按从旧到新排序。** 解包压缩档会还原档案内部保存的时间戳,它们早于用户自己写下的一切,因此新近解包的子树会落在任何宽泛匹配的最前面。(ripgrep 按升序排序;降序是 `--sortr`,此处未使用。) -- **内联页面取的是该顺序的头部。** 从最前面保留 `globMaxResults`(100)条路径。在前两项性质之下,一个子树吃掉全部位置。 - -单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区按从旧到新顺序排在最前的 100 个文件」与「本工作区」。 - -### 排序能修什么,不能修什么 - -由于 `rg --files` 输出文件、从不输出目录条目,一个目录的名字只能作为其下某个文件路径的前缀抵达模型。这既足以让排序变得非常重要,也不足以让 `glob` 回答那个问题。 - -在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个时间戳较旧的子树: - -| 前 100 条路径的挑选方式 | 可见的顶层名个数 | -| --- | --- | -| 按修改时间、从旧到新(已交付的行为) | 7 | -| 跨顶层条目轮转 | 21 | - -也就是说,换一种页面挑选方式确实能呈现出大部分缺失的名字,最初那句「排序帮不上忙」的诊断是错的。排序修不了的是:没有任何文件的条目根本不会出现(复现目录里的空目录在完整的 716 条输出中一次都没出现),而且输出里没有任何信息说明哪些名字是目录、某个目录有多少条目。因此 `glob` 能传达一棵树的大致形状,却说不出一个目录的内容。 - -## Decision - -两项改动,分别落在承担这次失败两半责任的两个包中。 - -### 超过上限的 `glob` 结果,内联页面改为取样而非取头部 - -`@deepseek-ai/dsh-tool-fs-search`。未超过 `globMaxResults` 的结果完全不变:整体展示,按修改时间排序。只有结果更大时页面才改变——而恰恰在这种情况下,取头部是会失败的做法。 - -`sampleAcrossTopLevel` 移除所显示的搜索根前缀,再按下一个路径段对完整结果分组,并以轮转方式填充页面:实际相对或绝对搜索根正下方的每个条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。 - -footer 会说明取用依据,因为一个悄悄不再是「按修改时间排序的前 N 条」的页面,只会成为同一个谎言更安静的版本: - -``` -(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched -instead of taken in modification-time order. Full sorted result stored at: …) -``` - -当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布,并要求模型缩小 `path`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。 - -同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序,而更大的结果跨顶层条目取样。它们不会推荐当前组合中可能不存在的兄弟包工具。 - -### `list`,位于 `@deepseek-ai/dsh-tool-fs` - -在既有的 `ctx.fs.listDir` 原语之上新增第四个面向模型的文件系统工具;该原语此前交付时唯一的消费方是 skill(技能)发现,[seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) 把面向模型的消费方推迟为一项单独的决策,也就是本文。 - -它接受可选的 `path` 和从 1 开始的 `offset` 参数,默认取调用 agent 的会话工作区和第 1 个条目,并返回一个有界页面,形如 `{ path, offset, entries: [{ name, type }], totalEntries, counts }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。 - -有三条展示规则承载了这个决策: - -- **先目录、再文件、最后非常规子项,各组内按字母序**,然后再分页,使每个 offset 都遍历同一稳定顺序,且第一页保留可导航的结构。 -- **规范值和 Native 结果携带一个可继续取回的页面**,最多包含 `listMaxEntries` 个条目(默认 200,可配置)。footer 会说明完整规模与构成,并在最后一页之前给出 `offset=`,因此被省略的同级名称仍可取回。 -- **文件系统文本无法伪造展示结构。** 条目名默认原样输出;只有当它会让列出结果失真时才转为 JSON 字符串并中和 `; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ - list: { - /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ - path?: string; - /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ - offset?: number; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -307,20 +298,6 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; - list: { - path: string; - offset: number; - entries: ({ - name: string; - type: "file" | "directory" | "other"; - })[]; - totalEntries: number; - counts: { - directories: number; - files: number; - other: number; - }; - }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 93457f0e37..314b24e2be 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -172,23 +172,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 1a8589a47b..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -82,13 +80,6 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ - list: { - /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ - path?: string; - /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ - offset?: number; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -278,20 +269,6 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; - list: { - path: string; - offset: number; - entries: ({ - name: string; - type: "file" | "directory" | "other"; - })[]; - totalEntries: number; - counts: { - directories: number; - files: number; - other: number; - }; - }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 9f55d9291f..b61d7bf623 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 1a8589a47b..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -82,13 +80,6 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ - list: { - /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ - path?: string; - /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ - offset?: number; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -278,20 +269,6 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; - list: { - path: string; - offset: number; - entries: ({ - name: string; - type: "file" | "directory" | "other"; - })[]; - totalEntries: number; - counts: { - directories: number; - files: number; - other: number; - }; - }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 1a8589a47b..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -82,13 +80,6 @@ interface ToolArgsMap { } & Record; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal: Record; - /** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents. */ - list: { - /** Directory to list. Defaults to the session workspace; a relative path resolves against it. */ - path?: string; - /** 1-based first entry to return. Defaults to 1; use the footer value to continue. */ - offset?: number; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -278,20 +269,6 @@ interface ToolOutputMap { }; activation: "armed" | "disarmed"; }; - list: { - path: string; - offset: number; - entries: ({ - name: string; - type: "file" | "directory" | "other"; - })[]; - totalEntries: number; - counts: { - directories: number; - files: number; - other: number; - }; - }; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md index cd3c81f6f8..e3437ad61a 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index 7094daf44e..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json new file mode 100644 index 0000000000..cc5fc95e59 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl new file mode 100644 index 0000000000..16a7d9b631 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -0,0 +1,24 @@ +{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"/tmp/acp-fs-glob-sampling","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785218400006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} +{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nsrc/index.ts\ndocs/guide.md\ntest/spec.ts\n\n(Showing 4 of 6 paths, sampled across 4 of the 4 top-level entries this pattern matched instead of taken in modification-time order. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} +{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":1785218400021,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"GLOB_SAMPLED"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-pro"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1785218400022,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":1785218400023,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl similarity index 90% rename from examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl rename to examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl index 22d36a3c79..691b11cef0 100644 --- a/examples/acp-agent/tests/snapshots/fs-list/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"docs src"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GLOB_SAMPLED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md new file mode 100644 index 0000000000..467b05904b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md @@ -0,0 +1,29 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json new file mode 100644 index 0000000000..1b25291c5c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json @@ -0,0 +1,517 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 4 paths come back in modification-time order; a larger result instead returns 4 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/fs-list/input.json b/examples/acp-agent/tests/snapshots/fs-list/input.json deleted file mode 100644 index 67a0bc3f42..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/input.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "steps": [ - { "op": "initialize" }, - { "op": "newSession" }, - { "op": "prompt", "text": "Call the list tool exactly once, with no arguments at all (NOT bash, NOT glob, and do not pass a path). Then reply with exactly the names of the subdirectories it reported, alphabetically, separated by a single space, and nothing else." } - ] -} diff --git a/examples/acp-agent/tests/snapshots/fs-list/session.jsonl b/examples/acp-agent/tests/snapshots/fs-list/session.jsonl deleted file mode 100644 index 0a05e2b1fe..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/session.jsonl +++ /dev/null @@ -1,32 +0,0 @@ -{"type":"session","version":0,"id":"ea640046-d959-4644-9a03-ff3d08133d4b","createdAt":1785159113359,"cwd":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785159113361,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785159113362,"data":{"content":[{"type":"text","text":"Call the list tool exactly once, with no arguments at all (NOT bash, NOT glob, and do not pass a path). Then reply with exactly the names of the subdirectories it reported, alphabetically, separated by a single space, and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785159113363,"data":{"title":"Call the list tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785159113368,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785159113369,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785159115248,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785159115249,"data":{"turn":1,"step":1,"index":0,"dt":[146,45,3,0,0,1,43,1,0,0,1,1,31,1,46,0,0,0,0,0,41,1,0,0,45,2,2,0,0,1,44,3,3,0],"texts":["The"," user"," wants"," me"," to"," call"," the"," list"," tool"," with"," no"," arguments"," and"," then"," reply"," with"," the"," names"," of"," the"," sub","direct","ories"," it"," reports",","," alphabet","ically",","," separated"," by"," a"," single"," space","."]}} -{"type":"assistant/chunk","seq":41,"time":1785159115836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":42,"time":1785159115837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":43,"time":1785159115837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","argumentsDelta":"{}"}}} -{"type":"assistant/chunk","seq":44,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."}}}} -{"type":"assistant/chunk","seq":45,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}}}} -{"type":"assistant/chunk","seq":46,"time":1785159115921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}}}} -{"type":"assistant/chunk","seq":47,"time":1785159115922,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":48,"time":1785159115926,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."},{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"} -{"type":"tool/call","seq":49,"time":1785159115927,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}} -{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\ndirectory\n\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":54,"time0":1785159116890,"data":{"turn":1,"step":2,"index":0,"dt":[145,49,1,0,60,0,0,1,0,39,1,0,0,0,0,23,1,46,3,0,0],"texts":["The"," sub","direct","ories"," reported"," are"," `","docs","/","`"," and"," `","src","/","`."," Alphabet","ically",":"," docs",","," src","."]}} -{"type":"assistant/chunk","seq":76,"time":1785159117259,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":77,"time":1785159117259,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"docs"}}} -{"type":"assistant/chunk","seq":78,"time":1785159117297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" src"}}} -{"type":"assistant/chunk","seq":79,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subdirectories reported are `docs/` and `src/`. Alphabetically: docs, src."}}}} -{"type":"assistant/chunk","seq":80,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"docs src"}}}} -{"type":"assistant/chunk","seq":81,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":161,"outputTokens":25,"cacheReadTokens":5504,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":82,"time":1785159117300,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":83,"time":1785159117301,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subdirectories reported are `docs/` and `src/`. Alphabetically: docs, src."},{"type":"text","text":"docs src"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":161,"outputTokens":25,"cacheReadTokens":5504,"reasoningTokens":22}},"sourceEventSeqs":[53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} -{"type":"step/end","seq":84,"time":1785159117308,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":85,"time":1785159117309,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt deleted file mode 100644 index dab306f45e..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/workspace/README.txt +++ /dev/null @@ -1 +0,0 @@ -# Project diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt deleted file mode 100644 index 8c0d02fadc..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/workspace/docs/guide.txt +++ /dev/null @@ -1 +0,0 @@ -# Guide diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json b/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json deleted file mode 100644 index e36fa754cf..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/workspace/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "demo" } diff --git a/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt b/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt deleted file mode 100644 index eab39ce89c..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-list/workspace/src/index.txt +++ /dev/null @@ -1 +0,0 @@ -export const answer = 42 diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index d02b6d0859..7bde8fe289 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index ab1bda91f3..9b5925605c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index c5496aeed5..df065a83cb 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 0d20d22762..8e093db8bd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md index 7d13160613..68bdd841c7 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index caee8e049e..beb93c6b53 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index e6ab6361db..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 7094daf44e..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index e6ab6361db..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 7094daf44e..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md index 451746f412..45705db0a5 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 6c389246e2..70940f8907 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index fdacca4b7f..6cd8d5725f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -5,8 +5,6 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. - Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 7094daf44e..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -115,23 +115,6 @@ "properties": {} } }, - { - "name": "list", - "description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. It includes subdirectories and is the tool for seeing one directory's contents.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory to list. Defaults to the session workspace; a relative path resolves against it." - }, - "offset": { - "type": "number", - "description": "1-based first entry to return. Defaults to 1; use the footer value to continue." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 974d88c18c..3754595f56 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 3e7c989373..13f1ecd649 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 packages/fs/tool-fs/README.md -README.md: 8fdd54fb36353ca6c1dbcf71cdad38c4bcf26b7d -README.zh.md: 567c8e0df58950369c59785859948e32cbccdef4 +# pnpm run verify-translation-pairing --write +README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 +README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 8fdd54fb36..4ff9b04352 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,24 +2,23 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `list`, `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, **listing order**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. +The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers list/read/write/edit +await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. ## Config -All keys are optional; the defaults are the shipped listing and read caps. +All keys are optional; the defaults are the shipped read caps. | Key | Default | Meaning | |---|---|---| -| `listMaxEntries` | `200` | Maximum entries one `list` page returns; the footer reports complete size and composition plus a next offset when more remain. | | `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | | `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | | `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | @@ -29,20 +28,18 @@ All keys are optional; the defaults are the shipped listing and read caps. | Tool | Arguments | Behavior | |---|---|---| -| `list` | `path?`, `offset?` | One page of direct children with their type, defaulting to the session workspace and entry 1. Ordered directories first, then files, then non-regular children, each alphabetical; when more remain, continue from the footer's next offset. | | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`, `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. `list.entries` is one bounded page; `type` is `file`, `directory`, or `other`, while its totals describe the complete directory. Native renderers preserve the listing/read envelopes and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: -- **list** — one `ctx.fs.listDir`; the seam already answers absence with `FS_NOT_FOUND` and a non-directory target with `FS_NOT_DIRECTORY`, so no probe precedes it. No `fs/observed`: a listing reads no file content and must not satisfy the read-before-write gate. (0 stat.) - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -53,9 +50,9 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -`list` and `read` opt into concurrent scheduling — `list` mutates nothing at all, and `read`'s only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). -The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Pure presentation lives beside the executors and is independently unit-tested: read windowing and output formatting in `src/read-render.ts`, listing order and envelope in `src/list-render.ts` (both Cordis-free); `src/list.ts`/`read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience @@ -63,13 +60,7 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con #### What the model sees -Every request in this plugin's registration scope receives the independently registered list, read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. - -##### List guidance - -```markdown -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. -``` +Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. ##### Read guidance @@ -101,7 +92,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`list`, `read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -111,20 +102,6 @@ Fixed schema cost on every request in that tool view. Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. -### List result - -#### What the model sees - -A successful listing is ``, newline, `directory`, newline, ``, one line per page entry, a blank line, one footer, and ``. A directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. A name is emitted verbatim unless it could make the listing say something untrue — a control character, a leading `"`, a backslash, ` entries: directories, files)` with optional `, other`, or `(Showing entries - of : . Use offset= to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition. - -#### Token effect - -Listing output and its canonical `entries` page are capped by `listMaxEntries`; the retained call and result are resent until compaction. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - ### Read result #### What the model sees @@ -157,7 +134,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `path must be a non-empty string when given`, `offset must be a positive integer`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( entries)`, and the corresponding ` lines` read error; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. #### Token effect @@ -169,6 +146,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **`list` reads one directory level** — recursion and per-directory child counts are absent; offset pagination traverses only the current directory's ordered direct children. +- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. -- **No timeout surface** — `list`/`read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). +- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 567c8e0df5..f94a903c9c 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,24 +2,23 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`list`、`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑**、**列出顺序** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不** 注入政策服务。新鲜度/观察政策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。 +**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不** 注入政策服务。新鲜度/观察政策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers list/read/write/edit +await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` `@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。 ## 配置 -所有键均为可选;默认值是随产品交付的列出与读取上限。 +所有键均为可选;默认值是随产品交付的读取上限。 | 键 | 默认值 | 含义 | |---|---|---| -| `listMaxEntries` | `200` | 单个 `list` 页面返回的最大条目数;footer 会报告完整规模与构成,并在仍有条目时给出下一 offset。 | | `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 | | `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 | | `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 | @@ -29,20 +28,18 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| -| `list` | `path?`、`offset?` | 单个目录的一页直接子项及其类型,默认取会话工作区并从第 1 个条目开始。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列;仍有条目时按 footer 给出的下一 offset 继续。 | | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有政策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有政策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`,`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。`list.entries` 是一个有界页面;`type` 为 `file`、`directory` 或 `other`,而总计信息描述的是完整目录。Native 渲染器会保留下方的列出/读取包络和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。 ## 工具就是执行器;政策是事件门禁 工具**不** 注入政策服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: -- **list**:一次 `ctx.fs.listDir`;seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,因此前面不需要任何探测。不发出 `fs/observed`:列出不读取任何文件内容,也不得满足编辑前读取门禁。(0 次 stat。) - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -53,9 +50,9 @@ await ctx.plugin(ToolFs) // this package — re `fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 -`list` 与 `read` 都允许并发调度:`list` 完全不做任何变更,而 `read` 的唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +`read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 -包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。纯展示逻辑与执行器并列存放并单独进行单元测试:读取窗口与输出格式化位于 `src/read-render.ts`,列出顺序与包络位于 `src/list-render.ts`(两者均不依赖 Cordis);`src/list.ts`/`read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 +包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 ## 模型体验 @@ -63,13 +60,7 @@ await ctx.plugin(ToolFs) // this package — re #### 模型看到的内容 -该插件注册作用域内的每个请求都会收到下方独立注册的 list、read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。 - -##### List 指导 - -```markdown -Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer. -``` +该插件注册作用域内的每个请求都会收到下方独立注册的 read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。 ##### Read 指导 @@ -101,7 +92,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`list`、`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -111,20 +102,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 只要可见工具定义和顺序不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 -### 列出结果 - -#### 模型看到的内容 - -成功列出结果为 ``、换行、`directory`、换行、``、页面中的每个条目一行、一个空行、一条 footer 和 ``。目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。条目名默认原样输出,只有当它可能让列出结果失真时才转为 JSON 字符串并中和 ` entries: directories, files)`(可选追加 `, other`),或 `(Showing entries - of : . Use offset= to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。 - -#### Token 影响 - -列出输出及其规范 `entries` 页面受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。 - -#### KV Cache 影响 - -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 - ### 读取结果 #### 模型看到的内容 @@ -157,7 +134,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`path must be a non-empty string when given`、`offset must be a positive integer`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( entries)`,以及对应的 ` lines` 读取错误;提供方和政策模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和政策模板在各自包的 README 中逐字列出。 #### Token 影响 @@ -169,6 +146,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## 已知限制与延期工作 -- **`list` 只读取一层目录**:不提供递归和逐目录子项计数;offset 分页只遍历当前目录中按顺序排列的直接子项。 +- **未交付面向模型的目录列出工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 - **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 -- **没有超时接口**:`list`/`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 +- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 936d125013..737f7ac26b 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-fs", - "description": "Model-facing filesystem tools (list, read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", + "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 7890d5084d..a4c96d606b 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,19 +1,16 @@ /** - * Model-facing list, read, write, and edit tools over `ctx.fs`. This package owns schemas, - * validation, read windows, listing order, formatting, and observation events, never a concrete - * provider. An optional event policy supplies mutation guards; without one the tools use - * unconditional provider calls. + * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, + * read windows, formatting, and observation events, never a concrete provider. An optional + * event policy supplies mutation guards; without one the tools use unconditional provider calls. * @module @deepseek-ai/dsh-tool-fs */ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-user-approval' -import { applyListTool } from './list.ts' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' -import { LIST_MAX_ENTRIES } from './list-render.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxSurface } from './sandbox.ts' @@ -25,8 +22,6 @@ export const inject = ['tools', 'fs', 'systemPrompt'] /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { - /** Maximum entries one `list` page returns; the footer still reports the complete count. */ - listMaxEntries?: number /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -38,7 +33,6 @@ export interface Config { } export const Config: z = z.object({ - listMaxEntries: z.number().default(LIST_MAX_ENTRIES), readLimit: z.number().default(READ_LIMIT), readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH), readMaxBytes: z.number().default(READ_MAX_BYTES), @@ -48,23 +42,21 @@ export const Config: z = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required -/** Every read or listing cap counts lines/chars/bytes/entries — a positive integer, or windowing arithmetic misbehaves silently. */ +/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { throw new Error(`tool-fs: ${name} must be a positive integer`) } } -/** Register the full `list`/`read`/`write`/`edit` filesystem tool suite. */ +/** Register the full `read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig - assertPositiveInteger('listMaxEntries', resolved.listMaxEntries) assertPositiveInteger('readLimit', resolved.readLimit) assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength) assertPositiveInteger('readMaxBytes', resolved.readMaxBytes) assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize) - applyListTool(ctx, { maxEntries: resolved.listMaxEntries }) applyReadTool(ctx, { limit: resolved.readLimit, maxLineLength: resolved.readMaxLineLength, diff --git a/packages/fs/tool-fs/src/list-render.ts b/packages/fs/tool-fs/src/list-render.ts deleted file mode 100644 index 86b57d02b3..0000000000 --- a/packages/fs/tool-fs/src/list-render.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Pure directory-listing presentation: order direct children, count complete - * composition, and render a bounded page without allowing filesystem text to - * forge the result envelope. - * @module @deepseek-ai/dsh-tool-fs/list-render - */ - -/** Default and maximum number of entries one `list` call returns (the `listMaxEntries` config). */ -export const LIST_MAX_ENTRIES = 200 - -/** One direct child in a directory listing. */ -export interface ListedEntry { - /** Basename of the child inside the listed directory. */ - name: string - /** Whether the child is a regular file, a directory, or something else. */ - type: 'file' | 'directory' | 'other' -} - -/** Complete-listing composition retained on every page. */ -export interface ListCounts { - directories: number - files: number - other: number -} - -/** Canonical bounded result returned by one `list` call. */ -export interface ListPage { - /** Backend display path of the listed directory. */ - path: string - /** 1-based index of the first returned entry. */ - offset: number - /** Current page in directory-first, name-sorted order. */ - entries: ListedEntry[] - /** Number of direct children in the complete listing. */ - totalEntries: number - /** Composition of the complete listing, not only this page. */ - counts: ListCounts -} - -/** - * Sort directories before files before other entries, each group by name. - * @param entries - direct children in provider order. - * @returns a new directory-first array without mutating `entries`. - */ -export function orderEntries(entries: readonly T[]): T[] { - const rank = { directory: 0, file: 1, other: 2 } - return [...entries].sort((a, b) => rank[a.type] - rank[b.type] || a.name.localeCompare(b.name)) -} - -/** - * Count every entry type in a complete listing. - * @param entries - every direct child in the listed directory. - * @returns the complete directory/file/other composition. - */ -export function countEntries(entries: readonly ListedEntry[]): ListCounts { - const counts: ListCounts = { directories: 0, files: 0, other: 0 } - for (const entry of entries) { - if (entry.type === 'directory') counts.directories += 1 - else if (entry.type === 'file') counts.files += 1 - else counts.other += 1 - } - return counts -} - -/** `1 directory` / `4 directories`. */ -function count(n: number, singular: string, plural: string): string { - return `${n} ${n === 1 ? singular : plural}` -} - -/** Complete-listing composition as model-facing prose. */ -function breakdown(counts: ListCounts): string { - const parts = [ - count(counts.directories, 'directory', 'directories'), - count(counts.files, 'file', 'files'), - ] - if (counts.other > 0) parts.push(`${counts.other} other`) - return parts.join(', ') -} - -/** - * Names this renderer cannot emit verbatim, because POSIX allows every byte but - * `/` and NUL in a name and each of these would make the listing say something - * untrue: - * - * - a control character (a newline above all) splits one entry across lines; - * - ` 1 || page.entries.length < page.totalEntries - ? `(Showing entries ${page.offset}-${end} of ${page.totalEntries}: ${breakdown(page.counts)}.` - + (end < page.totalEntries ? ` Use offset=${end + 1} to continue.)` : ')') - : `(${count(page.totalEntries, 'entry', 'entries')}: ${breakdown(page.counts)})` - const body = page.entries.length > 0 - ? `${page.entries.map(entry => `${renderName(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}` - : footer - return `${renderName(page.path)} -directory - -${body} -` -} diff --git a/packages/fs/tool-fs/src/list.ts b/packages/fs/tool-fs/src/list.ts deleted file mode 100644 index 270e2803b8..0000000000 --- a/packages/fs/tool-fs/src/list.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Model-facing directory listing. It enumerates ONE directory level through the - * provider seam's `listDir`, orders children so a capped view keeps the - * navigable structure, and renders the entries with their type. - * - * This is the orientation tool: `glob` and `grep` answer "where is the thing I - * can already name", while `list` answers "what is here at all". `rg --files` - * never emits directories, so no pattern makes `glob` describe a directory's - * shape — the gap this tool closes. - * @module @deepseek-ai/dsh-tool-fs/list - */ - -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import { FsError } from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-system-prompt' -import { countEntries, formatListOutput, orderEntries } from './list-render.ts' -import { sessionResolveOptions } from './session-cwd.ts' - -/** Resolved list-tool caps — plugin config after defaulting (see `Config` in index.ts). */ -export interface ListToolCaps { - /** Maximum entries returned on one page; the footer still reports complete size and composition. */ - maxEntries: number -} - -/** Validated `list` arguments after defaulting. */ -export interface ListInput { - /** Directory to list; `.` means the calling agent's session workspace. */ - path: string - /** 1-based first entry to return from the directory-first ordering. */ - offset: number -} - -/** - * Validate value constraints the schema DSL can't express, and default an - * omitted `path` to `.` — the session workspace, so "what is in this project" - * needs no argument at all. - * - * @param args - the schema-validated `list` arguments. - * @returns the accepted input with `path` and `offset` defaulted. - */ -export function parseListArgs(args: { path?: string; offset?: number }): ListInput { - if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') - const offset = args.offset ?? 1 - if (!Number.isInteger(offset) || offset < 1) throw new Error('offset must be a positive integer') - return { path: args.path ?? '.', offset } -} - -/** - * Pending-call presentation: a generic card titled by the directory, with a - * follow-along location so a capable editor can reveal it. - * - * @param args - the raw tool arguments; `path` and `offset` feed the title. - * @returns the generic card view shown while the call runs. - */ -export function presentListCall(args: { path?: string; offset?: number }): GenericCallView { - const path = args.path ?? '.' - const window = args.offset !== undefined ? ` (from entry ${args.offset})` : '' - return { card: 'generic', title: `List ${path}${window}`, kind: 'read', locations: [{ path }] } -} - -/** - * Register the `list` tool and its system-prompt guidance. - * - * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. - * @param caps - the deployment's resolved list caps (plugin config after defaulting). - */ -export function applyListTool(ctx: Context, caps: ListToolCaps): void { - ctx.systemPrompt.section({ - name: 'tool:list', - order: 99, - text: 'Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, ' - + 'and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. ' - + 'When a result is capped, continue with the offset named in its footer.', - }) - - ctx.tools.register(defineTool({ - name: 'list', - description: 'List the direct children of one directory, with their type. ' - + `Entries are directories first, then files, each alphabetical; up to ${caps.maxEntries} are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. ` - + 'It includes subdirectories and is the tool for seeing one directory\'s contents.', - parameters: { - path: { type: 'string', description: 'Directory to list. Defaults to the session workspace; a relative path resolves against it.' }, - offset: { type: 'number', description: '1-based first entry to return. Defaults to 1; use the footer value to continue.' }, - }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string', required: true }, - offset: { type: 'integer', required: true }, - entries: { - type: 'array', - required: true, - items: { - type: 'object', - additionalProperties: false, - properties: { - name: { type: 'string', required: true }, - type: { type: 'string', required: true, enum: ['file', 'directory', 'other'] }, - }, - }, - }, - totalEntries: { type: 'integer', required: true }, - counts: { - type: 'object', - required: true, - additionalProperties: false, - properties: { - directories: { type: 'integer', required: true }, - files: { type: 'integer', required: true }, - other: { type: 'integer', required: true }, - }, - }, - }, - }, - render: (_args, value) => [{ type: 'text', text: formatListOutput(value) }], - }, - // Listing reads directory metadata only: no content, no version recorded, - // nothing a concurrent call could observe out of order. - isConcurrencySafe: () => true, - async execute(args, exec) { - const input = parseListArgs(args) - const target = await ctx.fs.resolve(input.path, sessionResolveOptions(exec, input.path)) - // No stat first: the seam already answers absence with FS_NOT_FOUND and a - // non-directory target with FS_NOT_DIRECTORY, so a probe would only add a - // round-trip and a second source of truth. (0 stat.) - const entries = orderEntries(await ctx.fs.listDir(target, exec.signal)) - if (input.offset > entries.length && !(entries.length === 0 && input.offset === 1)) { - throw new FsError( - `offset ${input.offset} is out of range for "${target.displayPath}" (${entries.length} entries)`, - 'FS_NOT_FOUND', - ) - } - return { - path: target.displayPath, - offset: input.offset, - entries: entries - .slice(input.offset - 1, input.offset - 1 + caps.maxEntries) - .map(({ name, type }) => ({ name, type })), - totalEntries: entries.length, - counts: countEntries(entries), - } - }, - presentCall: presentListCall, - })) -} diff --git a/packages/fs/tool-fs/tests/list-render.spec.ts b/packages/fs/tool-fs/tests/list-render.spec.ts deleted file mode 100644 index 792c91bd18..0000000000 --- a/packages/fs/tool-fs/tests/list-render.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Pure listing-presentation tests: display ordering and the model-facing - * envelope, exercised without a context or provider. - */ - -import { describe, expect, it } from 'vitest' -import { countEntries, formatListOutput, orderEntries } from '../src/list-render.ts' -import type { ListedEntry, ListPage } from '../src/list-render.ts' - -const entry = (name: string, type: ListedEntry['type'] = 'file'): ListedEntry => ({ name, type }) - -function page(entries: ListedEntry[], options: { offset?: number; totalEntries?: number; all?: ListedEntry[] } = {}): ListPage { - const all = options.all ?? entries - return { - path: '/w', - offset: options.offset ?? 1, - entries, - totalEntries: options.totalEntries ?? all.length, - counts: countEntries(all), - } -} - -describe('orderEntries', () => { - it('groups directories, then files, then other, each by name', () => { - const ordered = orderEntries([ - entry('zeta.txt'), - entry('socket', 'other'), - entry('beta'), - entry('src', 'directory'), - entry('assets', 'directory'), - ]) - expect(ordered.map(e => e.name)).toEqual(['assets', 'src', 'beta', 'zeta.txt', 'socket']) - }) - - it('leaves the input array untouched and preserves extra entry fields', () => { - const input = [{ name: 'b', type: 'file' as const, size: 2 }, { name: 'a', type: 'file' as const, size: 1 }] - const ordered = orderEntries(input) - expect(input.map(e => e.name)).toEqual(['b', 'a']) - expect(ordered).toEqual([{ name: 'a', type: 'file', size: 1 }, { name: 'b', type: 'file', size: 2 }]) - }) -}) - -describe('formatListOutput', () => { - it('marks directories and non-regular children, and counts the whole listing', () => { - expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`/w -directory - -src/ -a.txt -sock@ - -(3 entries: 1 directory, 1 file, 1 other) -`) - }) - - it('omits the "other" clause when every child is a file or a directory', () => { - expect(formatListOutput(page([entry('a.txt'), entry('b.txt')]))).toContain('(2 entries: 0 directories, 2 files)') - }) - - it('says a one-entry listing in the singular', () => { - expect(formatListOutput(page([entry('only', 'directory')]))).toContain('(1 entry: 1 directory, 0 files)') - }) - - it('states the complete size and composition when the view is capped', () => { - const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))] - const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries })) - expect(rendered).toContain('src/\nf0.txt\n') - expect(rendered).not.toContain('f2.txt') - expect(rendered).toContain('(Showing entries 1-2 of 6: 1 directory, 5 files. Use offset=3 to continue.)') - }) - - it('renders an empty directory as a footer alone', () => { - expect(formatListOutput(page([]))).toBe(`/w -directory - -(Empty directory) -`) - }) -}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 53b58d2dcf..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -38,7 +38,6 @@ const testToolSignal = new AbortController().signal class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError - dirs = new Map() writeIntents: (FsWriteIntent | undefined)[] = [] editIntents: ({ version: FsVersion } | undefined)[] = [] @@ -67,9 +66,8 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async listDir(target: FsTarget): Promise { - this.throwIfArmed() - return this.dirs.get(target.targetKey) ?? [] + override async listDir(_target: FsTarget): Promise { + return [] } override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() @@ -141,15 +139,13 @@ describe('session cwd resolution', () => { }) describe('registration', () => { - it('registers list, read, write, and edit', async () => { + it('registers read, write, and edit', async () => { const { ctx } = await setup() - expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'list', 'read', 'write']) + expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) }) - it('declares list and read parallel-safe while write/edit remain exclusive', async () => { + it('declares read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('list-safe'), name: 'list', arguments: {} })) - .toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) @@ -161,8 +157,6 @@ describe('registration', () => { it('registers prompt sections for each tool', async () => { const { ctx } = await setup() const prompt = renderPrompt(await ctx.systemPrompt.assemble()) - expect(prompt).toContain('Use the list tool') - expect(prompt).not.toContain('glob or grep') expect(prompt).toContain('Use the read tool') expect(prompt).toContain('Use the write tool') expect(prompt).toContain('Use the edit tool') @@ -185,10 +179,9 @@ describe('registration', () => { const fiber = await ctx.plugin(ToolFs) // Each tool contributes BOTH a schema and a prompt section; disposal must // withdraw both, not just the schemas. - expect(ctx.tools.schemas()).toHaveLength(4) + expect(ctx.tools.schemas()).toHaveLength(3) const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() - expect(sectionNames(await ctx.systemPrompt.assemble())) - .toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:list', 'tool:read', 'tool:write']) + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) // Only the system-prompt plugin's own built-in sections remain. @@ -196,144 +189,6 @@ describe('registration', () => { }) }) -describe('list tool', () => { - /** Seed one directory's children; `listDir` order is deliberately NOT display order. */ - function seedDir(fs: FakeFs, path: string, children: readonly { name: string; type: 'file' | 'directory' | 'other' }[]): void { - fs.dirs.set(`key:${path}`, children.map(({ name, type }) => ({ - name, - type, - target: { targetKey: FsTargetKey(`key:${path}/${name}`), displayPath: `/abs/${path}/${name}` }, - }))) - } - - it('defaults to the session workspace and shows directories before files', async () => { - const { ctx, fs } = await setup() - seedDir(fs, '.', [ - { name: 'notes.md', type: 'file' }, - { name: 'zeroomega-3.3.23', type: 'directory' }, - { name: 'archive', type: 'directory' }, - { name: 'link-to-nowhere', type: 'other' }, - ]) - const result = await call(ctx, 'list', {}) - expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected list success') - // The canonical value carries display order, so a Code Mode caller and the - // model see the same ordering contract. - expect(result.value).toEqual({ - path: '/abs/.', - offset: 1, - entries: [ - { name: 'archive', type: 'directory' }, - { name: 'zeroomega-3.3.23', type: 'directory' }, - { name: 'notes.md', type: 'file' }, - { name: 'link-to-nowhere', type: 'other' }, - ], - totalEntries: 4, - counts: { directories: 2, files: 1, other: 1 }, - }) - expect(text(result)).toBe(`/abs/. -directory - -archive/ -zeroomega-3.3.23/ -notes.md -link-to-nowhere@ - -(4 entries: 2 directories, 1 file, 1 other) -`) - }) - - it('lists an explicit path and reports an empty directory as such', async () => { - const { ctx, fs } = await setup() - seedDir(fs, 'empty', []) - const result = await call(ctx, 'list', { path: 'empty' }) - expect(text(result)).toContain('(Empty directory)') - expect(text(result)).toContain('/abs/empty') - }) - - it('caps the rendered entries but still reports the complete composition', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(FakeFs) - await ctx.plugin(ToolFs, { listMaxEntries: 2 }) - const fs = ctx.fs as FakeFs - seedDir(fs, '.', [ - { name: 'a.txt', type: 'file' }, - { name: 'b.txt', type: 'file' }, - { name: 'c.txt', type: 'file' }, - { name: 'src', type: 'directory' }, - ]) - const result = await call(ctx, 'list', {}) - const rendered = text(result) - // The one directory survives the cap because directories sort first — the - // failure mode this ordering exists to prevent. - expect(rendered).toContain('src/\na.txt\n') - expect(rendered).not.toContain('c.txt') - expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)') - if (result.isError) throw new Error('expected list success') - expect(result.value).toEqual({ - path: '/abs/.', - offset: 1, - entries: [{ name: 'src', type: 'directory' }, { name: 'a.txt', type: 'file' }], - totalEntries: 4, - counts: { directories: 1, files: 3, other: 0 }, - }) - - const continuation = await call(ctx, 'list', { offset: 3 }) - expect(text(continuation)).toContain('b.txt\nc.txt') - expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)') - }) - - it('rejects a blank path and surfaces provider failures', async () => { - const { ctx, fs } = await setup() - const blank = await call(ctx, 'list', { path: ' ' }) - expect(blank.isError).toBe(true) - expect(text(blank)).toContain('path must be a non-empty string when given') - - fs.rejectWith = new FsError('cannot list "/abs/a.txt": not a directory', 'FS_NOT_DIRECTORY') - const failed = await call(ctx, 'list', { path: 'a.txt' }) - expect(failed.isError).toBe(true) - expect(failed.error).toMatchObject({ info: { code: 'FS_NOT_DIRECTORY' } }) - }) - - it('rejects invalid and out-of-range continuation offsets', async () => { - const { ctx, fs } = await setup() - seedDir(fs, '.', [{ name: 'only.txt', type: 'file' }]) - expect(text(await call(ctx, 'list', { offset: 0 }))).toContain('offset must be a positive integer') - expect(text(await call(ctx, 'list', { offset: 1.5 }))).toContain('offset must be a positive integer') - expect(text(await call(ctx, 'list', { offset: 2 }))).toContain('offset 2 is out of range') - }) - - it('encodes filesystem names without allowing them to forge the envelope or type marker', async () => { - const { ctx, fs } = await setup() - seedDir(fs, '.', [ - { name: 'regular@', type: 'file' }, - { name: 'special', type: 'other' }, - { name: 'fake\n', type: 'file' }, - ]) - const rendered = text(await call(ctx, 'list', {})) - // A regular file really named `regular@` must not read as a socket named - // `regular`, and a newline in a name must not become a second entry. - expect(rendered).toContain('"regular@"\nspecial@') - expect(rendered).toContain('"fake\\n<\\/content>"') - expect(rendered.match(/<\/content>/g)).toHaveLength(1) - }) - - it('records no observation, so a listing never authorizes a mutation', async () => { - const { ctx, fs } = await setup() - fs.files.set('key:a.txt', 'hello') - seedDir(fs, '.', [{ name: 'a.txt', type: 'file' }]) - const observed = vi.fn() - ctx.on('fs/observed', observed) - await call(ctx, 'list', {}) - expect(observed).not.toHaveBeenCalled() - // Seeing a name is not reading a file: the policy gate still demands a read. - const edit = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'h', new_string: 'j' }, { session: { header: {} } }) - expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) - }) -}) - describe('read tool', () => { it('formats line-numbered content with a footer', async () => { const { ctx, fs } = await setup() @@ -589,18 +444,6 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) - it('list: titles by the directory, falling back to the workspace "." when unset', async () => { - expect(await presentCall('list', { path: 'src' })).toEqual({ - card: 'generic', title: 'List src', kind: 'read', locations: [{ path: 'src' }], - }) - expect(await presentCall('list', {})).toEqual({ - card: 'generic', title: 'List .', kind: 'read', locations: [{ path: '.' }], - }) - expect(await presentCall('list', { path: 'src', offset: 201 })).toEqual({ - card: 'generic', title: 'List src (from entry 201)', kind: 'read', locations: [{ path: 'src' }], - }) - }) - it('read: bare title and line-1 location when offset/limit are unset', async () => { expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({ card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], @@ -813,7 +656,6 @@ describe('read caps are plugin config', () => { }) it.each([ - ['listMaxEntries', { listMaxEntries: 0 }], ['readLimit', { readLimit: 0 }], ['readLimit', { readLimit: 2.5 }], ['readMaxLineLength', { readMaxLineLength: -1 }], diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index c507ae0e0d..7bdc8b68a8 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -230,7 +230,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', @@ -248,7 +248,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-pty', From 6b79ce08c580c1921f545ac447b543cbc3970893 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 16:21:29 +0800 Subject: [PATCH 07/97] test(fs-search): minimize glob snapshot composition Boot the glob sampling scenario from a standalone ACP composition that exposes only bash, glob, and grep. Regenerate the smaller header fixtures and trim implementation narration already owned by the Agent Note. --- .../2026-07-27-glob-sampling.i18n.yaml | 4 +- .../bug-fix/2026-07-27-glob-sampling.md | 2 +- .../bug-fix/2026-07-27-glob-sampling.zh.md | 2 +- .../acp-agent/fs-search.cordis.snapshot.yml | 24 - examples/acp-agent/fs-search.cordis.yml | 13 - examples/acp-agent/tests/acp.snapshot.ts | 2 +- .../tests/fs-search.cordis.snapshot.yml | 34 ++ examples/acp-agent/tests/fs-search.cordis.yml | 32 ++ .../system-prompt.expected.md | 22 +- .../tool-schemas.expected.json | 437 +----------------- packages/fs/tool-fs-search/src/glob.ts | 35 +- 11 files changed, 78 insertions(+), 529 deletions(-) delete mode 100644 examples/acp-agent/fs-search.cordis.snapshot.yml delete mode 100644 examples/acp-agent/fs-search.cordis.yml create mode 100644 examples/acp-agent/tests/fs-search.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/fs-search.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml index 88e4ec9fc8..7c11db5207 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.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 .agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md -2026-07-27-glob-sampling.md: c583f0cd8110684a94d0cd04f5cf2ae861ce7aa3 -2026-07-27-glob-sampling.zh.md: 97025a7714237fc716dee745a858140cd783ba2d +2026-07-27-glob-sampling.md: b9dfc7fc89ff61824094ccd5297f766c665f4edb +2026-07-27-glob-sampling.zh.md: 4f022467e461247c746e906b32f5ecffcb885d56 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md index c583f0cd81..b9dfc7fc89 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.md @@ -42,4 +42,4 @@ The tool surface does not grow. The fix changes glob's prompt, schema descriptio ## Testing -Package tests pin concentrated and flat results, explicit roots, more groups than the JavaScript argument limit, exhausted groups, fewer slots than groups, and paths outside the workdir. The `fs-glob-sampling` ACP scenario boots the real Loader/app/sandbox-bash composition and executes the real search plugin against a deterministic `rg` process fixture; its result spans four top-level entries instead of returning one subtree's head. +Package tests pin concentrated and flat results, explicit roots, more groups than the JavaScript argument limit, exhausted groups, fewer slots than groups, and paths outside the workdir. The `fs-glob-sampling` ACP scenario boots a minimal real Loader/app/local-bash composition and executes the real search plugin against a deterministic `rg` process fixture; its result spans four top-level entries instead of returning one subtree's head. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md index 97025a7714..4f022467e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-glob-sampling.zh.md @@ -42,4 +42,4 @@ footer 会说明当前页面是跨条目的样本,而不是按修改时间排 ## 测试 -包测试锁定了结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会启动真实的 Loader/app/sandbox-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 +包测试锁定了结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会启动最小化的真实 Loader/app/local-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 diff --git a/examples/acp-agent/fs-search.cordis.snapshot.yml b/examples/acp-agent/fs-search.cordis.snapshot.yml deleted file mode 100644 index 1541cd96ce..0000000000 --- a/examples/acp-agent/fs-search.cordis.snapshot.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Keyless counterpart to fs-search.cordis.yml: the search plugin and sandboxed -# bash execution remain real; only the model adapter is replaced by replay. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek - name: DeepSeek - models: - - id: deepseek-v4-flash - - id: deepseek-v4-pro - - id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - globMaxResults: 4 diff --git a/examples/acp-agent/fs-search.cordis.yml b/examples/acp-agent/fs-search.cordis.yml deleted file mode 100644 index fd1eec2a38..0000000000 --- a/examples/acp-agent/fs-search.cordis.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Search snapshot composition: mount the real model-facing search plugin over -# the shipped sandboxed bash stack. A four-path inline cap keeps the fixture -# small while forcing the over-cap sampling branch. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - insert: - - id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - globMaxResults: 4 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9af14d850f..28e814a258 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -42,7 +42,7 @@ const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.ur const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) -const FS_SEARCH_CONFIG = fileURLToPath(new URL('../fs-search.cordis.yml', import.meta.url)) +const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml new file mode 100644 index 0000000000..e1755ec9d4 --- /dev/null +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -0,0 +1,34 @@ +# Minimal keyless composition: real app, bash, and search tool; replayed model. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + globMaxResults: 4 diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml new file mode 100644 index 0000000000..171ab88980 --- /dev/null +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -0,0 +1,32 @@ +# Minimal live counterpart for the glob-sampling snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + globMaxResults: 4 diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md index 467b05904b..eb22966cdb 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md @@ -1,29 +1,9 @@ You are an AI agent powered by the DeepSeek Harness SDK. -You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - -Verify your work by running the code or tests. Keep answers brief and factual. - - -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. +You are a concise snapshot agent working in {{cwd}}. Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree. Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. Check the [exit code: N] marker on every bash result; investigate failures before moving on. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). - - -Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json index 1b25291c5c..4cb292798a 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -25,18 +25,6 @@ "run_in_background": { "type": "boolean", "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." } }, "required": [ @@ -45,76 +33,6 @@ ] } }, - { - "name": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace. Must match exactly." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, { "name": "glob", "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 4 paths come back in modification-time order; a larger result instead returns 4 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", @@ -158,359 +76,6 @@ "pattern" ] } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." - }, - "meta": { - "type": "object", - "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } } ], "changes": [] diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 94d9b0e627..12af97a24b 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -5,13 +5,6 @@ * model-facing schema, argument validation, shell-safe command construction, * result parsing, inline sampling, and formatting; process concerns (defaulting, * scrubbing, kill, backend substitution) stay behind `ctx.bash`. - * - * A complete result keeps ripgrep's modification-time order. A result too large - * to show inline does NOT: its inline page is sampled across the complete - * result's top-level entries ({@link sampleAcrossTopLevel}), because the sorted - * head of a broad match is routinely one subtree's worth of files and reads as - * if the workspace held nothing else. - * * @module @deepseek-ai/dsh-tool-fs-search/glob */ @@ -145,19 +138,9 @@ function topLevelSegment(path: string): string { * Choose the inline page of an over-cap result by round-robin across the * complete result's top-level entries, instead of taking its head. * - * `--sort=modified` (oldest first) is the right order for a complete result and - * the wrong basis for a sample of one: a broad pattern in a workspace holding one - * unpacked archive — whose restored timestamps predate everything the user - * wrote — gives a head that is entirely that subtree, and the model reads the - * page as the workspace. Round-robin gives every top-level entry a slot before - * any entry gets a second, so the page spans the tree; an entry that runs out of - * paths drops out and its remaining slots go to the rest. - * - * Modification-time order survives where it still means something: groups are - * visited in the order ripgrep first emits them, and each group's own paths keep - * their relative order. With one path per group — a flat result — this - * reproduces the sorted head exactly, so nothing changes for a result that has - * no subtree to hide. + * Every top-level entry receives a slot before any receives a second; exhausted + * groups drop out. Group order and order within each group follow `paths`, so a + * flat result reproduces the modification-time head. * * @param paths - the complete result, in ripgrep's modification-time order. * @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`. @@ -191,16 +174,8 @@ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, } /** - * Format a CAPPED `glob` result: the inline page, then a footer stating that - * the page is a cross-directory sample rather than the most recent paths, how - * much of the top level it reaches, and either the formatted-spill recovery - * locator or the could-not-save explanation. The omitted count is a budget - * fact: the search itself completed. A result that fits inline never reaches - * here — it is emitted verbatim, in ripgrep's order. - * - * A result whose every path is its own top-level entry keeps the plain footer: - * the sample is the modification-time-ordered head, and naming a spread would only - * restate the path counts already there. + * Format a capped sampled page and its complete-result recovery path. A flat + * result keeps the plain footer because its sample is the modification-time head. * * @param sample - the inline page and its top-level spread. * @param seen - how many paths the complete result holds; always more than the page. From ec0786e099e487526785e4bdf870868ed640e9aa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 17:30:12 +0800 Subject: [PATCH 08/97] feat(settings): add user-settings seam (ctx.settings) + file provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-package capability family mirroring session-persistence/: - dsh-settings: abstract Settings service — namespace registry with caller-fiber effect registrations, layered resolution (schema defaults < composition base < user document), schemastery validation, per-namespace deep-equal commit detection, and the settings/updated event. Boot/registration validation fails loud; provider publishes keep last-good per namespace. - dsh-settings-local: settings.yaml/.json provider — resolveSpec defaulting to $DSH_HOME/settings.yaml, chokidar hot reload, content-equality self-write suppression, atomic 0600 tmp+rename writes, comment-preserving YAML namespace patching. Consumers register inside ctx.inject(['settings'], …), so every composition works unchanged without a mounted provider. Real Loader + Include composition test proves cordis.yml boot and external-edit hot propagation; HMR disposal test proves registry cleanup. Both packages hold per-file 100% coverage. Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md): one structural line per file for the new package group. Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md --- .../2026-07-28-user-settings-seam.i18n.yaml | 6 + .../2026-07-28-user-settings-seam.md | 35 ++ .../2026-07-28-user-settings-seam.zh.md | 35 ++ AGENTS.md | 1 + docs/capability-seams.md | 6 + docs/config-catalog.md | 19 ++ docs/cordis-catalog/events.md | 22 ++ docs/cordis-catalog/services.md | 42 +++ docs/event-producer-consumer.md | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 49 +++ packages/settings/README.i18n.yaml | 6 + packages/settings/README.md | 12 + packages/settings/README.zh.md | 12 + .../settings/settings-local/README.i18n.yaml | 6 + packages/settings/settings-local/README.md | 36 ++ packages/settings/settings-local/README.zh.md | 36 ++ packages/settings/settings-local/package.json | 46 +++ packages/settings/settings-local/src/index.ts | 226 +++++++++++++ .../settings/settings-local/src/invariant.ts | 31 ++ .../tests/loader-composition.spec.ts | 112 +++++++ .../settings-local/tests/local.spec.ts | 254 +++++++++++++++ .../settings-local/tests/watcher.spec.ts | 118 +++++++ .../settings/settings-local/tsconfig.json | 30 ++ packages/settings/settings/README.i18n.yaml | 6 + packages/settings/settings/README.md | 35 ++ packages/settings/settings/README.zh.md | 35 ++ packages/settings/settings/package.json | 41 +++ packages/settings/settings/src/index.ts | 304 +++++++++++++++++ packages/settings/settings/src/invariant.ts | 41 +++ .../settings/settings/tests/invariant.spec.ts | 41 +++ packages/settings/settings/tests/memory.ts | 53 +++ .../settings/settings/tests/settings.spec.ts | 307 ++++++++++++++++++ packages/settings/settings/tsconfig.json | 27 ++ pnpm-lock.yaml | 40 +++ scripts/doc-budgets.manifest.json | 4 +- scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 43 files changed, 2098 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md create mode 100644 packages/settings/README.i18n.yaml create mode 100644 packages/settings/README.md create mode 100644 packages/settings/README.zh.md create mode 100644 packages/settings/settings-local/README.i18n.yaml create mode 100644 packages/settings/settings-local/README.md create mode 100644 packages/settings/settings-local/README.zh.md create mode 100644 packages/settings/settings-local/package.json create mode 100644 packages/settings/settings-local/src/index.ts create mode 100644 packages/settings/settings-local/src/invariant.ts create mode 100644 packages/settings/settings-local/tests/loader-composition.spec.ts create mode 100644 packages/settings/settings-local/tests/local.spec.ts create mode 100644 packages/settings/settings-local/tests/watcher.spec.ts create mode 100644 packages/settings/settings-local/tsconfig.json create mode 100644 packages/settings/settings/README.i18n.yaml create mode 100644 packages/settings/settings/README.md create mode 100644 packages/settings/settings/README.zh.md create mode 100644 packages/settings/settings/package.json create mode 100644 packages/settings/settings/src/index.ts create mode 100644 packages/settings/settings/src/invariant.ts create mode 100644 packages/settings/settings/tests/invariant.spec.ts create mode 100644 packages/settings/settings/tests/memory.ts create mode 100644 packages/settings/settings/tests/settings.spec.ts create mode 100644 packages/settings/settings/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml new file mode 100644 index 0000000000..cc409d8403 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md +2026-07-28-user-settings-seam.md: f0f45d77c8f98fc15625b1a1bf116ec10b965676 +2026-07-28-user-settings-seam.zh.md: eb562099b1ff0b5b0a019e9956d6e36e71857236 diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md new file mode 100644 index 0000000000..f0f45d77c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md @@ -0,0 +1,35 @@ +# Agent Note: user-settings seam (`ctx.settings`) and the file provider + +Status: implemented + +English | [中文](2026-07-28-user-settings-seam.zh.md) + +> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope. + +## Problem + +User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise. + +## Decision + +**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section. + +**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `/settings.yaml`), chokidar watch, atomic `0600` tmp+rename writes, comment-preserving YAML patching of exactly one namespace key, and content-equality self-write suppression. + +**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner. + +**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload. + +**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin. + +## Alternatives considered + +- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document. +- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic. +- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema. +- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend. +- **A cross-process lockfile now** (Pi's proper-lockfile): atomic replace plus watcher convergence (last write wins) is documented behavior until real contention shows up. + +## Consequences + +Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step. diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md new file mode 100644 index 0000000000..eb562099b1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md @@ -0,0 +1,35 @@ +# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider + +Status: implemented + +[English](2026-07-28-user-settings-seam.md) | 中文 + +> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。 + +## 问题 + +用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。 + +## 决策 + +**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。 + +**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `/settings.yaml` 的 YAML/JSON、chokidar 监听、`0600` tmp+rename 原子写、只修补目标 namespace 键的保注释 YAML 写回、按内容相等抑制自写。 + +**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。 + +**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。 + +**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。 + +## Alternatives considered + +- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。 +- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。 +- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。 +- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。 +- **现在就上跨进程锁**(Pi 的 proper-lockfile):原子替换加 watcher 收敛(后写胜出)是已记录的行为,真实冲突出现再说。 + +## 后果 + +按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。 diff --git a/AGENTS.md b/AGENTS.md index c987801c17..7112dd3aee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// 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 + settings/ user-settings seam + file-backed provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9093f41f15..74f7c63c0d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -35,6 +35,9 @@ flowchart LR pkg_tool_bash["tool-bash"] pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] + pkg_settings["settings"] + svc_settings["ctx.settings
User-settings seam"] + pkg_settings_local["settings-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -188,6 +191,8 @@ flowchart LR pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle + pkg_settings --> svc_settings + pkg_settings_local --> svc_settings pkg_skill --> svc_skills pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore @@ -316,6 +321,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dcb4fa69e7..1305dd7270 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1177,6 +1177,24 @@ Depends on: [`SessionTitleLlmConfig`](../packages/session-title/session-title-ll Source: [`packages/session-title/session-title-first-message-llm/src/index.ts:15`](../packages/session-title/session-title-first-message-llm/src/index.ts) +## `@deepseek-ai/dsh-settings-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/settings/settings-local/src/index.ts:18`](../packages/settings/settings-local/src/index.ts) + ## `@deepseek-ai/dsh-skill` ```ts config-catalog @@ -2192,6 +2210,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/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)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-settings` — abstract `Settings` ([`packages/settings/settings/src/index.ts`](../packages/settings/settings/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ca0e9b82fc..aea04f4acd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -640,6 +640,28 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) +## `settings/*` + +### `settings/updated` — emit + +Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. + +```ts cordis-catalog +/** + * Committed change to one registered namespace's resolved value. Emitted + * after the provider persisted (for `update`) or published (`provider`) + * the change; never emitted when the resolved value is deep-equal. + * @param ns - the namespace whose resolved value changed. + * @param next - the new resolved value. + * @param prev - the previous resolved value. + * @param source - whether the change entered through `update()` or the provider. + * @mode emit + */ +'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void +``` + +Source: [`packages/settings/settings/src/index.ts:90`](../../packages/settings/settings/src/index.ts) + ## `slash/*` ### `slash/input-begin-command` — bail diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 19d7d7765d..a0271083d6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1388,6 +1388,48 @@ Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](. Source: [`packages/session-title/session-title/src/index.ts:232`](../../packages/session-title/session-title/src/index.ts) +## `ctx.settings` — `Settings` (abstract seam) + +Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. + +```ts cordis-catalog +/** + * Register a namespace schema and receive its owner scope. The registration + * is an effect on the calling plugin's fiber: disposing that fiber removes + * the namespace and its observers. An invalid stored section fails the + * registration itself — the earliest point where the schema can judge it. + * @param ns - unique namespace; duplicate registration fails loud. + * @param schema - schemastery schema resolving this namespace's value. + * @param options - composition `base` layer and effect timing. + * @returns the owner scope for reads, observation, and updates. + */ +register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope + +/** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ +describe(): SettingsDescriptor[] + +/** + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @returns the resolved value, or `undefined` while unregistered. + */ +get(ns: SettingsNamespace): unknown + +/** + * Merge a patch into one registered namespace's user layer, validate the + * resolved candidate, persist through the provider, then commit and emit. + * A validation failure rejects before anything is persisted. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ +async update(ns: SettingsNamespace, patch: object): Promise +``` + +Source: [`packages/settings/settings/src/index.ts:140`](../../packages/settings/settings/src/index.ts) + ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e9e5bb6e6a..49bdf3fe35 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,6 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:90`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5dd168f03e..360687f197 100644 --- a/packages/README.i18n.yaml +++ b/packages/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 packages/README.md -README.md: d16e395a42e491461c0862227205931894c27e39 -README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f +README.md: 7b415bdbbd3e442c952da49b6f9f8823a636f643 +README.zh.md: d3347ecd6db68d99bc00e340c632da51cf825c63 diff --git a/packages/README.md b/packages/README.md index d16e395a42..7b415bdbbd 100644 --- a/packages/README.md +++ b/packages/README.md @@ -37,6 +37,7 @@ Packages live at `packages///`; groups are containers, while names r | [`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, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`settings/`](settings/README.md) | User-settings capability family: the seam + file-backed provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 3fb4181ce7..d3347ecd6d 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -37,6 +37,7 @@ | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`settings/`](settings/README.md) | 用户设置能力族:seam + 文件 provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 47f8a1a54f..81e3c79d05 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -666,6 +666,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'settings', + summary: 'Abstract settings service.', + methods: [ + { + signature: 'register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope', + jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */', + }, + { + signature: 'describe(): SettingsDescriptor[]', + jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */', + }, + { + signature: 'get(ns: SettingsNamespace): unknown', + jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */', + }, + { + signature: 'async update(ns: SettingsNamespace, patch: object): Promise', + jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */', + }, + ], + }, { key: 'skills', summary: 'Registry of skill providers.', @@ -1188,6 +1210,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, + { + name: 'settings/updated', + mode: 'emit', + signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void', + jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */', + summary: 'Committed change to one registered namespace\'s resolved value.', + }, { name: 'slash/input-begin-command', mode: 'bail', @@ -2181,6 +2210,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleUserMessage', declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}', }, + { + name: 'SettingsApplies', + declaration: 'export type SettingsApplies = \'live\' | \'restart\';', + }, + { + name: 'SettingsDescriptor', + declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}', + }, + { + name: 'SettingsNamespace', + declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', + }, + { + name: 'SettingsRegisterOptions', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', + }, + { + name: 'SettingsScope', + declaration: 'export interface SettingsScope {\n get(): T;\n watch(callback: (next: T, prev: T) => void): () => void;\n update(patch: object): Promise;\n}', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/settings/README.i18n.yaml b/packages/settings/README.i18n.yaml new file mode 100644 index 0000000000..7c78505615 --- /dev/null +++ b/packages/settings/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/settings/README.md +README.md: 7a91355dd01805938944f0abce77765021288e6d +README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0 diff --git a/packages/settings/README.md b/packages/settings/README.md new file mode 100644 index 0000000000..7a91355dd0 --- /dev/null +++ b/packages/settings/README.md @@ -0,0 +1,12 @@ +# settings/ — user-settings capability family + +English | [中文](README.zh.md) + +The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` | +| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) | + +The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document. diff --git a/packages/settings/README.zh.md b/packages/settings/README.zh.md new file mode 100644 index 0000000000..2df40b67eb --- /dev/null +++ b/packages/settings/README.zh.md @@ -0,0 +1,12 @@ +# settings/ — 用户设置能力族 + +[English](README.md) | 中文 + +用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。 + +| 包 | 角色 | ctx key | +|---|---|---| +| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` | +| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) | + +接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。 diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml new file mode 100644 index 0000000000..f7ca57e86e --- /dev/null +++ b/packages/settings/settings-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/settings/settings-local/README.md +README.md: 90428f98055d8e49fa1ec54e571453db2e0a5054 +README.zh.md: 3532d6cee99cb46f54e23889ef1bb54b2548ecfa diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md new file mode 100644 index 0000000000..90428f9805 --- /dev/null +++ b/packages/settings/settings-local/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-settings-local + +English | [中文](README.zh.md) + +File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded. + +## Config + +| Field | Meaning | Default | +|---|---|---| +| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home | +| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` | +| `watch` | Watch the document and hot-publish external edits | `true` | +| `debounceMs` | Watcher write-settle window in milliseconds | `100` | + +Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load. + +## Behavior + +- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. +- **Write-back is atomic and owner-only.** `persist` writes `.tmp` with mode `0600` and renames over the target. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. +- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. + +## Model Experience + +Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect. + +#### KV Cache effect + +No direct invalidation; the consuming plugin owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up. +- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting. +- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md new file mode 100644 index 0000000000..3532d6cee9 --- /dev/null +++ b/packages/settings/settings-local/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-settings-local + +[English](README.md) | 中文 + +文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。 + +## 配置 + +| 字段 | 含义 | 默认 | +|---|---|---| +| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` | +| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` | +| `watch` | 监听文档并热发布外部编辑 | `true` | +| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` | + +默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。 + +## 行为 + +- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 +- **写回原子且仅属主可读。** `persist` 以 `0600` 权限写 `.tmp` 后 rename 覆盖目标。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 +- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 + +## Model Experience + +间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;请求前缀的变更由消费插件拥有。 + +## Known Limitations and Deferred Work + +- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。 +- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。 +- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json new file mode 100644 index 0000000000..aefb1ccd33 --- /dev/null +++ b/packages/settings/settings-local/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-settings-local", + "description": "File-backed settings provider (settings.yaml) 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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "schemastery": "^3.18.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts new file mode 100644 index 0000000000..421411ea50 --- /dev/null +++ b/packages/settings/settings-local/src/index.ts @@ -0,0 +1,226 @@ +/** + * File-backed settings provider. One YAML or JSON document under the user's + * harness home carries every namespace section; external edits hot-publish + * through the seam and `update()` writes back preserving the user's comments. + * @module @deepseek-ai/dsh-settings-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { Document, parseDocument } from 'yaml' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Settings document path; defaults to `settings.yaml` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Document format derived from the configured file extension. */ +type SettingsFormat = 'yaml' | 'json' + +const FORMATS: Record = { + '.yaml': 'yaml', + '.yml': 'yaml', + '.json': 'json', +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + format: SettingsFormat + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/settings.yaml`. + * @param config - raw plugin config. + * @returns the resolved file location, format, and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml')) + const format = FORMATS[extname(filename)] + if (format === undefined) { + throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`) + } + return { + filename, + format, + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** File-backed settings provider (`settings.yaml`/`.json`). */ +export class SettingsLocal extends Settings { + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last successfully parsed or persisted document; + * `undefined` while the file is absent. Watcher events whose content equals + * this cache are no-ops, which is also the self-write suppression. + */ + private text: string | undefined + /** Serializes watcher-triggered reloads so reads never interleave. */ + private refreshTask: Promise = Promise.resolve() + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + /** The local document is always writable through {@link Settings.update}. */ + get writable(): boolean { + return true + } + + protected async load(): Promise> { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + this.text = undefined + return {} + } + const doc = this.parse(text) + this.text = text + return doc + } + + protected async persist(ns: SettingsNamespace, section: Record): Promise { + const output = this.spec.format === 'yaml' + ? this.renderYaml(ns, section) + : this.renderJson(ns, section) + await mkdir(dirname(this.spec.filename), { recursive: true }) + const temp = `${this.spec.filename}.tmp` + // Owner-only permissions apply to the temp file and survive the rename, so + // a document that may carry personal values is never world-readable. + await writeFile(temp, output, { mode: 0o600 }) + await rename(temp, this.spec.filename) + this.text = output + } + + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + // A parse failure here is a boot failure: an existing-but-invalid document + // must fail loud, never be silently ignored or overwritten. + this.publish(await this.load()) + if (!this.spec.watch) return + const watcher = chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + watcher.on('all', () => { + this.refreshTask = this.refreshTask.then(() => this.refresh()) + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + yield () => watcher.close() + } + + /** Parse one document text into raw sections, failing on a non-map root. */ + private parse(text: string): Record { + let root: unknown + if (this.spec.format === 'yaml') { + const document = parseDocument(text, { prettyErrors: true }) + if (document.errors.length > 0) { + throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ + document.errors.map(error => error.message).join('; ')}`) + } + root = document.toJS() ?? {} + } else { + root = text.trim().length === 0 ? {} : JSON.parse(text) + } + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`) + } + return root as Record + } + + /** + * Re-read the document after a watcher event. Unchanged content (including + * this provider's own writes) is a no-op; an unreadable or unparsable + * document keeps the last good sections and warns — a live hot-reload must + * never take the process down. + */ + private async refresh(): Promise { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) { + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + if (this.text === undefined) return + this.text = undefined + this.publish({}) + return + } + if (text === this.text) return + let doc: Record + try { + doc = this.parse(text) + } catch (error) { + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + this.text = text + this.publish(doc) + } + + /** Render the next YAML text by patching one namespace in the comment-preserving document. */ + private renderYaml(ns: SettingsNamespace, section: Record): string { + if (this.text === undefined) { + return new Document({ [ns]: section }).toString() + } + // this.text only ever caches content that parsed successfully, so this + // re-parse (for the mutable comment-preserving tree) cannot fail. + const document = parseDocument(this.text) + document.set(ns, section) + return document.toString() + } + + /** Render the next JSON text by replacing one namespace key. */ + private renderJson(ns: SettingsNamespace, section: Record): string { + const root = this.text === undefined + ? {} + : this.parse(this.text) + root[ns] = section + return `${JSON.stringify(root, null, 2)}\n` + } +} + +export default SettingsLocal diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts new file mode 100644 index 0000000000..b59b798298 --- /dev/null +++ b/packages/settings/settings-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`. + * @module @deepseek-ai/dsh-settings-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local' + +/** Cordis companion plugin name. */ +export const name = 'settings-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this provider's contracts are file round-trip, + * watcher timing, and atomic-write behavior — IO effects proven by package + * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..5744e92f46 --- /dev/null +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -0,0 +1,112 @@ +/** + * Real-composition guard: the provider and a consumer plugin boot from a + * test-only cordis.yml through the actual Loader + Include path, and an + * external edit of settings.yaml hot-publishes into the consumer's scope. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import z from 'schemastery' +import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '../src/index.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +interface ConsumerState { + scope: SettingsScope | undefined + seen: ThemeConfig[] +} + +async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-')) + const settingsPath = join(root, 'settings.yaml') + await writeFile(settingsPath, 'ui-theme:\n theme: light\n') + + const state: ConsumerState = { scope: undefined, seen: [] } + const consumer = { + name: 'settings-consumer', + inject: ['settings'], + apply: (ctx: Context) => { + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + state.scope = scope + scope.watch(next => state.seen.push(next)) + }, + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: consumer', + ' name: test-settings-consumer', + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['test-settings-consumer', consumer], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, state, settingsPath } +} + +describe('settings-local real composition', () => { + it('boots from cordis.yml and hot-publishes an external settings edit', async () => { + const { ctx, state, settingsPath } = await loadComposition() + + // Composition resolution: user layer over the consumer's composition base. + expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 }) + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme']) + + await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n') + await vi.waitFor(() => { + expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 }) + }, { timeout: 5000 }) + expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 }) + }) +}) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts new file mode 100644 index 0000000000..9c9d472bac --- /dev/null +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal, resolveSpec } from '../src/index.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('resolveSpec', () => { + it('defaults watch and debounce when construction bypasses schema normalization', () => { + const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' }) + expect(spec.watch).toBe(true) + expect(spec.debounceMs).toBe(100) + }) +}) + +describe('boot and reads', () => { + it('resolves defaults over an absent file and reports writable', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + expect(ctx.settings.writable).toBe(true) + }) + + it('reads sections from an existing yaml document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) + + it('reads sections from a json document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } })) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('defaults the file location under the configured harness home', async () => { + const dir = await tempDir() + const ctx = await boot({ dshHome: dir, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = await readFile(join(dir, 'settings.yaml'), 'utf8') + expect(written).toContain('theme: light') + }) + + it('reads an empty yaml document as no sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, '') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('reads an empty json document as no sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, '') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('fails loud at boot when the document exists but is unreadable', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i) + }) + + it('fails loud on an unsupported extension', async () => { + const dir = await tempDir() + await expect(boot({ path: join(dir, 'settings.toml'), watch: false })) + .rejects.toThrow(/not supported/) + }) + + it('fails loud at boot on unparsable yaml', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme: [unclosed\n') + await expect(boot({ path, watch: false })).rejects.toThrow() + }) + + it('fails loud at boot when the root is not a map of sections', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, '- just\n- a list\n') + await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/) + }) +}) + +describe('persist', () => { + it('writes the merged section, creating the file with owner-only permissions', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + + const written = await readFile(path, 'utf8') + expect(written).toContain('theme: light') + expect((await stat(path)).mode & 0o777).toBe(0o600) + // Atomic replace leaves no temp artifact behind. + expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) + }) + + it('preserves comments and unregistered sections across updates', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + '# personal settings', + 'ui-theme:', + ' theme: light', + '# owned by a plugin that is not loaded right now', + 'future-plugin:', + ' keep: me', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ fontSize: 18 }) + + const written = await readFile(path, 'utf8') + expect(written).toContain('# personal settings') + expect(written).toContain('# owned by a plugin that is not loaded right now') + expect(written).toContain('keep: me') + expect(written).toContain('fontSize: 18') + expect(written).toContain('theme: light') + }) + + it('creates a json document from scratch', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = JSON.parse(await readFile(path, 'utf8')) as Record + expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) + }) + + it('round-trips a json document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2)) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = JSON.parse(await readFile(path, 'utf8')) as Record + expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } }) + }) +}) + +describe('watch', () => { + it('publishes an external edit to registered scopes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get().theme).toBe('light') + + await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n') + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 }) + }, { timeout: 5000 }) + }) + + it('keeps the last good document over an invalid edit, then recovers', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await writeFile(path, 'ui-theme: [unclosed\n') + // The bad edit must never take the live tree down or reset the value. + await new Promise(resolve => setTimeout(resolve, 300)) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + + await writeFile(path, 'ui-theme:\n theme: dark\n') + await vi.waitFor(() => { + expect(scope.get().theme).toBe('dark') + }, { timeout: 5000 }) + }) + + it('treats file removal as an empty document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await rm(path) + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }, { timeout: 5000 }) + }) + + it('does not republish its own persisted write', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 10 }) + const events: unknown[] = [] + ctx.on('settings/updated', (ns, _next, _prev, source) => { + events.push({ ns, source }) + }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 300)) + expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }]) + }) +}) diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..00c67eacc7 --- /dev/null +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +// chokidar is the nondeterministic OS boundary: faking it lets these tests +// drive the event pipeline (error events, races with unreadable files) +// deterministically. Real end-to-end watching stays covered by local.spec.ts. +vi.mock('chokidar', async () => { + const { EventEmitter } = await import('node:events') + class FakeWatcher extends EventEmitter { + close = vi.fn(() => Promise.resolve()) + } + const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] + return { + watch: vi.fn((path: string, options: unknown) => { + const watcher = new FakeWatcher() + instances.push({ path, options, watcher }) + return watcher + }), + __instances: instances, + } +}) + +interface FakeChokidar { + __instances: Array<{ + path: string + options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } + watcher: import('node:events').EventEmitter + }> +} + +async function fakeInstances(): Promise { + const chokidar = await import('chokidar') as unknown as FakeChokidar + return chokidar.__instances +} + +const ThemeSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('dark'), +}) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('watcher pipeline', () => { + it('clamps the write-settle poll interval for a zero debounce', async () => { + const dir = await tempDir() + await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 }) + const [instance] = await fakeInstances() + expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) + }) + + it('survives a watcher error and keeps publishing later edits', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const [instance] = await fakeInstances() + + instance!.watcher.emit('error', new Error('watch backend failure')) + expect(scope.get()).toEqual({ theme: 'dark' }) + + await writeFile(path, 'ui-theme:\n theme: light\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get()).toEqual({ theme: 'light' }) + }) + }) + + it('keeps the last good document when the file turns unreadable at runtime', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + // The warn-and-keep path is asynchronous; give the serialized refresh a turn. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(scope.get()).toEqual({ theme: 'light' }) + }) + + it('treats an event for a still-absent file as a no-op', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'add', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(scope.get()).toEqual({ theme: 'dark' }) + }) +}) diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json new file mode 100644 index 0000000000..67a746c982 --- /dev/null +++ b/packages/settings/settings-local/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/paths" + }, + { + "path": "../settings" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml new file mode 100644 index 0000000000..51f30f2ca6 --- /dev/null +++ b/packages/settings/settings/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/settings/settings/README.md +README.md: e57db00c095a87f9f0b51397e030dec364f48e62 +README.zh.md: 8733823106f0ef3880cbe2c567de2edcdeff2c86 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md new file mode 100644 index 0000000000..e57db00c09 --- /dev/null +++ b/packages/settings/settings/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-settings + +English | [中文](README.zh.md) + +Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings. + +## Service API + +- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. +- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces. +- `get(ns)` — resolved value, `undefined` while unregistered. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every update. +- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures are contained. + +## Provider contract + +Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. + +## Events + +`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value. + +## Model Experience + +Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect. + +#### KV Cache effect + +No direct invalidation; a consumer that folds a settings value into the request prefix owns that change. + +## Known Limitations and Deferred Work + +- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. +- **Cross-process concurrency is provider-defined** — the seam serializes nothing across processes; concurrent writers converge by provider behavior (the local file provider is last-write-wins). +- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md new file mode 100644 index 0000000000..8733823106 --- /dev/null +++ b/packages/settings/settings/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-settings + +[English](README.md) | 中文 + +抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。 + +## 服务 API + +- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 +- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。 +- `get(ns)` — 解析值;未注册时为 `undefined`。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切更新。 +- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`,观察者异常被隔离。 + +## Provider 契约 + +子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 + +## 事件 + +`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。 + +## Model Experience + +间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。 + +#### KV Cache effect + +无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。 + +## Known Limitations and Deferred Work + +- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 +- **跨进程并发由 provider 定义** — seam 不做跨进程串行化;并发写入者按 provider 行为收敛(本地文件 provider 为后写胜出)。 +- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。 diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json new file mode 100644 index 0000000000..7586b4bb24 --- /dev/null +++ b/packages/settings/settings/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-settings", + "description": "Abstract user-settings seam (ctx.settings) 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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" + } +} diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts new file mode 100644 index 0000000000..bec60dfdea --- /dev/null +++ b/packages/settings/settings/src/index.ts @@ -0,0 +1,304 @@ +/** + * User-settings seam (`ctx.settings`). Providers store one raw document of + * per-namespace sections; plugins register a namespace schema and read the + * resolved value, which layers schema defaults, the registrant's composition + * `base`, and the user document section, in that order. + * @module @deepseek-ai/dsh-settings + */ + +import { Context, Service } from 'cordis' +import { deepEqual } from 'cosmokit' +import type z from 'schemastery' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal id of one registered settings namespace. */ +export type SettingsNamespace = Branded<'SettingsNamespace'> + +const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ + +/** + * Brand a raw string as a {@link SettingsNamespace}. + * @param value - candidate namespace; lowercase kebab-case, as in plugin short names. + * @returns the branded namespace. + */ +export function settingsNamespace(value: string): SettingsNamespace { + if (!NAMESPACE_PATTERN.test(value)) { + throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`) + } + return value as SettingsNamespace +} + +/** When a namespace's changes take effect for its owner. */ +export type SettingsApplies = 'live' | 'restart' + +/** Origin of one committed settings change. */ +export type SettingsUpdateSource = 'update' | 'provider' + +/** Registration options beyond the namespace schema. */ +export interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} + +/** One registered namespace as surfaced to configuration UIs. */ +export interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} + +/** Owner-facing handle for one registered namespace. */ +export interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section. + */ + update(patch: object): Promise +} + +declare module 'cordis' { + interface Context { + settings: Settings + } + + interface Events { + /** + * Committed change to one registered namespace's resolved value. Emitted + * after the provider persisted (for `update`) or published (`provider`) + * the change; never emitted when the resolved value is deep-equal. + * @param ns - the namespace whose resolved value changed. + * @param next - the new resolved value. + * @param prev - the previous resolved value. + * @param source - whether the change entered through `update()` or the provider. + * @mode emit + */ + 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void + } +} + +/** Whether a value is a plain data object (not an array, null, or class instance). */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const proto: unknown = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** + * Layer `over` onto `under`: plain objects merge recursively, every other + * value (arrays included) replaces the lower layer wholesale, and `undefined` + * entries in `over` are ignored so a sparse patch cannot erase lower keys. + */ +function mergeLayers(under: unknown, over: unknown): unknown { + if (over === undefined) return under + if (!isPlainObject(under) || !isPlainObject(over)) return over + const merged: Record = { ...under } + for (const [key, value] of Object.entries(over)) { + if (value === undefined) continue + merged[key] = key in merged ? mergeLayers(merged[key], value) : value + } + return merged +} + +/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */ +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value + for (const entry of Object.values(value)) deepFreeze(entry) + return Object.freeze(value) +} + +/** One live namespace registration owned by a registrant fiber. */ +interface SettingsRegistration { + ns: SettingsNamespace + schema: z + base: unknown + applies: SettingsApplies + resolved: unknown + watchers: Set<(next: never, prev: never) => void> +} + +/** + * Abstract settings service. Providers implement raw-document storage + * (`load`/`persist`) and push external changes through {@link Settings.publish}; + * the base class owns namespace registration, resolution, validation, change + * detection, and the `settings/updated` commit event. + */ +export abstract class Settings extends Service { + private readonly registrations = new Map() + /** Latest published raw document; empty until the provider's first publish. */ + private document: Record = {} + + constructor(ctx: Context) { + super(ctx, 'settings') + } + + /** Whether {@link update} may persist through this provider. */ + abstract readonly writable: boolean + + /** + * Read the provider's current raw document (namespace to raw section). + * @returns the detached raw document. + */ + protected abstract load(): Promise> + + /** + * Durably store one namespace's merged user section. + * @param ns - the namespace being written. + * @param section - the complete merged user section to store. + */ + protected abstract persist(ns: SettingsNamespace, section: Record): Promise + + /** + * Register a namespace schema and receive its owner scope. The registration + * is an effect on the calling plugin's fiber: disposing that fiber removes + * the namespace and its observers. An invalid stored section fails the + * registration itself — the earliest point where the schema can judge it. + * @param ns - unique namespace; duplicate registration fails loud. + * @param schema - schemastery schema resolving this namespace's value. + * @param options - composition `base` layer and effect timing. + * @returns the owner scope for reads, observation, and updates. + */ + register(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope { + if (this.registrations.has(ns)) { + throw new Error(`settings namespace "${ns}" is already registered`) + } + const registration: SettingsRegistration = { + ns, + schema: schema as z, + base: options?.base, + applies: options?.applies ?? 'live', + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + watchers: new Set(), + } + this.ctx.effect(() => { + this.registrations.set(ns, registration) + return () => this.registrations.delete(ns) + }, `settings.register(${JSON.stringify(String(ns))})`) + return { + get: () => registration.resolved as T, + watch: (callback) => { + registration.watchers.add(callback) + return () => registration.watchers.delete(callback) + }, + update: patch => this.update(ns, patch), + } + } + + /** + * Describe every registered namespace for configuration surfaces. + * @returns one descriptor per registered namespace, in registration order. + */ + describe(): SettingsDescriptor[] { + return [...this.registrations.values()].map(registration => ({ + ns: registration.ns, + schema: registration.schema.toJSON(), + value: registration.resolved, + applies: registration.applies, + })) + } + + /** + * Read one registered namespace's resolved value. + * @param ns - the namespace to read. + * @returns the resolved value, or `undefined` while unregistered. + */ + get(ns: SettingsNamespace): unknown { + return this.registrations.get(ns)?.resolved + } + + /** + * Merge a patch into one registered namespace's user layer, validate the + * resolved candidate, persist through the provider, then commit and emit. + * A validation failure rejects before anything is persisted. + * @param ns - the registered namespace to update. + * @param patch - plain-object patch over the user section. + */ + async update(ns: SettingsNamespace, patch: object): Promise { + const registration = this.registrations.get(ns) + if (registration === undefined) { + throw new Error(`settings namespace "${ns}" is not registered`) + } + if (!this.writable) { + throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) + } + if (!isPlainObject(patch)) { + throw new TypeError(`settings update for "${ns}" must be a plain object patch`) + } + const section = mergeLayers(this.section(ns) ?? {}, patch) as Record + const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + await this.persist(ns, section) + this.document[ns] = section + this.commit(registration, next, 'update') + } + + /** + * Provider hook: commit a complete raw document observed in storage. Each + * registered namespace re-resolves; an invalid section keeps that + * namespace's last good value and warns, other namespaces still commit. + * @param doc - the detached raw document (unregistered sections preserved). + * @param source - change origin; defaults to `provider`. + */ + protected publish(doc: Record, source: SettingsUpdateSource = 'provider'): void { + this.document = doc + for (const registration of this.registrations.values()) { + let next: unknown + try { + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + } catch (error) { + this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) + this.ctx.logger.warn(error) + continue + } + this.commit(registration, next, source) + } + } + + /** Read one namespace's raw user section, rejecting non-object sections. */ + private section(ns: SettingsNamespace): Record | undefined { + const section = this.document[ns] + if (section === undefined) return undefined + if (!isPlainObject(section)) { + throw new TypeError(`settings section "${ns}" must be an object of keys`) + } + return section + } + + /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ + private resolve(schema: z, base: unknown, section: Record | undefined): T { + // The merged candidate is untyped by construction; the schema call is the + // runtime validation that admits it into T. + return schema(mergeLayers(base, section) as never) + } + + /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ + private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { + const prev = registration.resolved + if (deepEqual(next, prev)) return + registration.resolved = next + for (const watcher of [...registration.watchers]) { + try { + watcher(next as never, prev as never) + } catch (error) { + this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns) + this.ctx.logger.warn(error) + } + } + this.ctx.emit('settings/updated', registration.ns, next, prev, source) + } +} + +export default Settings diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts new file mode 100644 index 0000000000..235f413aae --- /dev/null +++ b/packages/settings/settings/src/invariant.ts @@ -0,0 +1,41 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-settings`. + * @module @deepseek-ai/dsh-settings/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-settings' + +/** Cordis companion plugin name. */ +export const name = 'settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event contract: `settings/updated` fires only for a + * currently registered namespace and only when the resolved value changed. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('settings/updated', (ns, next, prev) => { + const settings = ctx.get('settings') + if (settings === undefined) { + fail(`settings/updated for "${ns}" emitted without a live settings service`) + } + if (settings.get(ns) === undefined) { + fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) + } + if (JSON.stringify(next) === JSON.stringify(prev)) { + fail(`settings/updated for "${ns}" emitted without a resolved-value change`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..f12ff6126c --- /dev/null +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SettingsInvariant from '../src/invariant.ts' +import { settingsNamespace } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +async function setup(withProvider: boolean): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(SettingsInvariant) + if (withProvider) await ctx.plugin(MemorySettings) + return ctx +} + +describe('settings invariants', () => { + it('fails a settings/updated emission without a live settings service', async () => { + const ctx = await setup(false) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/without a live settings service/) + }) + + it('fails a settings/updated emission for an unregistered namespace', async () => { + const ctx = await setup(true) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider') + }).toThrow(/unregistered/) + }) + + it('fails a settings/updated emission without a resolved-value change', async () => { + const ctx = await setup(true) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + theme: z.string().default('dark'), + })) + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update') + }).toThrow(/without a resolved-value change/) + }) +}) diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts new file mode 100644 index 0000000000..0b20771cbe --- /dev/null +++ b/packages/settings/settings/tests/memory.ts @@ -0,0 +1,53 @@ +/** + * In-memory settings provider fixture: the smallest real subclass of the seam, + * used by the base-class behavior suite in place of a file- or network-backed + * provider. Kept in `tests/` because production providers live in their own + * packages. + */ + +import { Service } from 'cordis' +import { Settings, type SettingsNamespace } from '../src/index.ts' + +/** In-memory provider exposing the protected seam hooks to tests. */ +export class MemorySettings extends Settings { + /** Raw document the provider "storage" currently holds. */ + doc: Record + /** Every persist() call observed, in order. */ + persisted: Array<{ ns: SettingsNamespace; section: Record }> = [] + /** When false, update() must reject before reaching persist(). */ + writableFlag: boolean + + constructor(ctx: ConstructorParameters[0], options?: { + doc?: Record + writable?: boolean + }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + this.writableFlag = options?.writable ?? true + } + + get writable(): boolean { + return this.writableFlag + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.persisted.push({ ns, section: structuredClone(section) }) + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } + + /** Simulate an external storage change reaching the provider. */ + pushExternal(doc: Record): void { + this.doc = structuredClone(doc) + this.publish(structuredClone(doc)) + } + + async* [Service.init](): AsyncGenerator<() => void, void, void> { + this.publish(await this.load()) + yield () => { this.persisted.length = 0 } + } +} diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts new file mode 100644 index 0000000000..a3d223c3f7 --- /dev/null +++ b/packages/settings/settings/tests/settings.spec.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +interface ThemeConfig { + theme: 'dark' | 'light' + fontSize: number +} + +const ThemeSchema: z = z.object({ + theme: z.union(['dark', 'light']).default('dark'), + fontSize: z.number().default(14), +}) + +interface NestedConfig { + retry: { attempts: number; delayMs: number } + tags: string[] +} + +const NestedSchema: z = z.object({ + retry: z.object({ + attempts: z.number().default(2), + delayMs: z.number().default(100), + }), + tags: z.array(z.string()).default(['default']), +}) + +async function boot(options?: ConstructorParameters[1]) { + const ctx = new Context() + await ctx.plugin(MemorySettings, options) + const provider = ctx.get('settings') as MemorySettings + return { ctx, provider } +} + +/** Record every settings/updated emission. */ +function recordUpdates(ctx: Context) { + const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = [] + ctx.on('settings/updated', (ns, next, prev, source) => { + events.push({ ns, next, prev, source }) + }) + return events +} + +describe('settingsNamespace', () => { + it('brands lowercase kebab-case names', () => { + expect(settingsNamespace('ui-theme')).toBe('ui-theme') + }) + + it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => { + expect(() => settingsNamespace(value)).toThrow(TypeError) + }) +}) + +describe('registration', () => { + it('resolves schema defaults, then composition base, then the user layer', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + // theme: user layer wins; fontSize: base wins over the schema default. + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) + + it('rejects a duplicate namespace loud', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/already registered/) + }) + + it('fails registration when the stored section is invalid for the schema', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow() + }) + + it('fails registration when the stored section is not an object', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)) + .toThrow(/must be an object/) + }) + + it('describes registered namespaces with schema JSON, value, and applies', async () => { + const { ctx } = await boot() + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' }) + const descriptors = ctx.settings.describe() + expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([ + ['ui-theme', 'live'], + ['workspace', 'restart'], + ]) + expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 }) + // schemastery's canonical wire form: a { uid, refs } envelope whose root ref + // is the object schema — the shape schema-driven form UIs reconstruct from. + const serialized = descriptors[0]!.schema as { uid: number; refs: Record } + expect(serialized.refs[String(serialized.uid)]?.type).toBe('object') + }) + + it('reads undefined for an unregistered namespace', async () => { + const { ctx } = await boot() + expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined() + }) + + it('hands out frozen resolved values', async () => { + const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + const value = scope.get() + expect(Object.isFrozen(value)).toBe(true) + expect(Object.isFrozen(value.retry)).toBe(true) + expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError) + }) + + it('removes the namespace and its observers when the registrant fiber disposes', async () => { + const { ctx, provider } = await boot() + const seen: unknown[] = [] + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(next => seen.push(next)) + }, + }) + await fiber + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 }) + + await fiber.dispose() + expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined() + expect(ctx.settings.describe()).toEqual([]) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(seen).toEqual([]) + + // The namespace is free again, and re-registration resolves the user layer + // that kept living in storage while nobody owned the namespace. + const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(again.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) + +describe('update', () => { + it('persists the merged user section without baking in the base layer', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + await scope.update({ theme: 'dark' }) + expect(provider.persisted).toEqual([ + { ns: 'ui-theme', section: { theme: 'dark' } }, + ]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + }) + + it('deep-merges nested objects and replaces arrays wholesale', async () => { + const { ctx, provider } = await boot({ + doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } }, + }) + const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + await scope.update({ retry: { attempts: 7 }, tags: ['c'] }) + expect(provider.persisted[0]!.section).toEqual({ + retry: { attempts: 7, delayMs: 300 }, + tags: ['c'], + }) + expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] }) + }) + + it('commits, notifies watchers, and emits with source update', async () => { + const { ctx } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + await scope.update({ theme: 'light' }) + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + expect(events).toEqual([{ + ns: 'ui-theme', + next: { theme: 'light', fontSize: 14 }, + prev: { theme: 'dark', fontSize: 14 }, + source: 'update', + }]) + }) + + it('rejects an invalid patch before persisting anything', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ fontSize: 'big' })).rejects.toThrow() + expect(provider.persisted).toEqual([]) + expect(events).toEqual([]) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + }) + + it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: undefined, fontSize: 18 }) + expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) + }) + + it('rejects a non-object patch', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update([1])).rejects.toThrow(TypeError) + await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError) + }) + + it('accepts a null-prototype patch object', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number } + patch.fontSize = 18 + await scope.update(patch) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) + + it('rejects an unregistered namespace', async () => { + const { ctx } = await boot() + await expect(ctx.settings.update(settingsNamespace('missing'), {})) + .rejects.toThrow(/not registered/) + }) + + it('rejects on a read-only provider before reaching persist', async () => { + const { ctx, provider } = await boot({ writable: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/) + expect(provider.persisted).toEqual([]) + }) +}) + +describe('publish', () => { + it('notifies watchers of an external change with source provider', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + expect(events[0]!.source).toBe('provider') + }) + + it('stays silent when the resolved value is deep-equal', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + scope.watch(watcher) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + expect(events).toEqual([]) + }) + + it('keeps the last good value for an invalid section while other namespaces commit', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema) + provider.pushExternal({ + 'ui-theme': { fontSize: 'broken' }, + workspace: { retry: { attempts: 9 } }, + }) + expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 }) + expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] }) + expect(events.map(event => event.ns)).toEqual(['workspace']) + }) + + it('recovers from a bad section once storage turns valid again', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + provider.pushExternal({ 'ui-theme': { fontSize: 18 } }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) + }) +}) + +describe('watch', () => { + it('stops after its disposer runs', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + const dispose = scope.watch(watcher) + dispose() + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(watcher).not.toHaveBeenCalled() + }) + + it('contains a throwing watcher without blocking the commit or other watchers', async () => { + const { ctx, provider } = await boot() + const events = recordUpdates(ctx) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(() => { throw new Error('watcher boom') }) + const second = vi.fn() + scope.watch(second) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(1) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) + }) +}) diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json new file mode 100644 index 0000000000..dd18d27fc3 --- /dev/null +++ b/packages/settings/settings/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9538051cd..fce89446a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3636,6 +3636,46 @@ 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/settings/settings: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + 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) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + + packages/settings/settings-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../settings + 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/skill/skill: dependencies: schemastery: diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4cae854147..a27d6af22b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1705, + "AGENTS.md": 1710, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 835 + "packages/README.md": 845 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b10ad9dfac..b7df3dc622 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -217,6 +217,12 @@ const FOUNDATION_TYPE_NAMES = new Set([ /** Project types deliberately documented outside the core-data catalog. */ const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', + SettingsNamespace: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsUpdateSource: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsRegisterOptions: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsScope: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + SettingsDescriptor: 'settings seam vocabulary is owned by packages/settings/settings/README.md', + z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1d4a7e447d..a077042d3c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -137,6 +137,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'settings', + pkg: 'settings', + title: 'User-settings seam', + mode: 'seam', + implementations: ['settings-local'], + consumers: [], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + }, { key: 'telemetry', pkg: 'session-telemetry', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 13dc2def8d..dc02127a17 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -90,6 +90,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, + 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index d4beec7afa..8f39d3ec5f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -75,6 +75,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/settings/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -156,6 +157,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/settings/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 9cf2a86bda..2c9bc2a652 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -58,6 +58,8 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/settings/settings" }, + { "path": "./packages/settings/settings-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, From f44b4db1f22b44b5d280065ad0bd8170d36b8d1a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 18:18:34 +0800 Subject: [PATCH 09/97] fix(settings): harden seam and provider per review findings Confirmed and fixed, each with a regression test that failed first: - Concurrent update() lost patches (merge over one stale snapshot): per-namespace serialized write queues; a failed write cannot poison the queue for later writers. - Fixed-name .tmp write followed planted symlinks and kept stale modes: random-suffix sibling, exclusive-create (wx), 0600, cleanup on failure, then rename. - A throwing settings/updated listener escaped commit and permanently wedged the provider reload chain (rejected refreshTask): commit now contains listener failures (INVARIANT-coded errors still propagate), async watcher rejections are adopted and contained (watch callbacks are officially void | Promise), and the provider chains refreshes on a settled tail with an error log. - No way to remove a user override: scope/service replace(section) sets the user section wholesale; replace({}) re-inherits base and schema defaults. - The three-primitive provider contract did not hold (base never called load()): the base Service.init loads and publishes once; settings-local delegates via yield* super[Service.init](). - Dispose did not quiesce: teardown flags closed, closes the watcher, then awaits queued/in-flight reloads; closed is re-checked across await points. - Invariant now checks the authoritative relation with the seam's own deepEqualJson: emitted next must equal settings.get(ns), and next/prev must differ structurally (cosmokit dependency dropped). - New docs/core-data-structures/settings.{md,zh.md} with type-equiv blocks + manifest entries; catalog types moved from exemptions to LINK_MAP; website page registered. Both packages stay at per-file 100% coverage. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 4 +- docs/cordis-catalog/services.md | 18 ++- docs/core-data-structures/settings.i18n.yaml | 6 + docs/core-data-structures/settings.md | 94 +++++++++++++ docs/core-data-structures/settings.zh.md | 94 +++++++++++++ docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 3 +- packages/settings/settings-local/README.zh.md | 3 +- packages/settings/settings-local/src/index.ts | 58 +++++++-- .../tests/loader-composition.spec.ts | 2 +- .../settings-local/tests/local.spec.ts | 33 ++++- .../settings-local/tests/watcher.spec.ts | 54 ++++++++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 9 +- packages/settings/settings/README.zh.md | 9 +- packages/settings/settings/src/index.ts | 123 +++++++++++++++--- packages/settings/settings/src/invariant.ts | 13 +- .../settings/settings/tests/invariant.spec.ts | 11 ++ packages/settings/settings/tests/memory.ts | 17 +-- .../settings/settings/tests/settings.spec.ts | 114 +++++++++++++++- scripts/gen-cordis-catalog.ts | 10 +- scripts/project-doc-site.spec.ts | 2 +- scripts/type-equiv.manifest.json | 30 +++++ website/docs.ts | 1 + 27 files changed, 655 insertions(+), 73 deletions(-) create mode 100644 docs/core-data-structures/settings.i18n.yaml create mode 100644 docs/core-data-structures/settings.md create mode 100644 docs/core-data-structures/settings.zh.md diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1305dd7270..2a1eb47da5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1193,7 +1193,7 @@ export interface Config { } ``` -Source: [`packages/settings/settings-local/src/index.ts:18`](../packages/settings/settings-local/src/index.ts) +Source: [`packages/settings/settings-local/src/index.ts:19`](../packages/settings/settings-local/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index aea04f4acd..74e1e6cfda 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -660,7 +660,9 @@ Committed change to one registered namespace's resolved value. Emitted after the 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void ``` -Source: [`packages/settings/settings/src/index.ts:90`](../../packages/settings/settings/src/index.ts) +Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) + +Source: [`packages/settings/settings/src/index.ts:96`](../../packages/settings/settings/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0271083d6..e915952ffb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1421,14 +1421,28 @@ get(ns: SettingsNamespace): unknown /** * Merge a patch into one registered namespace's user layer, validate the * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. + * A validation failure rejects before anything is persisted. Writes to one + * namespace are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. */ async update(ns: SettingsNamespace, patch: object): Promise + +/** + * Replace one registered namespace's user section wholesale, validate, + * persist, then commit and emit. Keys absent from `section` fall back to the + * composition `base` and schema defaults — this is the removal/reset path a + * merge-only patch cannot express (`replace({})` re-inherits everything). + * @param ns - the registered namespace to replace. + * @param section - the complete next user section. + */ +async replace(ns: SettingsNamespace, section: object): Promise ``` -Source: [`packages/settings/settings/src/index.ts:140`](../../packages/settings/settings/src/index.ts) +Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) + +Source: [`packages/settings/settings/src/index.ts:168`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml new file mode 100644 index 0000000000..6dc8750dfb --- /dev/null +++ b/docs/core-data-structures/settings.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/core-data-structures/settings.md +settings.md: 851087065c627f2390041dd6e69273e31be3917d +settings.zh.md: 955c4fbf7c147b3d0a8be62c33c031d7bd2c4ba2 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md new file mode 100644 index 0000000000..851087065c --- /dev/null +++ b/docs/core-data-structures/settings.md @@ -0,0 +1,94 @@ +# User Settings + +English | [中文](settings.zh.md) + +The user-settings seam of [dsh-settings](../../packages/settings/settings) holds one user-owned document of per-namespace sections and resolves each registered namespace as schema defaults, then the registrant's composition `base`, then the user section. Providers such as [dsh-settings-local](../../packages/settings/settings-local) store the raw document and push external edits; consumer plugins register a schema and read or observe the resolved value. Composition config stays in `cordis.yml` — a namespace carries only the user-editable subset. + +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + +## Identity + +A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape. + +```ts type-equiv +/** Nominal id of one registered settings namespace. */ +type SettingsNamespace = Branded<'SettingsNamespace'> +``` + +## Registration + +Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer and the owner's effect timing. + +```ts type-equiv +/** Registration options beyond the namespace schema. */ +interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} +``` + +`applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change. + +```ts type-equiv +/** When a namespace's changes take effect for its owner. */ +type SettingsApplies = 'live' | 'restart' +``` + +## Owner scope + +The scope is the owner-facing handle. `update` merges a sparse patch over the user section only (never into `base`); `replace` sets the section wholesale, which is the removal/reset path — keys absent from the replacement re-inherit `base` and schema defaults. Writes to one namespace are serialized in call order, and resolved values are deep-frozen snapshots. + +```ts type-equiv +/** Owner-facing handle for one registered namespace. */ +interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. A callback + * may be async; a rejection is contained and logged like a sync throw. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void | Promise): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section. + */ + update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section. + */ + replace(section: object): Promise +} +``` + +## Descriptors + +`describe()` serializes every registered namespace for configuration surfaces: the schemastery `toJSON()` envelope drives schema-rendered forms, and the resolved value fills them. + +```ts type-equiv +/** One registered namespace as surfaced to configuration UIs. */ +interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} +``` + +## Change commits + +Every committed change — an in-process write or an externally observed provider edit — emits `settings/updated (ns, next, prev, source)` after the new value is authoritative, and never when the resolved value is deep-equal. The source tag separates the two entry paths. + +```ts type-equiv +/** Origin of one committed settings change. */ +type SettingsUpdateSource = 'update' | 'provider' +``` diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md new file mode 100644 index 0000000000..955c4fbf7c --- /dev/null +++ b/docs/core-data-structures/settings.zh.md @@ -0,0 +1,94 @@ +# 用户设置 + +[English](settings.md) | 中文 + +[dsh-settings](../../packages/settings/settings) 的用户设置 seam 持有一份按 namespace 分节的用户文档,并把每个已注册 namespace 解析为:schema 默认值,然后注册方的组合 `base`,最后用户分节。[dsh-settings-local](../../packages/settings/settings-local) 这类 provider 存储原始文档并推送外部编辑;消费插件注册 schema 后读取或观察解析值。组合配置仍留在 `cordis.yml`——namespace 只承载用户可编辑子集。 + +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) + +## 标识 + +namespace 命名用户文档中一个插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。 + +```ts type-equiv +/** Nominal id of one registered settings namespace. */ +type SettingsNamespace = Branded<'SettingsNamespace'> +``` + +## 注册 + +注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层与 owner 的生效时机。 + +```ts type-equiv +/** Registration options beyond the namespace schema. */ +interface SettingsRegisterOptions { + /** Composition-layer values resolved below the user layer (entry-config subset). */ + base?: Partial + /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ + applies?: SettingsApplies +} +``` + +`applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。 + +```ts type-equiv +/** When a namespace's changes take effect for its owner. */ +type SettingsApplies = 'live' | 'restart' +``` + +## Owner scope + +scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分节(绝不进 `base`);`replace` 整体替换分节,是删除/重置路径——替换中缺席的键重新继承 `base` 与 schema 默认值。同一 namespace 的写入按调用顺序串行,解析值是深冻结快照。 + +```ts type-equiv +/** Owner-facing handle for one registered namespace. */ +interface SettingsScope { + /** Current resolved value: schema defaults, then `base`, then the user layer. */ + get(): T + /** + * Observe committed changes to this namespace's resolved value. A callback + * may be async; a rejection is contained and logged like a sync throw. + * @param callback - invoked after each commit with the next and previous values. + * @returns the disposer removing this observer. + */ + watch(callback: (next: T, prev: T) => void | Promise): () => void + /** + * Merge a partial patch into this namespace's user layer and persist it. + * @param patch - plain-object patch over the user section. + */ + update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section. + */ + replace(section: object): Promise +} +``` + +## 描述符 + +`describe()` 为配置界面序列化每个已注册 namespace:schemastery 的 `toJSON()` 信封驱动 schema 渲染的表单,解析值填充表单。 + +```ts type-equiv +/** One registered namespace as surfaced to configuration UIs. */ +interface SettingsDescriptor { + /** The registered namespace. */ + ns: SettingsNamespace + /** Serialized schemastery schema (`schema.toJSON()`). */ + schema: unknown + /** Current resolved value. */ + value: unknown + /** Owner's declared effect timing. */ + applies: SettingsApplies +} +``` + +## 变更提交 + +每次提交的变更——进程内写入或 provider 观察到的外部编辑——在新值成为权威值之后发出 `settings/updated (ns, next, prev, source)`,解析值深相等时绝不发出。source 标记区分两条入口路径。 + +```ts type-equiv +/** Origin of one committed settings change. */ +type SettingsUpdateSource = 'update' | 'provider' +``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 49bdf3fe35..e3eb1d9435 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:90`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:96`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 81e3c79d05..4190a94a90 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -684,7 +684,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async update(ns: SettingsNamespace, patch: object): Promise', - jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */', + jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */', + }, + { + signature: 'async replace(ns: SettingsNamespace, section: object): Promise', + jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */', }, ], }, @@ -2228,7 +2232,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsScope', - declaration: 'export interface SettingsScope {\n get(): T;\n watch(callback: (next: T, prev: T) => void): () => void;\n update(patch: object): Promise;\n}', + declaration: 'export interface SettingsScope {\n get(): T;\n watch(callback: (next: T, prev: T) => void | Promise): () => void;\n update(patch: object): Promise;\n replace(section: object): Promise;\n}', }, { name: 'SkillCandidate', diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index f7ca57e86e..455255941a 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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 packages/settings/settings-local/README.md -README.md: 90428f98055d8e49fa1ec54e571453db2e0a5054 -README.zh.md: 3532d6cee99cb46f54e23889ef1bb54b2548ecfa +README.md: 9d0aa3982507ca16b382aaa918ca1536a98e29ea +README.zh.md: 075de7ee5b3e0ef0a4ee27eeb8cb098ca0d73456 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index 90428f9805..9d0aa39825 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -18,7 +18,8 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension ## Behavior - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. -- **Write-back is atomic and owner-only.** `persist` writes `.tmp` with mode `0600` and renames over the target. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. +- **Write-back is atomic, owner-only, and symlink-proof.** `persist` exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. +- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. ## Model Experience diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 3532d6cee9..075de7ee5b 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -18,7 +18,8 @@ ## 行为 - **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 -- **写回原子且仅属主可读。** `persist` 以 `0600` 权限写 `.tmp` 后 rename 覆盖目标。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 +- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 +- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 ## Model Experience diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 421411ea50..2c020da63c 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -8,7 +8,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -86,6 +87,13 @@ export class SettingsLocal extends Settings { private text: string | undefined /** Serializes watcher-triggered reloads so reads never interleave. */ private refreshTask: Promise = Promise.resolve() + /** Set at dispose: refuse new watcher events and let in-flight work no-op. */ + private closed = false + + /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ + private isClosed(): boolean { + return this.closed + } constructor(ctx: Context, public config: Config) { super(ctx) @@ -118,18 +126,26 @@ export class SettingsLocal extends Settings { ? this.renderYaml(ns, section) : this.renderJson(ns, section) await mkdir(dirname(this.spec.filename), { recursive: true }) - const temp = `${this.spec.filename}.tmp` - // Owner-only permissions apply to the temp file and survive the rename, so - // a document that may carry personal values is never world-readable. - await writeFile(temp, output, { mode: 0o600 }) - await rename(temp, this.spec.filename) + // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to + // follow any planted symlink at a guessable temp path, and the fresh inode + // carries owner-only permissions that survive the rename — a document that + // may hold personal values is never world-readable and never a symlink. + const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) + await rename(temp, this.spec.filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } this.text = output } - async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { - // A parse failure here is a boot failure: an existing-but-invalid document - // must fail loud, never be silently ignored or overwritten. - this.publish(await this.load()) + override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + // The base init loads and publishes; a parse failure there is a boot + // failure: an existing-but-invalid document must fail loud, never be + // silently ignored or overwritten. + yield* super[Service.init]() if (!this.spec.watch) return const watcher = chokidarWatch(this.spec.filename, { ignoreInitial: true, @@ -139,13 +155,26 @@ export class SettingsLocal extends Settings { }, }) watcher.on('all', () => { - this.refreshTask = this.refreshTask.then(() => this.refresh()) + if (this.closed) return + this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the commit path can reject a + // refresh; keep the reload queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) }) watcher.on('error', (error) => { this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) - yield () => watcher.close() + yield async () => { + // Quiesce: stop accepting events, close the watcher, then wait out any + // queued or in-flight refresh so nothing publishes after disposal. + this.closed = true + await watcher.close() + await this.refreshTask + } } /** Parse one document text into raw sections, failing on a non-map root. */ @@ -174,6 +203,7 @@ export class SettingsLocal extends Settings { * never take the process down. */ private async refresh(): Promise { + if (this.closed) return let text: string try { text = await readFile(this.spec.filename, 'utf8') @@ -183,12 +213,12 @@ export class SettingsLocal extends Settings { this.ctx.logger.warn(error) return } - if (this.text === undefined) return + if (this.text === undefined || this.isClosed()) return this.text = undefined this.publish({}) return } - if (text === this.text) return + if (text === this.text || this.isClosed()) return let doc: Record try { doc = this.parse(text) diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts index 5744e92f46..d584c11899 100644 --- a/packages/settings/settings-local/tests/loader-composition.spec.ts +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -55,7 +55,7 @@ async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; base: { fontSize: 16 }, }) state.scope = scope - scope.watch(next => state.seen.push(next)) + scope.watch((next) => { state.seen.push(next) }) }, } diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 9c9d472bac..1c74429153 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -146,6 +146,23 @@ describe('persist', () => { expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) }) + it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const victim = join(dir, 'victim.txt') + await writeFile(victim, 'precious') + // A hostile sibling plants the historic fixed temp name as a symlink. + await symlink(victim, `${path}.tmp`) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + + expect(await readFile(victim, 'utf8')).toBe('precious') + expect((await lstat(path)).isSymbolicLink()).toBe(false) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(await readFile(path, 'utf8')).toContain('theme: light') + }) + it('preserves comments and unregistered sections across updates', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') @@ -180,6 +197,20 @@ describe('persist', () => { expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) }) + it('rejects and leaves no temp residue when the directory turns unwritable', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await chmod(dir, 0o500) + cleanups.push(() => chmod(dir, 0o700)) + await expect(scope.update({ theme: 'dark' })).rejects.toThrow() + await chmod(dir, 0o700) + expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) + expect(scope.get().theme).toBe('light') + }) + it('round-trips a json document', async () => { const dir = await tempDir() const path = join(dir, 'settings.json') diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts index 00c67eacc7..439f213473 100644 --- a/packages/settings/settings-local/tests/watcher.spec.ts +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -105,6 +105,60 @@ describe('watcher pipeline', () => { expect(scope.get()).toEqual({ theme: 'light' }) }) + it('keeps the reload queue alive after an invariant violation escapes a commit', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let arm = true + ctx.on('settings/updated', () => { + if (!arm) return + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const [instance] = await fakeInstances() + + await writeFile(path, 'ui-theme:\n theme: broken-commit\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get().theme).toBe('broken-commit') + }) + + arm = false + await writeFile(path, 'ui-theme:\n theme: recovered\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(() => { + expect(scope.get().theme).toBe('recovered') + }) + }) + + it('quiesces the refresh pipeline before dispose completes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, { path, debounceMs: 5 }) + await fiber + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let disposed = false + let postDisposeCommits = 0 + ctx.on('settings/updated', () => { + if (disposed) postDisposeCommits += 1 + }) + + await writeFile(path, 'ui-theme:\n theme: darker\n') + const [instance] = await fakeInstances() + // Two queued refreshes: dispose interrupts one mid-flight and the other + // before it starts, so both closed guards must hold. + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('all', 'change', path) + await fiber.dispose() + disposed = true + instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(postDisposeCommits).toBe(0) + }) + it('treats an event for a still-absent file as a no-op', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 51f30f2ca6..6f5e5c6beb 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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 packages/settings/settings/README.md -README.md: e57db00c095a87f9f0b51397e030dec364f48e62 -README.zh.md: 8733823106f0ef3880cbe2c567de2edcdeff2c86 +README.md: f7858a247f6011cd0654a73b5325d81c118441e5 +README.zh.md: 67ecba695066389bfe3a69f517d52f91b48b6c75 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index e57db00c09..f7858a247f 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -9,12 +9,13 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces. - `get(ns)` — resolved value, `undefined` while unregistered. -- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every update. -- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures are contained. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). +- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures — sync throws and async rejections alike — are contained. ## Provider contract -Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. +Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud. ## Events @@ -31,5 +32,5 @@ No direct invalidation; a consumer that folds a settings value into the request ## Known Limitations and Deferred Work - **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. -- **Cross-process concurrency is provider-defined** — the seam serializes nothing across processes; concurrent writers converge by provider behavior (the local file provider is last-write-wins). +- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins). - **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 8733823106..67ecba6950 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -9,12 +9,13 @@ - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 -- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切更新。 -- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`,观察者异常被隔离。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。 +- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`;观察者异常——同步抛出与异步拒绝——均被隔离。 ## Provider 契约 -子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 +子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。 ## 事件 @@ -31,5 +32,5 @@ ## Known Limitations and Deferred Work - **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 -- **跨进程并发由 provider 定义** — seam 不做跨进程串行化;并发写入者按 provider 行为收敛(本地文件 provider 为后写胜出)。 +- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 为后写胜出)。 - **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index bec60dfdea..10b21c2154 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -7,7 +7,6 @@ */ import { Context, Service } from 'cordis' -import { deepEqual } from 'cosmokit' import type z from 'schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' @@ -59,16 +58,23 @@ export interface SettingsScope { /** Current resolved value: schema defaults, then `base`, then the user layer. */ get(): T /** - * Observe committed changes to this namespace's resolved value. + * Observe committed changes to this namespace's resolved value. A callback + * may be async; a rejection is contained and logged like a sync throw. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ - watch(callback: (next: T, prev: T) => void): () => void + watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. * @param patch - plain-object patch over the user section. */ update(patch: object): Promise + /** + * Replace this namespace's user section wholesale; absent keys re-inherit + * the composition `base` and schema defaults (`replace({})` resets all). + * @param section - the complete next user section. + */ + replace(section: object): Promise } declare module 'cordis' { @@ -91,6 +97,28 @@ declare module 'cordis' { } } +/** + * Deep equality over JSON-shaped data (objects, arrays, primitives) — the + * seam's single change-detection predicate, exported so the invariant + * companion checks exactly the implementation's relation. + * @param a - one JSON-shaped value. + * @param b - the other JSON-shaped value. + * @returns whether the two values are structurally equal. + */ +export function deepEqualJson(a: unknown, b: unknown): boolean { + if (a === b) return true + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((entry, index) => deepEqualJson(entry, b[index])) + } + const left = a as Record + const right = b as Record + const keys = Object.keys(left) + if (keys.length !== Object.keys(right).length) return false + return keys.every(key => key in right && deepEqualJson(left[key], right[key])) +} + /** Whether a value is a plain data object (not an array, null, or class instance). */ function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false @@ -128,7 +156,7 @@ interface SettingsRegistration { base: unknown applies: SettingsApplies resolved: unknown - watchers: Set<(next: never, prev: never) => void> + watchers: Set<(next: never, prev: never) => void | Promise> } /** @@ -141,11 +169,22 @@ export abstract class Settings extends Service { private readonly registrations = new Map() /** Latest published raw document; empty until the provider's first publish. */ private document: Record = {} + /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ + private readonly writeQueues = new Map>() constructor(ctx: Context) { super(ctx, 'settings') } + /** + * Load the provider's document once and publish it before the service + * becomes injectable. Providers with their own init (watchers, connections) + * delegate here first via `yield* super[Service.init]()`. + */ + async* [Service.init](): AsyncGenerator<() => void, void, void> { + this.publish(await this.load()) + } + /** Whether {@link update} may persist through this provider. */ abstract readonly writable: boolean @@ -195,6 +234,7 @@ export abstract class Settings extends Service { return () => registration.watchers.delete(callback) }, update: patch => this.update(ns, patch), + replace: section => this.replace(ns, section), } } @@ -223,11 +263,30 @@ export abstract class Settings extends Service { /** * Merge a patch into one registered namespace's user layer, validate the * resolved candidate, persist through the provider, then commit and emit. - * A validation failure rejects before anything is persisted. + * A validation failure rejects before anything is persisted. Writes to one + * namespace are serialized: concurrent updates apply in call order, each + * merging over the previous write's committed section. * @param ns - the registered namespace to update. * @param patch - plain-object patch over the user section. */ async update(ns: SettingsNamespace, patch: object): Promise { + return this.write(ns, patch, 'merge') + } + + /** + * Replace one registered namespace's user section wholesale, validate, + * persist, then commit and emit. Keys absent from `section` fall back to the + * composition `base` and schema defaults — this is the removal/reset path a + * merge-only patch cannot express (`replace({})` re-inherits everything). + * @param ns - the registered namespace to replace. + * @param section - the complete next user section. + */ + async replace(ns: SettingsNamespace, section: object): Promise { + return this.write(ns, section, 'replace') + } + + /** Validate a write, then queue it on the namespace's serialized write chain. */ + private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise { const registration = this.registrations.get(ns) if (registration === undefined) { throw new Error(`settings namespace "${ns}" is not registered`) @@ -235,14 +294,23 @@ export abstract class Settings extends Service { if (!this.writable) { throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) } - if (!isPlainObject(patch)) { - throw new TypeError(`settings update for "${ns}" must be a plain object patch`) + if (!isPlainObject(input)) { + throw new TypeError(`settings ${mode === 'merge' ? 'update' : 'replace'} for "${ns}" must be a plain object`) } - const section = mergeLayers(this.section(ns) ?? {}, patch) as Record - const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) - await this.persist(ns, section) - this.document[ns] = section - this.commit(registration, next, 'update') + const previous = this.writeQueues.get(ns) ?? Promise.resolve() + // Chain past a failed predecessor: one rejected write must not poison the + // namespace queue for every later caller. + const run = previous.catch(() => undefined).then(async () => { + const section = mode === 'merge' + ? mergeLayers(this.section(ns) ?? {}, input) as Record + : structuredClone(input) + const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + await this.persist(ns, section) + this.document[ns] = section + this.commit(registration, next, 'update') + }) + this.writeQueues.set(ns, run) + return run } /** @@ -287,17 +355,38 @@ export abstract class Settings extends Service { /** Commit a resolved value when changed: swap, notify watchers, emit the event. */ private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void { const prev = registration.resolved - if (deepEqual(next, prev)) return + if (deepEqualJson(next, prev)) return registration.resolved = next for (const watcher of [...registration.watchers]) { try { - watcher(next as never, prev as never) + // A watcher may be async: adopt its promise so a rejection is contained + // here instead of surfacing as an unhandled rejection. + const outcome = watcher(next as never, prev as never) as unknown + if (outcome instanceof Promise) { + outcome.catch((error: unknown) => { + this.warnWatcherFailure(registration.ns, error) + }) + } } catch (error) { - this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns) - this.ctx.logger.warn(error) + this.warnWatcherFailure(registration.ns, error) } } - this.ctx.emit('settings/updated', registration.ns, next, prev, source) + try { + this.ctx.emit('settings/updated', registration.ns, next, prev, source) + } catch (error) { + // Invariant violations are harness-fatal by design; any other listener + // failure is contained so one broken observer cannot wedge the commit + // path (and, through it, a provider's reload loop). + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error + this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns) + this.ctx.logger.warn(error) + } + } + + /** Contained-watcher diagnostic shared by the sync and async failure paths. */ + private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void { + this.ctx.logger.warn('settings: watcher for "%s" failed', ns) + this.ctx.logger.warn(error) } } diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts index 235f413aae..d8db41bce4 100644 --- a/packages/settings/settings/src/invariant.ts +++ b/packages/settings/settings/src/invariant.ts @@ -5,6 +5,7 @@ import type { Context } from 'cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { deepEqualJson } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-settings' @@ -15,7 +16,9 @@ export const inject = ['invariants'] /** * Install the commit-event contract: `settings/updated` fires only for a - * currently registered namespace and only when the resolved value changed. + * currently registered namespace, only when the resolved value changed, and + * only with the service's authoritative resolved value — all judged with the + * seam's own equality predicate. */ const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { ctx.on('settings/updated', (ns, next, prev) => { @@ -23,10 +26,14 @@ const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { if (settings === undefined) { fail(`settings/updated for "${ns}" emitted without a live settings service`) } - if (settings.get(ns) === undefined) { + const current = settings.get(ns) + if (current === undefined) { fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`) } - if (JSON.stringify(next) === JSON.stringify(prev)) { + if (!deepEqualJson(current, next)) { + fail(`settings/updated for "${ns}" does not match the authoritative resolved value`) + } + if (deepEqualJson(next, prev)) { fail(`settings/updated for "${ns}" emitted without a resolved-value change`) } }) diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts index f12ff6126c..0976827368 100644 --- a/packages/settings/settings/tests/invariant.spec.ts +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -38,4 +38,15 @@ describe('settings invariants', () => { ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update') }).toThrow(/without a resolved-value change/) }) + + it('fails a settings/updated emission whose value diverges from the authoritative state', async () => { + const ctx = await setup(true) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + theme: z.string().default('dark'), + })) + // Fabricated next ≠ the service's current resolved value ({theme: 'dark'}). + expect(() => { + ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update') + }).toThrow(/authoritative/) + }) }) diff --git a/packages/settings/settings/tests/memory.ts b/packages/settings/settings/tests/memory.ts index 0b20771cbe..bb310c939d 100644 --- a/packages/settings/settings/tests/memory.ts +++ b/packages/settings/settings/tests/memory.ts @@ -5,7 +5,6 @@ * packages. */ -import { Service } from 'cordis' import { Settings, type SettingsNamespace } from '../src/index.ts' /** In-memory provider exposing the protected seam hooks to tests. */ @@ -17,13 +16,18 @@ export class MemorySettings extends Settings { /** When false, update() must reject before reaching persist(). */ writableFlag: boolean + /** Artificial persist latency so tests can interleave concurrent updates. */ + persistDelayMs: number + constructor(ctx: ConstructorParameters[0], options?: { doc?: Record writable?: boolean + persistDelayMs?: number }) { super(ctx) this.doc = structuredClone(options?.doc ?? {}) this.writableFlag = options?.writable ?? true + this.persistDelayMs = options?.persistDelayMs ?? 0 } get writable(): boolean { @@ -34,10 +38,12 @@ export class MemorySettings extends Settings { return Promise.resolve(structuredClone(this.doc)) } - protected persist(ns: SettingsNamespace, section: Record): Promise { + protected async persist(ns: SettingsNamespace, section: Record): Promise { + if (this.persistDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, this.persistDelayMs)) + } this.persisted.push({ ns, section: structuredClone(section) }) this.doc[ns] = structuredClone(section) - return Promise.resolve() } /** Simulate an external storage change reaching the provider. */ @@ -45,9 +51,4 @@ export class MemorySettings extends Settings { this.doc = structuredClone(doc) this.publish(structuredClone(doc)) } - - async* [Service.init](): AsyncGenerator<() => void, void, void> { - this.publish(await this.load()) - yield () => { this.persisted.length = 0 } - } } diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index a3d223c3f7..c413e948ed 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,9 +1,32 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { settingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' +/** A provider implementing only the three primitives: the seam owns init. */ +class BareProvider extends Settings { + doc: Record + + constructor(ctx: ConstructorParameters[0], options?: { doc?: Record }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + } + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} + interface ThemeConfig { theme: 'dark' | 'light' fontSize: number @@ -119,7 +142,7 @@ describe('registration', () => { inject: ['settings'], apply: (child: Context) => { scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - scope.watch(next => seen.push(next)) + scope.watch((next) => { seen.push(next) }) }, }) await fiber @@ -191,6 +214,9 @@ describe('update', () => { expect(provider.persisted).toEqual([]) expect(events).toEqual([]) expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) + // The failed write must not poison the namespace queue for later writers. + await scope.update({ fontSize: 18 }) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 }) }) it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => { @@ -206,6 +232,7 @@ describe('update', () => { const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await expect(scope.update([1])).rejects.toThrow(TypeError) await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError) + await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/) }) it('accepts a null-prototype patch object', async () => { @@ -231,6 +258,89 @@ describe('update', () => { }) }) +describe('deepEqualJson', () => { + it.each([ + [{ a: [1, 2] }, { a: [1, 2] }, true], + [{ a: [1, 2] }, { a: [1] }, false], + [{ a: [1] }, { a: { 0: 1 } }, false], + [{ a: 1 }, { b: 1 }, false], + [{ a: 1 }, {}, false], + [{ a: null }, { a: null }, true], + [{ a: null }, { a: {} }, false], + ])('compares %j vs %j as %s', (a, b, equal) => { + expect(deepEqualJson(a, b)).toBe(equal) + }) +}) + +describe('review regressions', () => { + it('propagates an invariant-coded listener failure instead of containing it', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }) + .toThrow(/forged relation/) + }) + + it('serializes concurrent updates so neither patch is lost', async () => { + const { ctx, provider } = await boot({ persistDelayMs: 10 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await Promise.all([ + scope.update({ theme: 'light' }), + scope.update({ fontSize: 20 }), + ]) + expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 }) + }) + + it('contains a throwing settings/updated listener and keeps later commits alive', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw new Error('listener boom') + }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow() + expect(scope.get().theme).toBe('light') + provider.pushExternal({ 'ui-theme': { theme: 'dark' } }) + expect(scope.get().theme).toBe('dark') + }) + + it('contains an async watcher rejection', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(async () => { + throw new Error('async watcher boom') + }) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(scope.get().theme).toBe('light') + // Give the rejected watcher promise a microtask turn; containment means + // vitest observes no unhandled rejection out of this test. + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('loads the provider document through the base init without provider boilerplate', async () => { + const ctx = new Context() + await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 }) + }) + + it('replaces the user section wholesale so overrides can be removed', async () => { + const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { fontSize: 16 }, + }) + await scope.replace({ theme: 'light' }) + // fontSize override is gone: resolution falls back to the base layer. + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) + await scope.replace({}) + expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 }) + expect(provider.doc['ui-theme']).toEqual({}) + }) +}) + describe('publish', () => { it('notifies watchers of an external change with source provider', async () => { const { ctx, provider } = await boot() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b7df3dc622..971576f35f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -189,6 +189,11 @@ export const LINK_MAP: Record = { ToolRegistry: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', + SettingsNamespace: 'settings.md', + SettingsRegisterOptions: 'settings.md', + SettingsScope: 'settings.md', + SettingsDescriptor: 'settings.md', + SettingsUpdateSource: 'settings.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', @@ -217,11 +222,6 @@ const FOUNDATION_TYPE_NAMES = new Set([ /** Project types deliberately documented outside the core-data catalog. */ const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', - SettingsNamespace: 'settings seam vocabulary is owned by packages/settings/settings/README.md', - SettingsUpdateSource: 'settings seam vocabulary is owned by packages/settings/settings/README.md', - SettingsRegisterOptions: 'settings seam vocabulary is owned by packages/settings/settings/README.md', - SettingsScope: 'settings seam vocabulary is owned by packages/settings/settings/README.md', - SettingsDescriptor: 'settings seam vocabulary is owned by packages/settings/settings/README.md', z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index e6cc11ae6e..28f854fe9b 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(18) + expect(translated).toHaveLength(19) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b607995003..7bd9c09177 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1308,6 +1308,36 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsNamespace", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsRegisterOptions", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsApplies", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsScope", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsDescriptor", + "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/settings.md", + "symbol": "SettingsUpdateSource", + "source": "packages/settings/settings/src/index.ts" } ] } diff --git a/website/docs.ts b/website/docs.ts index 1888cd908c..cbc8d9e627 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -256,6 +256,7 @@ const coreDataReference = pairedPages(([ ['sandbox.md', '沙箱', 'Sandboxing', 18], ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], + ['settings.md', '用户设置', 'User settings', 21], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`, From 1010291fe6cd764deff8e5e3056bb57f94c3fa4f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 10:07:28 +0800 Subject: [PATCH 10/97] fix(settings): close cross-namespace, dispatch, and lifecycle races from second review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed and fixed, each with a regression test that failed first: - Concurrent writes to different namespaces lost whole sections on disk (each persist rendered the full document from a stale text): the local provider serializes render->write->rename->text-commit on one internal persist chain shared by every namespace queue. - One throwing settings/updated listener starved the rest (cordis emit stops at the first throw): commit fans out per listener via events.dispatch, contains individual failures, and rethrows the first INVARIANT-coded error only after every listener ran. - Write queues ignored fiber/service lifecycle: the base init now registers a teardown that refuses new writes and drains queued chains; queued tasks re-verify service liveness and namespace ownership before running and again before committing, so a registrant disposed mid-flight is never notified and a disposed service never commits. - Async watcher invocations could interleave (a slow stale call applied last): each watcher carries a serialized invocation chain — one call at a time, in commit order; JSDoc/doc pages state the async timing. - update/replace borrowed the caller's object until the queued task ran: inputs are structured-clone snapshotted at call time; non-cloneable plain objects reject with a typed error. - Composition guard now proves the documented fallback: the consumer uses the optional scoped-inject shape and boots both with the settings entry (hot publish) and without it (entry-config resolution, no scope). - core-data-structures index: settings.md row added to the sub-page table in core.md/core.zh.md. Both packages hold per-file 100% coverage across repeated runs. --- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + docs/core-data-structures/settings.i18n.yaml | 4 +- docs/core-data-structures/settings.md | 5 +- docs/core-data-structures/settings.zh.md | 5 +- docs/event-producer-consumer.md | 2 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 14 +- .../tests/loader-composition.spec.ts | 63 +++++-- .../settings-local/tests/local.spec.ts | 20 +++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 3 +- packages/settings/settings/README.zh.md | 3 +- packages/settings/settings/src/index.ts | 114 +++++++++---- .../settings/settings/tests/settings.spec.ts | 157 +++++++++++++++++- 20 files changed, 339 insertions(+), 71 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 74e1e6cfda..44fcc62e88 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -662,7 +662,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:96`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:97`](../../packages/settings/settings/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e915952ffb..c2e5451699 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1442,7 +1442,7 @@ async replace(ns: SettingsNamespace, section: object): Promise Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:168`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:176`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index f0d078c123..d0bdbc5dd2 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 docs/core-data-structures/core.md -core.md: 647c7f273183e0890ef191d98ab009ad129db572 -core.zh.md: 8b0f439f09a6e6609dbe69c3056aa74d553a0943 +core.md: ca8426e6fbece18a277cc31f5e86d3058b3feb32 +core.zh.md: 6b297ca233428d38dbe022e58344e98fab7a15ad diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 647c7f2731..ca8426e6fb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -24,6 +24,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | +| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 8b0f439f09..6b297ca233 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -24,6 +24,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index 6dc8750dfb..cca43c251b 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.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 docs/core-data-structures/settings.md -settings.md: 851087065c627f2390041dd6e69273e31be3917d -settings.zh.md: 955c4fbf7c147b3d0a8be62c33c031d7bd2c4ba2 +settings.md: abbfecb35f67b27e16dffb9558a35cf368c90beb +settings.zh.md: c746e3cc181f8347cbe634b9231cfee1b3beecd3 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index 851087065c..abbfecb35f 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -46,8 +46,9 @@ interface SettingsScope { /** Current resolved value: schema defaults, then `base`, then the user layer. */ get(): T /** - * Observe committed changes to this namespace's resolved value. A callback - * may be async; a rejection is contained and logged like a sync throw. + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index 955c4fbf7c..c746e3cc18 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -46,8 +46,9 @@ interface SettingsScope { /** Current resolved value: schema defaults, then `base`, then the user layer. */ get(): T /** - * Observe committed changes to this namespace's resolved value. A callback - * may be async; a rejection is contained and logged like a sync throw. + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e3eb1d9435..74645a1674 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:96`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:97`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 455255941a..5d44f50f9d 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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 packages/settings/settings-local/README.md -README.md: 9d0aa3982507ca16b382aaa918ca1536a98e29ea -README.zh.md: 075de7ee5b3e0ef0a4ee27eeb8cb098ca0d73456 +README.md: af8df7c030757b330e034a1c46507fbe75c9bab8 +README.zh.md: fc8943263b339baad1a92a1d0b0977b926e40f6e diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index 9d0aa39825..af8df7c030 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -19,6 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. - **Write-back is atomic, owner-only, and symlink-proof.** `persist` exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. +- **Cross-namespace writes serialize on one document.** Every namespace shares the file, so persists from different namespace queues chain internally; each render sees the text the previous write committed. - **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 075de7ee5b..fc8943263b 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -19,6 +19,7 @@ - **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 - **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 +- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。 - **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 2c020da63c..b305f61fe7 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -87,6 +87,8 @@ export class SettingsLocal extends Settings { private text: string | undefined /** Serializes watcher-triggered reloads so reads never interleave. */ private refreshTask: Promise = Promise.resolve() + /** Serializes whole-document writes across namespace queues; settled tail. */ + private persistChain: Promise = Promise.resolve() /** Set at dispose: refuse new watcher events and let in-flight work no-op. */ private closed = false @@ -121,7 +123,17 @@ export class SettingsLocal extends Settings { return doc } - protected async persist(ns: SettingsNamespace, section: Record): Promise { + protected persist(ns: SettingsNamespace, section: Record): Promise { + // One document backs every namespace, so writes from different namespace + // queues must serialize here: each render must see the text the previous + // write committed, or the loser's section silently vanishes from disk. + // The stored tail is settled on both outcomes, so chaining needs no catch. + const task = this.persistChain.then(() => this.persistSection(ns, section)) + this.persistChain = task.then(() => undefined, () => undefined) + return task + } + + private async persistSection(ns: SettingsNamespace, section: Record): Promise { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts index d584c11899..c7cea89e41 100644 --- a/packages/settings/settings-local/tests/loader-composition.spec.ts +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -1,7 +1,9 @@ /** * Real-composition guard: the provider and a consumer plugin boot from a - * test-only cordis.yml through the actual Loader + Include path, and an - * external edit of settings.yaml hot-publishes into the consumer's scope. + * test-only cordis.yml through the actual Loader + Include path, an external + * edit of settings.yaml hot-publishes into the consumer's scope, and the same + * consumer booted WITHOUT a settings entry keeps its entry-config resolution — + * the documented optional-inject fallback. */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -39,33 +41,50 @@ afterEach(async () => { interface ConsumerState { scope: SettingsScope | undefined seen: ThemeConfig[] + /** What the consumer is actually running with, settings or not. */ + applied: ThemeConfig | undefined } -async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> { +async function loadComposition( + options?: { withSettings?: boolean }, +): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> { + const withSettings = options?.withSettings ?? true root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-')) const settingsPath = join(root, 'settings.yaml') await writeFile(settingsPath, 'ui-theme:\n theme: light\n') - const state: ConsumerState = { scope: undefined, seen: [] } + const state: ConsumerState = { scope: undefined, seen: [], applied: undefined } const consumer = { name: 'settings-consumer', - inject: ['settings'], apply: (ctx: Context) => { - const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { - base: { fontSize: 16 }, + // The documented consumer shape: no hard dependency — entry config alone + // is the running state, and the scoped inject overlays the user layer + // only while a settings service exists. + const base: Partial = { fontSize: 16 } + state.applied = ThemeSchema(base as ThemeConfig) + ctx.inject(['settings'], (child: Context) => { + const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base }) + state.scope = scope + state.applied = scope.get() + scope.watch((next) => { + state.seen.push(next) + state.applied = next + }) }) - state.scope = scope - scope.watch((next) => { state.seen.push(next) }) }, } const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ - '- id: settings', - " name: '@deepseek-ai/dsh-settings-local'", - ' config:', - ` path: ${JSON.stringify(settingsPath)}`, - ' debounceMs: 10', + ...withSettings + ? [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + ] + : [], '- id: consumer', ' name: test-settings-consumer', '', @@ -100,7 +119,9 @@ describe('settings-local real composition', () => { const { ctx, state, settingsPath } = await loadComposition() // Composition resolution: user layer over the consumer's composition base. - expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 }) + await vi.waitFor(() => { + expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme']) await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n') @@ -109,4 +130,16 @@ describe('settings-local real composition', () => { }, { timeout: 5000 }) expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 }) }) + + it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => { + const { ctx, state } = await loadComposition({ withSettings: false }) + + // No settings service anywhere in the composition… + expect(ctx.get('settings')).toBeUndefined() + // …so the consumer runs on schema defaults plus its composition base, and + // never receives a scope. + expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 }) + expect(state.scope).toBeUndefined() + expect(state.seen).toEqual([]) + }) }) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 1c74429153..4c3c24ccd9 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -146,6 +146,23 @@ describe('persist', () => { expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) }) + it('serializes cross-namespace writes into one on-disk document', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema) + const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema) + await Promise.all([ + alpha.update({ theme: 'light' }), + beta.update({ fontSize: 20 }), + ]) + const text = await readFile(path, 'utf8') + expect(text).toContain('alpha:') + expect(text).toContain('beta:') + expect(alpha.get().theme).toBe('light') + expect(beta.get().fontSize).toBe(20) + }) + it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') @@ -209,6 +226,9 @@ describe('persist', () => { await chmod(dir, 0o700) expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) expect(scope.get().theme).toBe('light') + // The failed persist must not poison the document write chain. + await scope.update({ theme: 'dark' }) + expect(scope.get().theme).toBe('dark') }) it('round-trips a json document', async () => { diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 6f5e5c6beb..63a274dd4d 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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 packages/settings/settings/README.md -README.md: f7858a247f6011cd0654a73b5325d81c118441e5 -README.zh.md: 67ecba695066389bfe3a69f517d52f91b48b6c75 +README.md: ff6cdeb57a265dbaa9d5f50de1d558f1e3cb581f +README.zh.md: d820a5c1fa804455c439f1a155e7628f5118a49b diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index f7858a247f..ff6cdeb57a 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -11,7 +11,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document - `get(ns)` — resolved value, `undefined` while unregistered. - `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). -- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures — sync throws and async rejections alike — are contained. +- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest. +- Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody. ## Provider contract diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 67ecba6950..d820a5c1fa 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -11,7 +11,8 @@ - `get(ns)` — 解析值;未注册时为 `undefined`。 - `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 - `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。 -- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`;观察者异常——同步抛出与异步拒绝——均被隔离。 +- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener。 +- 服务卸载先拒绝新写入并排干全部排队写入后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 ## Provider 契约 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 10b21c2154..a7e9366048 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -58,8 +58,9 @@ export interface SettingsScope { /** Current resolved value: schema defaults, then `base`, then the user layer. */ get(): T /** - * Observe committed changes to this namespace's resolved value. A callback - * may be async; a rejection is contained and logged like a sync throw. + * Observe committed changes to this namespace's resolved value. Invocations + * of one callback run asynchronously, one at a time, in commit order; a + * rejection is contained and logged like a sync throw. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ @@ -149,6 +150,13 @@ function deepFreeze(value: T): T { return Object.freeze(value) } +/** One registered watcher and its serialized invocation chain. */ +interface SettingsWatcher { + callback: (next: never, prev: never) => void | Promise + /** Settled tail: invocations of this callback run one at a time, in commit order. */ + tail: Promise +} + /** One live namespace registration owned by a registrant fiber. */ interface SettingsRegistration { ns: SettingsNamespace @@ -156,7 +164,7 @@ interface SettingsRegistration { base: unknown applies: SettingsApplies resolved: unknown - watchers: Set<(next: never, prev: never) => void | Promise> + watchers: Set } /** @@ -171,6 +179,13 @@ export abstract class Settings extends Service { private document: Record = {} /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ private readonly writeQueues = new Map>() + /** Set at service dispose: refuse new writes while queued ones drain. */ + private stopped = false + + /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */ + private isStopped(): boolean { + return this.stopped + } constructor(ctx: Context) { super(ctx, 'settings') @@ -178,10 +193,17 @@ export abstract class Settings extends Service { /** * Load the provider's document once and publish it before the service - * becomes injectable. Providers with their own init (watchers, connections) - * delegate here first via `yield* super[Service.init]()`. + * becomes injectable, and register the write-drain teardown. Providers with + * their own init (watchers, connections) delegate here first via + * `yield* super[Service.init]()`; their disposers then run before the drain. */ - async* [Service.init](): AsyncGenerator<() => void, void, void> { + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + yield async () => { + // Teardown: refuse new writes, then wait until every queued write chain + // settles so disposal completes only once storage is quiescent. + this.stopped = true + await Promise.allSettled([...this.writeQueues.values()]) + } this.publish(await this.load()) } @@ -230,8 +252,9 @@ export abstract class Settings extends Service { return { get: () => registration.resolved as T, watch: (callback) => { - registration.watchers.add(callback) - return () => registration.watchers.delete(callback) + const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve() } + registration.watchers.add(watcher) + return () => registration.watchers.delete(watcher) }, update: patch => this.update(ns, patch), replace: section => this.replace(ns, section), @@ -287,27 +310,50 @@ export abstract class Settings extends Service { /** Validate a write, then queue it on the namespace's serialized write chain. */ private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise { + const verb = mode === 'merge' ? 'update' : 'replace' const registration = this.registrations.get(ns) if (registration === undefined) { throw new Error(`settings namespace "${ns}" is not registered`) } + if (this.isStopped()) { + throw new Error(`settings service is disposed: "${ns}" cannot be written`) + } if (!this.writable) { throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`) } if (!isPlainObject(input)) { - throw new TypeError(`settings ${mode === 'merge' ? 'update' : 'replace'} for "${ns}" must be a plain object`) + throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`) + } + // Snapshot at call time: the queue must never read a caller-owned object + // the caller may keep mutating while the write waits its turn. + let snapshot: Record + try { + snapshot = structuredClone(input) + } catch { + throw new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped (structured-cloneable) data`) } const previous = this.writeQueues.get(ns) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the // namespace queue for every later caller. const run = previous.catch(() => undefined).then(async () => { + if (this.isStopped()) { + throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`) + } + if (this.registrations.get(ns) !== registration) { + throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`) + } const section = mode === 'merge' - ? mergeLayers(this.section(ns) ?? {}, input) as Record - : structuredClone(input) + ? mergeLayers(this.section(ns) ?? {}, snapshot) as Record + : snapshot const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) await this.persist(ns, section) + // The write reached storage either way; the cache must say so. Commit + // only when this registration is still the namespace owner — a fiber + // disposed (or replaced) mid-persist must not receive the notification. this.document[ns] = section - this.commit(registration, next, 'update') + if (this.registrations.get(ns) === registration && !this.isStopped()) { + this.commit(registration, next, 'update') + } }) this.writeQueues.set(ns, run) return run @@ -358,29 +404,35 @@ export abstract class Settings extends Service { if (deepEqualJson(next, prev)) return registration.resolved = next for (const watcher of [...registration.watchers]) { + // Serialize per watcher: invocations of one callback run one at a time + // in commit order, so a slow stale invocation can never apply after a + // newer one. Sync throws and async rejections land in the same handler. + watcher.tail = watcher.tail + .then(() => watcher.callback(next as never, prev as never)) + .then(() => undefined, (error: unknown) => { + this.warnWatcherFailure(registration.ns, error) + }) + } + // Fan the event out one listener at a time (the plain emit stops at the + // first throwing listener, starving the rest). Invariant violations are + // harness-fatal by design and rethrow after every listener ran; any other + // failure is contained so one broken observer cannot wedge the commit + // path (and, through it, a provider's reload loop). + let invariantFailure: unknown + const args = ['settings/updated', registration.ns, next, prev, source] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { try { - // A watcher may be async: adopt its promise so a rejection is contained - // here instead of surfacing as an unhandled rejection. - const outcome = watcher(next as never, prev as never) as unknown - if (outcome instanceof Promise) { - outcome.catch((error: unknown) => { - this.warnWatcherFailure(registration.ns, error) - }) - } + listener(registration.ns, next, prev, source) } catch (error) { - this.warnWatcherFailure(registration.ns, error) + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { + invariantFailure ??= error + continue + } + this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns) + this.ctx.logger.warn(error) } } - try { - this.ctx.emit('settings/updated', registration.ns, next, prev, source) - } catch (error) { - // Invariant violations are harness-fatal by design; any other listener - // failure is contained so one broken observer cannot wedge the commit - // path (and, through it, a provider's reload loop). - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error - this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns) - this.ctx.logger.warn(error) - } + if (invariantFailure !== undefined) throw invariantFailure as Error } /** Contained-watcher diagnostic shared by the sync and async failure paths. */ diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index c413e948ed..a989d9a5cc 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -52,9 +52,10 @@ const NestedSchema: z = z.object({ async function boot(options?: ConstructorParameters[1]) { const ctx = new Context() - await ctx.plugin(MemorySettings, options) + const fiber = ctx.plugin(MemorySettings, options) + await fiber const provider = ctx.get('settings') as MemorySettings - return { ctx, provider } + return { ctx, provider, fiber } } /** Record every settings/updated emission. */ @@ -341,6 +342,144 @@ describe('review regressions', () => { }) }) +describe('second review regressions', () => { + it('runs every settings/updated listener even when an earlier one throws', async () => { + const { ctx, provider } = await boot() + ctx.on('settings/updated', () => { + throw new Error('first listener boom') + }) + const second = vi.fn() + ctx.on('settings/updated', second) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('rejects an update queued after the registrant fiber disposed', async () => { + const { ctx } = await boot() + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + }, + }) + await fiber + await fiber.dispose() + await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/) + }) + + it('does not notify a registrant disposed while its update was in flight', async () => { + const { ctx, provider } = await boot({ persistDelayMs: 30 }) + const events = recordUpdates(ctx) + let scope: SettingsScope | undefined + const watcher = vi.fn() + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + scope.watch(watcher) + }, + }) + await fiber + const pending = scope!.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await pending.catch(() => undefined) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(watcher).not.toHaveBeenCalled() + expect(events).toEqual([]) + // The persist was already in flight, so storage keeps the write — but no + // commit reached the disposed registration. + expect(provider.doc['ui-theme']).toEqual({ theme: 'light' }) + }) + + it('drains in-flight writes at service dispose and rejects later ones', async () => { + const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 }) + const service = ctx.settings + const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema) + const pending = scope.update({ theme: 'light' }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + // The teardown drained the in-flight write before completing… + await pending.catch(() => undefined) + const persistedAtDispose = provider.persisted.length + expect(persistedAtDispose).toBe(1) + // …and afterwards nothing writes and new writes reject. + await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' })) + .rejects.toThrow(/disposed|not registered/) + await new Promise(resolve => setTimeout(resolve, 40)) + expect(provider.persisted.length).toBe(persistedAtDispose) + }) + + it('serializes invocations of one async watcher in commit order', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const applied: number[] = [] + let firstCall = true + scope.watch(async (next) => { + // The first (stale) invocation is slow; unserialised it would finish + // last and clobber the newer applied state. + const delay = firstCall ? 30 : 0 + firstCall = false + await new Promise(resolve => setTimeout(resolve, delay)) + applied.push(next.fontSize) + }) + provider.pushExternal({ 'ui-theme': { fontSize: 1 } }) + provider.pushExternal({ 'ui-theme': { fontSize: 2 } }) + await vi.waitFor(() => { + expect(applied).toHaveLength(2) + }) + expect(applied).toEqual([1, 2]) + }) + + it('rejects a plain object that is not structured-cloneable', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await expect(scope.update({ theme: () => 'dark' })) + .rejects.toThrow(/JSON-shaped/) + }) + + it('rejects a write still queued when the service disposes', async () => { + const { ctx, fiber } = await boot({ persistDelayMs: 20 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const first = scope.update({ theme: 'light' }) + const second = scope.update({ fontSize: 20 }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await first + await expect(second).rejects.toThrow(/disposed before the queued/) + }) + + it('rejects a write still queued when the registrant disposes', async () => { + const { ctx } = await boot({ persistDelayMs: 20 }) + let scope: SettingsScope | undefined + const fiber = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + }, + }) + await fiber + const first = scope!.update({ theme: 'light' }) + const second = scope!.update({ fontSize: 20 }) + await new Promise(resolve => setTimeout(resolve, 5)) + await fiber.dispose() + await first + await expect(second).rejects.toThrow(/registration was disposed before the queued/) + }) + + it('snapshots the patch at call time so caller mutation cannot leak in', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const patch = { fontSize: 18 } + const pending = scope.update(patch) + patch.fontSize = 99 + await pending + expect(scope.get().fontSize).toBe(18) + }) +}) + describe('publish', () => { it('notifies watchers of an external change with source provider', async () => { const { ctx, provider } = await boot() @@ -349,10 +488,12 @@ describe('publish', () => { const watcher = vi.fn() scope.watch(watcher) provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(watcher).toHaveBeenCalledWith( - { theme: 'light', fontSize: 14 }, - { theme: 'dark', fontSize: 14 }, - ) + await vi.waitFor(() => { + expect(watcher).toHaveBeenCalledWith( + { theme: 'light', fontSize: 14 }, + { theme: 'dark', fontSize: 14 }, + ) + }) expect(events[0]!.source).toBe('provider') }) @@ -410,7 +551,9 @@ describe('watch', () => { const second = vi.fn() scope.watch(second) provider.pushExternal({ 'ui-theme': { theme: 'light' } }) - expect(second).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(second).toHaveBeenCalledTimes(1) + }) expect(events).toHaveLength(1) expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) }) From 42de60347acbf5953747f4ad4208f5bb53011a9b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 10:26:29 +0800 Subject: [PATCH 11/97] docs: raise packages/README.md budget ceiling to 850 The group table legitimately gained one row for the new settings group; the row itself is already condensed to the minimum. The intended raise missed the merge commit because a pipeline swallowed the failing edit's exit status. --- scripts/doc-budgets.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 80766e314d..48a570a20d 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 845 + "packages/README.md": 850 } From ba37180946fcfbf2c0125b01856f96a13e72acac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 12:59:41 +0800 Subject: [PATCH 12/97] feat(util): extract dsh-atomic-write and migrate settings-local writes writeFileAtomic: exclusive-create random-suffix temp + rename carrying the caller-stated mode; settings-local persistSection now consumes it. The credentials-local store shares it next. --- packages/settings/settings-local/package.json | 2 + packages/settings/settings-local/src/index.ts | 21 ++------ .../settings/settings-local/tsconfig.json | 3 ++ packages/util/atomic-write/README.md | 30 +++++++++++ packages/util/atomic-write/README.zh.md | 30 +++++++++++ packages/util/atomic-write/package.json | 37 ++++++++++++++ packages/util/atomic-write/src/index.ts | 50 +++++++++++++++++++ packages/util/atomic-write/src/invariant.ts | 30 +++++++++++ .../atomic-write/tests/atomic-write.spec.ts | 48 ++++++++++++++++++ .../util/atomic-write/tests/invariant.spec.ts | 18 +++++++ packages/util/atomic-write/tsconfig.json | 15 ++++++ pnpm-lock.yaml | 12 +++++ tsconfig.host.json | 1 + 13 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 packages/util/atomic-write/README.md create mode 100644 packages/util/atomic-write/README.zh.md create mode 100644 packages/util/atomic-write/package.json create mode 100644 packages/util/atomic-write/src/index.ts create mode 100644 packages/util/atomic-write/src/invariant.ts create mode 100644 packages/util/atomic-write/tests/atomic-write.spec.ts create mode 100644 packages/util/atomic-write/tests/invariant.spec.ts create mode 100644 packages/util/atomic-write/tsconfig.json diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index aefb1ccd33..0040b65507 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -38,6 +39,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index b305f61fe7..057f974a5e 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -8,10 +8,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { dirname, extname, join, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' +import { extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -137,19 +137,8 @@ export class SettingsLocal extends Settings { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) - await mkdir(dirname(this.spec.filename), { recursive: true }) - // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to - // follow any planted symlink at a guessable temp path, and the fresh inode - // carries owner-only permissions that survive the rename — a document that - // may hold personal values is never world-readable and never a symlink. - const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) - await rename(temp, this.spec.filename) - } catch (error) { - await rm(temp, { force: true }) - throw error - } + // 0600: a document that may hold personal values is never world-readable. + await writeFileAtomic(this.spec.filename, output, { mode: 0o600 }) this.text = output } diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json index 67a746c982..cf5b68fc11 100644 --- a/packages/settings/settings-local/tsconfig.json +++ b/packages/settings/settings-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/paths" }, diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md new file mode 100644 index 0000000000..42c65c820a --- /dev/null +++ b/packages/util/atomic-write/README.md @@ -0,0 +1,30 @@ +# dsh-atomic-write + +English | [中文](README.zh.md) + +Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`). + +## Surface + +```ts +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +``` + +One export. The contract, in the order failures would exploit it: + +- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. +- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). +- **`rename` replaces a symlinked target itself**, never writing through to its referent. +- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. +- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. + +## Model Experience + +None, as this is a pure filesystem primitive; nothing here reaches a model request. + +## Known Limitations and Deferred Work + +- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. +- **String content only** — no `Buffer` or stream form until a consumer needs one. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md new file mode 100644 index 0000000000..4a59eaea9d --- /dev/null +++ b/packages/util/atomic-write/README.zh.md @@ -0,0 +1,30 @@ +# dsh-atomic-write + +[English](README.md) | 中文 + +零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 + +## 接口面 + +```ts +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +``` + +仅一个导出。契约按攻击面利用顺序列出: + +- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 +- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 +- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 +- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 +- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。 + +## Model Experience + +None, as this is a pure filesystem primitive; nothing here reaches a model request. + +## Known Limitations and Deferred Work + +- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。 +- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json new file mode 100644 index 0000000000..147ecb1e05 --- /dev/null +++ b/packages/util/atomic-write/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-atomic-write", + "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts new file mode 100644 index 0000000000..f4a20c10bc --- /dev/null +++ b/packages/util/atomic-write/src/index.ts @@ -0,0 +1,50 @@ +/** + * Zero-dependency atomic file replacement. `writeFileAtomic` writes a + * random-suffix sibling with exclusive create and the caller's permission + * bits, then renames it over the target, so readers observe either the old or + * the new complete content and a replaced file ends up with exactly the + * stated mode. + * @module @deepseek-ai/dsh-atomic-write + */ + +import { randomBytes } from 'node:crypto' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** + * Filesystem options for {@link writeFileAtomic}; `mode` is required so the + * permission decision stays visible at every call site. + */ +export interface WriteFileAtomicOptions { + /** + * Permission bits stamped on the fresh temp inode and carried through the + * rename (subject to the process umask, like every fresh inode). + */ + mode: number +} + +/** + * Replace `filename` with `content` in one atomic step, creating parent + * directories. The content is first written to a random-suffix sibling opened + * with exclusive create (`wx`): the open refuses to follow a symlink planted + * at the temp path, and the fresh inode carries `options.mode` through the + * rename, so replacing a wider-permission file narrows it without a chmod + * race. The rename also replaces a symlinked target itself instead of writing + * through to its referent, and the same-directory sibling keeps the rename on + * one filesystem. On any failure the temp file is removed and the failure + * rethrown. Crash durability (fsync) is out of scope. + * @param filename - final path receiving the content. + * @param content - complete next file content. + * @param options - permission bits for the replacement inode. + */ +export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { + await mkdir(dirname(filename), { recursive: true }) + const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) + await rename(temp, filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } +} diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts new file mode 100644 index 0000000000..4027dd9bda --- /dev/null +++ b/packages/util/atomic-write/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. + * @module @deepseek-ai/dsh-atomic-write/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' + +/** Cordis companion plugin name. */ +export const name = 'atomic-write-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime + * data; its replacement contract is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts new file mode 100644 index 0000000000..2bc9d3ab6a --- /dev/null +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -0,0 +1,48 @@ +import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { writeFileAtomic } from '../src/index.ts' + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) +} + +describe('writeFileAtomic', () => { + it('creates the file and its parents with exactly the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'nested', 'deep', 'doc.yaml') + await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('a: 1\n') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'doc.yaml') + await writeFile(target, 'old', { mode: 0o644 }) + await writeFileAtomic(target, 'new', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('new') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces a symlinked target itself without writing through to the referent', async () => { + const dir = await scratch() + const victim = join(dir, 'victim') + await writeFile(victim, 'victim-content') + const target = join(dir, 'doc.yaml') + await symlink(victim, target) + await writeFileAtomic(target, 'replaced', { mode: 0o600 }) + expect((await lstat(target)).isSymbolicLink()).toBe(false) + expect(await readFile(target, 'utf8')).toBe('replaced') + expect(await readFile(victim, 'utf8')).toBe('victim-content') + }) + + it('leaves no temp sibling and rethrows when the rename fails', async () => { + const dir = await scratch() + const target = join(dir, 'occupied') + await mkdir(target) + await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() + expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) + }) +}) diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts new file mode 100644 index 0000000000..c80346762c --- /dev/null +++ b/packages/util/atomic-write/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AtomicWriteInvariant from '../src/invariant.ts' + +describe('atomic-write invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(AtomicWriteInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/atomic-write/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c78b285e..2036f68aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3895,6 +3895,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4985,6 +4988,15 @@ 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/util/atomic-write: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + 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/util/brand: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/tsconfig.host.json b/tsconfig.host.json index e01245b0f6..92eeea6a94 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, + { "path": "./packages/util/atomic-write" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, From 3a794495ad1ab208e32a827bbb8f98379fb91aba Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:03:12 +0800 Subject: [PATCH 13/97] feat(credentials): abstract credential seam (ctx.credentials) References-not-values doctrine: settings carry env-shaped CredentialRefs, providers own storage. Per-operation resolve, UI-safe describe, fail-loud set/unset under read-only shadowing, credentials/updated commit event with a live-service invariant. --- packages/credentials/credentials/README.md | 45 +++++++ packages/credentials/credentials/README.zh.md | 45 +++++++ packages/credentials/credentials/package.json | 39 ++++++ packages/credentials/credentials/src/index.ts | 114 ++++++++++++++++++ .../credentials/credentials/src/invariant.ts | 38 ++++++ .../credentials/tests/credentials.spec.ts | 71 +++++++++++ .../credentials/tests/invariant.spec.ts | 37 ++++++ .../credentials/credentials/tests/memory.ts | 51 ++++++++ .../credentials/credentials/tsconfig.json | 24 ++++ pnpm-lock.yaml | 12 ++ tsconfig.base.json | 2 + tsconfig.host.json | 1 + 12 files changed, 479 insertions(+) create mode 100644 packages/credentials/credentials/README.md create mode 100644 packages/credentials/credentials/README.zh.md create mode 100644 packages/credentials/credentials/package.json create mode 100644 packages/credentials/credentials/src/index.ts create mode 100644 packages/credentials/credentials/src/invariant.ts create mode 100644 packages/credentials/credentials/tests/credentials.spec.ts create mode 100644 packages/credentials/credentials/tests/invariant.spec.ts create mode 100644 packages/credentials/credentials/tests/memory.ts create mode 100644 packages/credentials/credentials/tsconfig.json diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md new file mode 100644 index 0000000000..48b7b0f952 --- /dev/null +++ b/packages/credentials/credentials/README.md @@ -0,0 +1,45 @@ +# dsh-credentials + +English | [中文](README.zh.md) + +Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: + +**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. + +**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. + +**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. + +## Surface + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +``` + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. + +The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. +- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md new file mode 100644 index 0000000000..ef9e32ebad --- /dev/null +++ b/packages/credentials/credentials/README.zh.md @@ -0,0 +1,45 @@ +# dsh-credentials + +[English](README.md) | 中文 + +抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: + +**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 + +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 + +**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 + +## 接口面 + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 +await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 +await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +``` + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 + +`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带秘密。 + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 +- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json new file mode 100644 index 0000000000..d907b0a1bb --- /dev/null +++ b/packages/credentials/credentials/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-credentials", + "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts new file mode 100644 index 0000000000..2df89132ac --- /dev/null +++ b/packages/credentials/credentials/src/index.ts @@ -0,0 +1,114 @@ +/** + * Credential seam (`ctx.credentials`). Settings and composition files carry + * *references* to secrets — environment-variable names — while providers own + * the actual values and their storage. Consumers resolve a reference once per + * operation, so a changed credential reaches the next operation without any + * plugin restart, and configuration surfaces describe a reference without + * ever seeing its value. + * @module @deepseek-ai/dsh-credentials + */ + +import { Context, Service } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +export type CredentialRef = Branded<'CredentialRef'> + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Brand a raw string as a {@link CredentialRef}. + * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`. + * @returns the branded reference. + */ +export function credentialRef(value: string): CredentialRef { + if (!REF_PATTERN.test(value)) { + throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) + } + return value as CredentialRef +} + +/** One resolved credential value and the source layer that supplied it. */ +export interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} + +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +export interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} + +declare module 'cordis' { + interface Context { + credentials: Credentials + } + + interface Events { + /** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ + 'credentials/updated'(ref: CredentialRef): void + } +} + +/** + * Abstract credential service. Providers implement the four operations over + * their source layers; one seam-wide rule binds them all: an empty stored + * value is absent everywhere — `resolve` skips it, `describe` reports it + * unconfigured — so a blank never masquerades as a configured secret. + */ +export abstract class Credentials extends Service { + constructor(ctx: Context) { + super(ctx, 'credentials') + } + + /** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ + abstract resolve(ref: CredentialRef): Promise + + /** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ + abstract describe(ref: CredentialRef): Promise + + /** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ + abstract set(ref: CredentialRef, value: string): Promise + + /** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ + abstract unset(ref: CredentialRef): Promise +} + +export default Credentials diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts new file mode 100644 index 0000000000..23c2dda45b --- /dev/null +++ b/packages/credentials/credentials/src/invariant.ts @@ -0,0 +1,38 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials`. + * @module @deepseek-ai/dsh-credentials/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' + +/** Cordis companion plugin name. */ +export const name = 'credentials-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event lifecycle contract: `credentials/updated` names a + * committed provider-source change, so it can only fire while a credentials + * service is live — an emission after disposal means a provider leaked work + * past its teardown quiescence. The value relation itself (`describe` + * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by + * each provider's own suite. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('credentials/updated', (ref) => { + if (ctx.get('credentials') === undefined) { + fail(`credentials/updated for "${ref}" emitted without a live credentials service`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts new file mode 100644 index 0000000000..9b4cf7b1e8 --- /dev/null +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { credentialRef } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +async function boot(seed: Record = {}): Promise { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, seed) + return ctx +} + +describe('credentialRef', () => { + it('brands POSIX shell identifiers', () => { + expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') + expect(credentialRef('_private')).toBe('_private') + expect(credentialRef('lower_case9')).toBe('lower_case9') + }) + + it('rejects every other shape', () => { + for (const invalid of ['', '9LEADING', 'WITH-DASH', 'WITH SPACE', 'ns:key']) { + expect(() => credentialRef(invalid)).toThrow(TypeError) + } + }) +}) + +describe('the credentials seam through the memory provider', () => { + it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) + expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) + }) + + it('treats an empty stored value as absent everywhere', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) + }) + + it('stores through set, removes through unset, and emits the committed change', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await ctx.credentials.set(REF, 'sk-live') + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) + await ctx.credentials.unset(REF) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(events).toEqual([REF, REF]) + }) + + it('rejects an empty set and keeps an absent unset silent', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) + await ctx.credentials.unset(REF) + expect(events).toEqual([]) + }) + + it('removes the service with its fiber', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(MemoryCredentials) + expect(ctx.get('credentials')).toBeDefined() + await fiber.dispose() + expect(ctx.get('credentials')).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts new file mode 100644 index 0000000000..dccde4843f --- /dev/null +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { credentialRef } from '../src/index.ts' +import * as CredentialsInvariant from '../src/invariant.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +describe('credentials invariant companion', () => { + it('accepts a committed change emitted by a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + await ctx.plugin(MemoryCredentials) + + await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() + }) + + it('fails an update event emitted without a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) + }) + + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-credentials', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts new file mode 100644 index 0000000000..c562d8ab0a --- /dev/null +++ b/packages/credentials/credentials/tests/memory.ts @@ -0,0 +1,51 @@ +import type { Context } from 'cordis' +import { Credentials } from '../src/index.ts' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' + +/** + * In-memory credentials provider for interface and consumer tests: one + * always-writable `memory` source seeded from plugin config. + */ +export class MemoryCredentials extends Credentials { + private readonly store = new Map() + + constructor(ctx: Context, seed: Record = {}) { + super(ctx) + for (const [key, value] of Object.entries(seed)) this.store.set(key, value) + } + + override resolve(ref: CredentialRef): Promise { + const value = this.store.get(ref) + return Promise.resolve(value === undefined || value.length === 0 + ? undefined + : { value, source: 'memory' }) + } + + override describe(ref: CredentialRef): Promise { + const value = this.store.get(ref) + const configured = value !== undefined && value.length > 0 + return Promise.resolve({ + configured, + ...configured ? { source: 'memory' } : {}, + writable: true, + }) + } + + override set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) + } + this.store.set(ref, value) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } + + override unset(ref: CredentialRef): Promise { + if (this.store.delete(ref)) { + this.ctx.emit('credentials/updated', ref) + } + return Promise.resolve() + } +} + +export default MemoryCredentials diff --git a/packages/credentials/credentials/tsconfig.json b/packages/credentials/credentials/tsconfig.json new file mode 100644 index 0000000000..5bc7a9fcf5 --- /dev/null +++ b/packages/credentials/credentials/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2036f68aa6..31ff52fe9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2062,6 +2062,18 @@ 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/credentials/credentials: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + 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/acp-demo: devDependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.base.json b/tsconfig.base.json index a93c67b304..9ffb5618d7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,6 +88,7 @@ "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/settings/*/src/invariant.ts", + "./packages/credentials/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -173,6 +174,7 @@ "./packages/session-query/*/src", "./packages/session-title/*/src", "./packages/settings/*/src", + "./packages/credentials/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 92eeea6a94..67e340e898 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -64,6 +64,7 @@ { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, + { "path": "./packages/credentials/credentials" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, From aee06097ee5311b81234b6cfe6eef5385732a959 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:14:20 +0800 Subject: [PATCH 14/97] feat(credentials): file-backed provider layering process env over $DSH_HOME/.env Live environment wins read-only (shadowed writes reject instead of appearing to succeed); the file is the writable source with byte-preserving line edits, a quoting ladder dotenv reads back verbatim, atomic 0600 writes, wholesale snapshot replacement on reload, and write-drain teardown. --- .../credentials/credentials-local/README.md | 46 +++ .../credentials-local/README.zh.md | 46 +++ .../credentials-local/package.json | 48 +++ .../credentials-local/src/index.ts | 332 ++++++++++++++++++ .../credentials-local/src/invariant.ts | 31 ++ .../credentials-local/tests/drain.spec.ts | 67 ++++ .../credentials-local/tests/local.spec.ts | 244 +++++++++++++ .../credentials-local/tests/watcher.spec.ts | 207 +++++++++++ .../credentials-local/tsconfig.json | 33 ++ pnpm-lock.yaml | 34 ++ tsconfig.host.json | 1 + 11 files changed, 1089 insertions(+) create mode 100644 packages/credentials/credentials-local/README.md create mode 100644 packages/credentials/credentials-local/README.zh.md create mode 100644 packages/credentials/credentials-local/package.json create mode 100644 packages/credentials/credentials-local/src/index.ts create mode 100644 packages/credentials/credentials-local/src/invariant.ts create mode 100644 packages/credentials/credentials-local/tests/drain.spec.ts create mode 100644 packages/credentials/credentials-local/tests/local.spec.ts create mode 100644 packages/credentials/credentials-local/tests/watcher.spec.ts create mode 100644 packages/credentials/credentials-local/tsconfig.json diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md new file mode 100644 index 0000000000..55b73c2ac6 --- /dev/null +++ b/packages/credentials/credentials-local/README.md @@ -0,0 +1,46 @@ +# dsh-credentials-local + +English | [中文](README.zh.md) + +File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. + +| Layer | Source id | Writable | Wins | +|---|---|---|---| +| Live process environment | `env` | no | always | +| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | + +The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `path` | `/.env` | Credentials document location. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | +| `watch` | `true` | Hot-publish external edits. | +| `debounceMs` | `100` | Watcher write-settle window. | + +## The document + +dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`. + +Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. + +## Hot reload + +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. + +## Model Experience + +Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly. +- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. +- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. +- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md new file mode 100644 index 0000000000..2563c973a7 --- /dev/null +++ b/packages/credentials/credentials-local/README.zh.md @@ -0,0 +1,46 @@ +# dsh-credentials-local + +[English](README.md) | 中文 + +文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。 + +| 层 | 来源 id | 可写 | 优先 | +|---|---|---|---| +| 活跃进程环境 | `env` | 否 | 恒定优先 | +| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | + +环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `path` | `/.env` | 凭据文档位置。 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | +| `watch` | `true` | 热发布外部编辑。 | +| `debounceMs` | `100` | watcher 写入沉降窗口。 | + +## 文档本身 + +dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`。 + +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 + +## 热重载 + +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 + +## Model Experience + +Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。 +- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 +- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json new file mode 100644 index 0000000000..0b8924d7f2 --- /dev/null +++ b/packages/credentials/credentials-local/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-credentials-local", + "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) 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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", + "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "dotenv": "^17.2.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts new file mode 100644 index 0000000000..853a1ce015 --- /dev/null +++ b/packages/credentials/credentials-local/src/index.ts @@ -0,0 +1,332 @@ +/** + * File-backed credentials provider layering the live process environment over + * a `$DSH_HOME/.env` document. The environment is authoritative and read-only + * (a launch-time override must win, and must be visibly read-only rather than + * silently shadow writes); the file is the provider-managed writable source: + * `set`/`unset` rewrite only their own line and preserve every other byte, + * external edits hot-publish through the seam, and each reload replaces the + * snapshot wholesale so a deleted entry never lingers in memory. + * @module @deepseek-ai/dsh-credentials-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { parse } from 'dotenv' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/.env`. + * @param config - raw plugin config. + * @returns the resolved file location and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + return { + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** Match the physical line(s) assigning one reference (ref chars need no escaping). */ +function refLinePattern(ref: CredentialRef): RegExp { + return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`) +} + +/** Values that survive a dotenv round-trip without quoting. */ +const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ + +/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ +function hasControlCharacters(value: string): boolean { + for (const char of value) { + if (char.charCodeAt(0) < 0x20) return true + } + return false +} + +/** + * Render one `KEY=value` line in the narrowest style dotenv reads back + * verbatim: bare, then single quotes (fully literal), then double quotes + * (safe only without backslashes, which double-quote reading expands). + * A value no style can represent fails loud instead of corrupting silently. + */ +function renderLine(ref: CredentialRef, value: string): string { + if (BARE_VALUE.test(value)) return `${ref}=${value}` + if (hasControlCharacters(value)) { + throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) + } + if (!value.includes('\'')) return `${ref}='${value}'` + if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` + throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) +} + +/** + * Replace, insert, or delete one reference's assignment while preserving every + * other byte. The first matching line is rewritten in place; further matches + * are dropped (dotenv reads the last one, so duplicates are dead weight that + * would otherwise override the edit). + */ +function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string { + const lines = text === undefined || text.length === 0 ? [] : text.split('\n') + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + const matcher = refLinePattern(ref) + const out: string[] = [] + let placed = false + for (const current of lines) { + if (matcher.test(current)) { + if (line !== undefined && !placed) { + out.push(line) + placed = true + } + continue + } + out.push(current) + } + if (line !== undefined && !placed) out.push(line) + return out.length === 0 ? '' : `${out.join('\n')}\n` +} + +/** File-backed credentials provider (`$DSH_HOME/.env`). */ +export class CredentialsLocal extends Credentials { + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last read or persisted document; `undefined` while the + * file is absent. Watcher events whose content equals this cache are no-ops, + * which is also the self-write suppression. + */ + private text: string | undefined + /** Parsed document snapshot; replaced wholesale on every reload. */ + private values = new Map() + /** Serializes watcher-triggered reloads so reads never interleave. */ + private refreshTask: Promise = Promise.resolve() + /** Serializes writes to the one document; settled tail. */ + private writeChain: Promise = Promise.resolve() + /** Set at dispose: refuse new writes and let in-flight work no-op. */ + private closed = false + + /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ + private isClosed(): boolean { + return this.closed + } + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + yield async () => { + // Drain: refuse new writes, then settle the queued ones so disposal + // completes only once storage is quiescent. + this.closed = true + await this.writeChain + } + await this.loadInitial() + if (!this.spec.watch) return + const watcher = chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + watcher.on('all', () => { + if (this.closed) return + this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the update fan-out can reject a + // refresh; keep the reload queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + yield async () => { + // Quiesce: stop accepting events, close the watcher, then wait out any + // queued or in-flight refresh so nothing publishes after disposal. + this.closed = true + await watcher.close() + await this.refreshTask + } + } + + override resolve(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) + return Promise.resolve(undefined) + } + + override describe(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + return Promise.resolve({ configured: true, source: 'env', writable: false }) + } + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) { + return Promise.resolve({ configured: true, source: 'file', writable: true }) + } + return Promise.resolve({ configured: false, writable: true }) + } + + override async set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) + } + await this.write(ref, value) + } + + override async unset(ref: CredentialRef): Promise { + await this.write(ref, undefined) + } + + /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ + private async write(ref: CredentialRef, value: string | undefined): Promise { + const verb = value === undefined ? 'unset' : 'set' + if (this.isClosed()) { + throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) + } + this.assertUnshadowed(ref, verb) + // The stored tail is settled on both outcomes, so chaining needs no catch + // and one rejected write can never poison the queue for later callers. + const previous = this.writeChain + const run = previous.then(async () => { + if (this.isClosed()) { + throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) + } + // Re-judged at run time: the environment may have changed while queued. + this.assertUnshadowed(ref, verb) + const existing = this.values.get(ref) + if (value === undefined && existing === undefined) return + if (existing !== undefined && existing.includes('\n')) { + throw new Error( + `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, + ) + } + const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + // 0600: a document holding secrets is never world-readable. + await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 }) + this.text = nextText + if (value === undefined) this.values.delete(ref) + else this.values.set(ref, value) + this.ctx.emit('credentials/updated', ref) + }) + this.writeChain = run.then(() => undefined, () => undefined) + return run + } + + /** Reject a write the live environment would shadow into apparent no-effect. */ + private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + throw new Error( + `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` + + ' shadowed; change the launching environment instead', + ) + } + } + + /** Boot read: an absent file is an empty store; any other failure is loud. */ + private async loadInitial(): Promise { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + return + } + this.text = text + this.values = new Map(Object.entries(parse(text))) + } + + /** + * Re-read the document after a watcher event. Unchanged content (including + * this provider's own writes) is a no-op; an unreadable document keeps the + * last good snapshot and warns — a live hot-reload must never take the + * process down. dotenv parsing is lenient by design and cannot fail. + */ + private async refresh(): Promise { + if (this.closed) return + let text: string | undefined + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) { + this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + text = undefined + } + if (text === this.text || this.isClosed()) return + const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const changed = this.changedRefs(this.values, next) + this.text = text + this.values = next + for (const ref of changed) this.ctx.emit('credentials/updated', ref) + } + + /** Seam-addressable entries whose effective (non-empty) value changed. */ + private changedRefs(prev: Map, next: Map): CredentialRef[] { + const changed: CredentialRef[] = [] + for (const key of new Set([...prev.keys(), ...next.keys()])) { + const before = prev.get(key) + const after = next.get(key) + const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined + const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined + if (effectiveBefore === effectiveAfter) continue + try { + changed.push(credentialRef(key)) + } catch (_unaddressableKey) { + // A key that is not a POSIX identifier is preserved file content the + // seam cannot address, so no observer could ever see it change. + } + } + return changed + } +} + +export default CredentialsLocal diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts new file mode 100644 index 0000000000..9ec75ed21d --- /dev/null +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`. + * @module @deepseek-ai/dsh-credentials-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local' + +/** Cordis companion plugin name. */ +export const name = 'credentials-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the + * `credentials/updated` lifecycle contract; this provider's file/environment layering is + * asynchronous I/O pinned by its unit suite. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts new file mode 100644 index 0000000000..6c05759b54 --- /dev/null +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +// The atomic write is the only asynchronous hold point inside a queued write; +// gating it makes the dispose-versus-queued-write race fully deterministic. +vi.mock('@deepseek-ai/dsh-atomic-write', () => { + let gate: Promise = Promise.resolve() + return { + writeFileAtomic: vi.fn(() => gate), + __setGate: (next: Promise) => { + gate = next + }, + } +}) + +async function setGate(next: Promise): Promise { + const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise) => void } + mocked.__setGate(next) +} + +const KEY = credentialRef('DSH_CRED_DRAIN_A') +const OTHER = credentialRef('DSH_CRED_DRAIN_B') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + await setGate(Promise.resolve()) + while (cleanups.length > 0) await cleanups.pop()!() +}) + +describe('write-drain teardown', () => { + it('lets the in-flight write land and fails the queued one after disposal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + const service = ctx.credentials + + let release!: () => void + await setGate(new Promise((resolveGate) => { + release = resolveGate + })) + const first = service.set(KEY, 'one') + // Let the first task pass its liveness checks and park on the gate, so it + // is genuinely in-flight when disposal begins. + await new Promise(resolvePause => setTimeout(resolvePause, 5)) + // Attach the rejection handler up front: the queued write fails while the + // drain is still awaited, before any later `await expect` could run. + const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/) + const disposal = fiber.dispose() + // Give the drain disposer its first turn (set closed) before opening the gate. + await new Promise(resolvePause => setTimeout(resolvePause, 10)) + release() + await disposal + + await expect(first).resolves.toBeUndefined() + await secondRejects + expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' }) + expect(await service.resolve(OTHER)).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts new file mode 100644 index 0000000000..4ebaed1a0c --- /dev/null +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal, resolveSpec } from '../src/index.ts' + +const KEY = credentialRef('DSH_CRED_TEST') +const OTHER = credentialRef('DSH_CRED_OTHER') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + vi.unstubAllEnvs() + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +function updates(ctx: Context): CredentialRef[] { + const seen: CredentialRef[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + return seen +} + +describe('resolveSpec', () => { + it('defaults to .env under the harness home with watching on', () => { + const spec = resolveSpec({ dshHome: '/custom/home' }) + expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + }) + + it('lets an explicit path win over the home', () => { + const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + }) +}) + +describe('layering and reads', () => { + it('treats an absent file as an empty writable store', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('serves file entries, including export-prefixed and quoted values', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) + expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + }) + + it('lets a non-empty process environment win read-only over the file', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', 'from-env') + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + }) + + it('treats empty values as absent in both layers', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', '') + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('fails boot loud when the document exists but cannot be read', async () => { + const dir = await tempDir() + const path = join(dir, 'occupied') + await mkdir(path) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow() + }) +}) + +describe('line-editing writes', () => { + it('appends a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.set(KEY, 'sk-fresh') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) + expect(seen).toEqual([KEY]) + }) + + it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(KEY, 'new value!') + expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + }) + + it('quotes hostile values so they round-trip through a fresh provider', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const singleQuoted = 'with "quote", back\\slash and space' + const doubleQuoted = "it's got an apostrophe" + await ctx.credentials.set(KEY, singleQuoted) + await ctx.credentials.set(OTHER, doubleQuoted) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + }) + + it('fails loud on values no .env quoting style reads back verbatim', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) + await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + }) + + it('unsets only the owning line and keeps an absent unset silent', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + await ctx.credentials.unset(KEY) + expect(seen).toEqual([KEY]) + }) + + it('rejects empty values, shadowed writes, and multi-line entries', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const ctx = await boot({ path, watch: false }) + + await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) + + vi.stubEnv('DSH_CRED_TEST', 'shadowing') + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) + }) + + it('leaves an empty document after unsetting the only entry', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=only\n') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('') + }) + + it('chains past a rejected write so one bad value cannot poison the queue', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const good = ctx.credentials.set(OTHER, 'lands') + await bad + await good + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + }) + + it('serializes concurrent writes so both land in the one document', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + await Promise.all([ + ctx.credentials.set(KEY, 'one'), + ctx.credentials.set(OTHER, 'two'), + ]) + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + }) + + it('refuses writes after disposal', async () => { + const dir = await tempDir() + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + // Capture the handle first: disposal also removes the ctx.credentials service. + const service = ctx.credentials + await fiber.dispose() + await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/) + }) +}) + +describe('real hot reload', () => { + it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + // Watching starts on an existing document: creation racing watcher setup + // is a chokidar readiness gap, not the reload contract under test. + await writeFile(path, 'DSH_CRED_TEST=boot\n') + const ctx = await boot({ path, debounceMs: 10 }) + const seen = updates(ctx) + + await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) + }) + + // Wholesale replacement: an entry deleted on disk never lingers in memory. + await writeFile(path, 'DSH_CRED_TEST=live\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() + }) + + const before = seen.length + await ctx.credentials.set(KEY, 'self-written') + await new Promise(resolvePause => setTimeout(resolvePause, 200)) + // Exactly the committed write's own event: the watcher echo of our own + // content is recognized by the text cache and publishes nothing extra. + expect(seen.length).toBe(before + 1) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..798cdc8a88 --- /dev/null +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +// chokidar is the nondeterministic OS boundary: faking it lets these tests +// drive the event pipeline (error events, races with unreadable files) +// deterministically. Real end-to-end watching stays covered by local.spec.ts. +vi.mock('chokidar', async () => { + const { EventEmitter } = await import('node:events') + class FakeWatcher extends EventEmitter { + close = vi.fn(() => Promise.resolve()) + } + const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] + return { + watch: vi.fn((path: string, options: unknown) => { + const watcher = new FakeWatcher() + instances.push({ path, options, watcher }) + return watcher + }), + __instances: instances, + } +}) + +interface FakeChokidar { + __instances: Array<{ + path: string + options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } + watcher: import('node:events').EventEmitter + }> +} + +async function fakeInstances(): Promise { + const chokidar = await import('chokidar') as unknown as FakeChokidar + return chokidar.__instances +} + +const KEY = credentialRef('DSH_CRED_PIPE') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +describe('watcher pipeline', () => { + it('clamps the write-settle poll interval for a zero debounce', async () => { + const dir = await tempDir() + await boot({ path: join(dir, '.env'), debounceMs: 0 }) + const [instance] = await fakeInstances() + expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) + }) + + it('survives a watcher error and keeps publishing later edits', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + + instance!.watcher.emit('error', new Error('watch backend failure')) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + + await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) + }) + }) + + it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=good\n') + const ctx = await boot({ path, debounceMs: 5 }) + + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + // The warn-and-keep path is asynchronous; give the serialized refresh a turn. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) + }) + + it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + let arm = true + ctx.on('credentials/updated', () => { + if (!arm) return + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const [instance] = await fakeInstances() + + await writeFile(path, 'DSH_CRED_PIPE=first\n') + instance!.watcher.emit('all', 'change', path) + // The snapshot commits before the fan-out, so the value lands even though + // the listener threw out of the refresh. + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) + }) + + arm = false + await writeFile(path, 'DSH_CRED_PIPE=second\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) + }) + }) + + it('quiesces the refresh pipeline before dispose completes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) + await fiber + let disposed = false + let postDisposeCommits = 0 + ctx.on('credentials/updated', () => { + if (disposed) postDisposeCommits += 1 + }) + + await writeFile(path, 'DSH_CRED_PIPE=changed\n') + const [instance] = await fakeInstances() + // Two queued refreshes: dispose interrupts one mid-flight and the other + // before it starts, so both closed guards must hold. + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('all', 'change', path) + await fiber.dispose() + disposed = true + instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(postDisposeCommits).toBe(0) + }) + + it('empties the snapshot when the document is deleted and emits the removals', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await rm(path) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'unlink', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) + expect(seen).toEqual([KEY]) + }) + + it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) + }) + // The dash-named key is preserved file content the seam cannot address: + // its change publishes nothing and breaks nothing. + expect(seen).toEqual([KEY]) + }) + + it('treats an event for a still-absent file as a no-op', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'add', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json new file mode 100644 index 0000000000..3acfbdeffe --- /dev/null +++ b/packages/credentials/credentials-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/paths" + }, + { + "path": "../credentials" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31ff52fe9d..52e7b070b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2074,6 +2074,34 @@ 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/credentials/credentials-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + dotenv: + specifier: ^17.2.0 + version: 17.4.2 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + 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/acp-demo: devDependencies: '@cordisjs/plugin-include': @@ -8637,6 +8665,10 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -13610,6 +13642,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/tsconfig.host.json b/tsconfig.host.json index 67e340e898..6bfe4d2cad 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -65,6 +65,7 @@ { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, { "path": "./packages/credentials/credentials" }, + { "path": "./packages/credentials/credentials-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, From f05ab3f9450e8b398a3f6c66e0e348713babe709 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:26:52 +0800 Subject: [PATCH 15/97] feat(llm-deepseek): per-request connection resolution over settings + credentials The adapter takes an options thunk and a per-stream credential resolver instead of frozen construction facts: base URL, catalog, defaults, idle budget, and the API key re-resolve at each operation, so a settings or credential change reaches the very next request while in-flight streams keep the facts they started with. resolveAdapterOptions is the one explicit resolve step (entry config fails loud at load; a live snapshot failing a beyond-schema bound keeps the last good options). The plugin layers its entry config under the optional llm-deepseek settings section and resolves keys literal-first through ctx.credentials with an ambient env fallback; a missing key now registers the route, warns, and fails each request with actionable MISSING_CREDENTIAL instead of failing plugin load. The registration-captured retry policy re-registers the route in place when it changes. --- packages/llm/llm-deepseek/package.json | 4 + packages/llm/llm-deepseek/src/adapter.ts | 109 ++++++----- packages/llm/llm-deepseek/src/index.ts | 170 +++++++++++++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 182 ++++++------------ .../llm-deepseek/tests/dynamic-config.spec.ts | 163 ++++++++++++++++ .../llm/llm-deepseek/tests/mock-server.ts | 82 ++++++++ packages/llm/llm-deepseek/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 8 files changed, 522 insertions(+), 200 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/dynamic-config.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/mock-server.ts diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index d233ef7764..2c39e2d920 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,8 +39,10 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ff5ce9bf72..0163dd3cab 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,21 +1,23 @@ /** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) - * chat-completions endpoint, emitting harness StreamChunks. + * chat-completions endpoint, emitting harness StreamChunks. The adapter is + * transport-only: connection facts arrive through a thunk resolved once per + * operation and the bearer token through a per-request resolver, so the + * registering plugin owns validation, layering, and credential policy. * * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ResolvedRetryPolicy, - RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' -import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -34,22 +36,37 @@ export interface DeepSeekCatalogModel { contextWindow?: number } -/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ -export interface DeepSeekAdapterOptions { - /** Bearer token sent in the `authorization` header on every request. */ - apiKey: string +/** + * Validated connection facts for one operation. The plugin's + * `resolveAdapterOptions` is the one explicit resolve step producing this + * shape; the adapter trusts it and re-reads it per operation, which is what + * makes a configuration change reach the next request without re-registration. + */ +export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** Request defaults applied to every call (thinking mode, effort). */ - defaults?: RequestDefaults + defaults: RequestDefaults /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ - models?: readonly DeepSeekCatalogModel[] + models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ - streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ - retryPolicy?: RetryPolicyConfig + streamIdleTimeoutMs: number + /** Provider-owned model-request retry policy, already resolved. */ + retryPolicy: ResolvedRetryPolicy +} + +/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */ +export interface DeepSeekAdapterOptions { + /** Current validated connection facts; called once per operation. */ + options: () => DeepSeekConnectionOptions + /** + * Resolve the bearer token for one request; called once per stream call and + * frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key + * is available anywhere. + */ + resolveApiKey: () => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -118,29 +135,8 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { - private readonly streamIdleTimeoutMs: number - private readonly retryPolicy: ResolvedRetryPolicy - - constructor(private readonly options: DeepSeekAdapterOptions) { + constructor(private readonly config: DeepSeekAdapterOptions) { super() - if (options.defaults?.thinking === 'disabled' - && options.defaults.reasoningEffort !== undefined - && options.defaults.reasoningEffort !== 'off') { - throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') - } - if (options.defaultContextWindow !== undefined - && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { - throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') - } - this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS - if (!Number.isFinite(this.streamIdleTimeoutMs) - || this.streamIdleTimeoutMs <= 0 - || this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { - throw new Error( - `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, - ) - } - this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { @@ -148,11 +144,11 @@ export class DeepSeekAdapter extends LlmAdapter { } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.retryPolicy + return this.config.options().retryPolicy } override listModels(provider: string): Promise { - return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model))) + return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) } override resolveModel( @@ -160,15 +156,16 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const configured = this.options.models?.find(entry => entry.id === model) + const connection = this.config.options() + const configured = connection.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? this.options.defaultContextWindow + ?? connection.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, - ...this.options.defaults?.thinking === 'disabled' + ...connection.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, @@ -178,9 +175,9 @@ export class DeepSeekAdapter extends LlmAdapter { : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: this.options.defaults?.reasoningEffort === 'off' + defaultEffort: connection.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT - : this.options.defaults?.reasoningEffort === 'max' + : connection.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, @@ -189,12 +186,17 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + // One resolution per stream call: connection facts and the credential + // freeze here and hold for this whole request, so an in-flight stream + // never observes a configuration change and the next call re-resolves. + const connection = this.config.options() + const apiKey = await this.config.resolveApiKey() const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) - using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]() + using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) + const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -208,7 +210,7 @@ export class DeepSeekAdapter extends LlmAdapter { } catch (error: unknown) { if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { throw new LlmError( - `DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`, + `DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }, ) @@ -217,7 +219,7 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) } if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error }) + throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }) } finally { consumer.abort('DeepSeek stream consumer stopped') if (!exhausted && iterator.return !== undefined) { @@ -230,13 +232,18 @@ export class DeepSeekAdapter extends LlmAdapter { } } - private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults ?? {}) + private async * request( + options: GenerateOptions, + signal: AbortSignal, + connection: DeepSeekConnectionOptions, + apiKey: string, + ): AsyncIterable { + const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) const headers = { - 'authorization': `Bearer ${this.options.apiKey}`, + 'authorization': `Bearer ${apiKey}`, 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), @@ -252,7 +259,7 @@ export class DeepSeekAdapter extends LlmAdapter { // outweighs its additional runtime dependencies. let response: Response try { - response = await fetch(`${this.options.baseURL}/chat/completions`, { + response = await fetch(`${connection.baseURL}/chat/completions`, { method: 'POST', headers, body: payload, @@ -266,7 +273,7 @@ export class DeepSeekAdapter extends LlmAdapter { // lives on `cause`. Wrapping with the endpoint and chaining the cause // lets `errorChain` render the full diagnosis at every reporting seam. throw new LlmError( - `DeepSeek API request to ${this.options.baseURL} failed`, + `DeepSeek API request to ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }, ) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 00db46d642..054f1984b0 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,41 +1,56 @@ /** - * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses - * Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`, - * as shown in the package README, rather than reading ad hoc files. + * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on + * `ctx.llm`, with connection facts resolved per request instead of frozen at + * load: the plugin layers its `cordis.yml` entry config under the optional + * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API + * key through the optional credential seam (`ctx.credentials`), so a changed + * base URL, catalog, or key reaches the very next request without restarting + * anything, while an in-flight stream keeps the facts it started with. The + * one registration-captured fact — the retry policy — re-registers the route + * in place when it changes. * @module @deepseek-ai/dsh-llm-deepseek */ import type { Context } from 'cordis' import z from 'schemastery' -import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' -import type { DeepSeekCatalogModel } from './adapter.ts' +import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' +export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export type { RequestDefaults } from './serialize.ts' export type * from './types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] +const NS = settingsNamespace('llm-deepseek') +const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' + const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 }, ] /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -60,7 +75,8 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['off', 'high', 'max']), @@ -73,6 +89,12 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' +/** Connection facts plus the plugin-consumed credential reference. */ +export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions { + /** Reference resolved per request when no literal key is configured. */ + apiKeyEnv: CredentialRef +} + /** Resolve, validate, and detach the advisory model catalog. */ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { const seen = new Set() @@ -98,20 +120,35 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee }) } -export function apply(ctx: Context, config: Config): void { +/** + * The one explicit resolve step from raw config to validated connection + * facts. Programmatic construction may bypass Schemastery normalization, so + * every default and bound is re-judged here — for the composition entry at + * load (fail loud) and for each settings snapshot at its first use. + * @param config - raw plugin config or resolved settings snapshot. + * @returns validated connection facts plus the credential reference. + */ +export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } - const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY - if (apiKey === undefined || apiKey.length === 0) { - throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)') + if (config.defaultContextWindow !== undefined + && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) { + throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') } - const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL - ctx.llm.registerAdapter(['deepseek'], new DeepSeekAdapter({ - apiKey, - baseURL, + const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(streamIdleTimeoutMs) + || streamIdleTimeoutMs <= 0 + || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + return { + apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), + baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -120,7 +157,92 @@ export function apply(ctx: Context, config: Config): void { ? {} : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), - streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, - ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, - })) + streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), + } +} + +export function apply(ctx: Context, config: Config): void { + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ResolvedDeepSeekOptions | undefined + const options = (): ResolvedDeepSeekOptions => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveAdapterOptions(raw) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing a beyond-schema bound: + // keep serving the last good facts and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + options() + + const resolveApiKey = async (): Promise => { + const raw = current() + if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey + const ref = options().apiKeyEnv + const credentials = ctx.get('credentials') + if (credentials !== undefined) { + const hit = await credentials.resolve(ref) + if (hit !== undefined) return hit.value + } else { + // Without the seam, keep the historical ambient fallback so a plain + // cordis.yml composition works from the environment alone. + const ambient = process.env[ref] + if (ambient !== undefined && ambient.length > 0) return ambient + } + throw new LlmError( + 'llm-deepseek: no API key for provider route "deepseek"; set the llm-deepseek "apiKey" setting,' + + ` store ${ref} with the credentials service, or export ${ref}`, + 'MISSING_CREDENTIAL', + ) + } + + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // even when a swap runs inside the scoped settings callback below. + let disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + let registeredPolicy = options().retryPolicy + const ensureRegistrationFacts = (): void => { + const policy = options().retryPolicy + if (deepEqualJson(policy, registeredPolicy)) return + // The registry captures the retry policy at registration, so it is the one + // fact per-request resolution cannot refresh: swap the registration in one + // synchronous section (same adapter instance, no NO_ADAPTER window). + disposeRoute() + disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + registeredPolicy = policy + } + + void resolveApiKey().then(() => undefined, () => { + // Expected on a first boot with dynamic sources: the route stays + // registered (the catalog is browsable) and each request fails with the + // actionable MISSING_CREDENTIAL message until a key arrives. + ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') + }) + + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(NS, Config, { base: config }) + current = () => scope.get() + sctx.effect(() => () => { + // Settings detached (provider disposed or reloading): fall back to the + // composition entry so the plugin keeps working exactly as configured. + current = () => config + ensureRegistrationFacts() + }) + ensureRegistrationFacts() + scope.watch(() => { + ensureRegistrationFacts() + }) + }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1b64c57982..935235d825 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, @@ -14,90 +12,18 @@ import LlmService, { createUserMessage, import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' - -/** One scripted behavior for the next request the mock server receives. */ -type Behavior = - | { kind: 'sse'; events: string[]; delayMs?: number } - | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } - | { kind: 'close-early'; events: string[] } - -interface MockServer { - url: string - /** Bodies of received requests, in order. */ - requests: unknown[] - /** Header bags of received requests, in order (parallel to `requests`). */ - headers: IncomingMessage['headers'][] - script: Behavior[] - close(): Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' afterEach(async () => { - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() vi.unstubAllEnvs() vi.useRealTimers() }) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ -async function mockServer(script: Behavior[]): Promise { - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - requests.push(JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() - if (!behavior) { - response.writeHead(500).end('mock script exhausted') - return - } - if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { - 'content-type': behavior.contentType ?? 'application/json', - ...behavior.headers, - }) - response.end(behavior.body) - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - const write = (index: number): void => { - if (index >= behavior.events.length) { - if (behavior.kind === 'sse') response.end() - else response.destroy() // close-early: drop the socket mid-stream - return - } - response.write(`data: ${behavior.events[index]}\n\n`) - setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) - } - write(0) - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - requests, - headers, - script, - close: () => new Promise(resolve => server.close(() => { resolve() })), - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, config: object = {}) { const ctx = new Context() await ctx.plugin(LlmService) @@ -105,6 +31,15 @@ async function harness(baseURL: string, config: object = {}) { return ctx } +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions(rest), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + }) +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -275,11 +210,7 @@ describe('DeepSeekAdapter against a mock server', () => { 'rejects direct adapter effort %s before I/O when thinking is disabled', async (effort) => { const server = await mockServer([]) - const adapter = new DeepSeekAdapter({ - apiKey: 'test-key', - baseURL: server.url, - defaults: { thinking: 'disabled' }, - }) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) const stream = adapter.stream({ provider: 'deepseek', @@ -483,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(null, { status: 200 }), ) @@ -538,7 +469,7 @@ describe('DeepSeekAdapter against a mock server', () => { it('maps connection failures to TRANSPORT without losing the cause', async () => { const cause = new TypeError('connection refused') const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -555,7 +486,7 @@ describe('DeepSeekAdapter against a mock server', () => { failed.reject('offline') return failed.promise }) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -585,11 +516,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) return Promise.resolve(new Response(body, { status: 200 })) }) - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) try { const drain = (async () => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -733,22 +660,15 @@ describe('plugin registration and config', () => { ) it.each(['high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the direct constructor boundary', + 'rejects disabled-thinking effort %s at the resolver boundary', (reasoningEffort) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort }, - })).toThrow(/only reasoningEffort "off"/) + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) }, ) - it('accepts disabled thinking with off at the direct constructor boundary', async () => { - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort: 'off' }, - }) + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({ reasoning: { efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], @@ -863,11 +783,8 @@ describe('plugin registration and config', () => { it.each([0, 1.5])( 'rejects invalid adapter-wide default context capacity %s', async (defaultContextWindow) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).toThrow(/defaultContextWindow must be a positive integer/) + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) const ctx = new Context() await ctx.plugin(LlmService) @@ -889,13 +806,19 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('throws a clear error when no API key is available', async () => { + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmDeepSeek, {})) - .rejects.toThrow(/an API key is required/) - expect(ctx.llm.listProviders()).toEqual([]) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // First-boot onboarding: the route registers so models stay discoverable; + // only the request itself needs a key. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/) }) it('prefers explicit config over env for key and base URL', async () => { @@ -927,23 +850,32 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('adapter is constructible directly for embedding', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() expect(adapter).toBeInstanceOf(DeepSeekAdapter) - await expect(adapter.listModels('deepseek')).resolves.toEqual([]) + // Direct embedding shares the plugin's one resolve step, so it advertises + // the same default catalog instead of a divergent empty one. + await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') }) it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: Number.POSITIVE_INFINITY, - })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).toThrow(/streamIdleTimeoutMs.*no greater/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..79a8afb671 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-llm-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +interface Harness { + ctx: Context + settingsFiber: { dispose(): Promise } +} + +/** + * Real dynamic composition: llm + settings-local + credentials-local + + * llm-deepseek over one temp harness home. `watch: false` keeps every change + * flowing through the in-process write path, which is deterministic; external + * file watching is the providers' own covered concern. + */ +async function boot(dir: string, config: object): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await settingsFiber + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, config) + return { ctx, settingsFiber } +} + +function prompt(ctx: Context) { + return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) +} + +describe('request-level dynamic configuration', () => { + it('routes the next request with the freshly resolved base URL and credential', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: serverA.url }) + + await prompt(ctx) + expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await ctx.credentials.set(KEY_REF, 'second-key') + + await prompt(ctx) + // No restart, no re-registration: the next request resolved both facts. + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') + }) + + it('prefers a literal settings apiKey over the credential layers', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await ctx.settings.update(NS, { apiKey: 'literal-key' }) + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer literal-key') + }) + + it('starts keyless and serves the next request once the key arrives', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await ctx.credentials.set(KEY_REF, 'sk-arrived') + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') + }) + + it('advertises a live settings catalog without re-registration', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'settings-model', name: 'From Settings' }, + ]) + }) + + it('re-registers the route in place when the captured retry policy changes', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await ctx.settings.update(NS, { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }) + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + }) + + it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + // Schema-valid but resolver-invalid: duplicate catalog ids pass the array + // schema and fail the explicit resolve step. + await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'recovered', name: 'recovered' }, + ]) + }) + + it('falls back to the composition entry when settings detach', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await prompt(ctx) + expect(serverB.requests).toHaveLength(1) + + await settingsFiber.dispose() + await prompt(ctx) + expect(serverA.requests).toHaveLength(1) + expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key') + }) +}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts new file mode 100644 index 0000000000..cdb499e143 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +/** One scripted behavior for the next request the mock server receives. */ +export type Behavior = + | { kind: 'sse'; events: string[]; delayMs?: number } + | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } + | { kind: 'close-early'; events: string[] } + +export interface MockServer { + url: string + /** Bodies of received requests, in order. */ + requests: unknown[] + /** Header bags of received requests, in order (parallel to `requests`). */ + headers: IncomingMessage['headers'][] + script: Behavior[] + close(): Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation, reused by request-shape assertions. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + '{"choices":[{"delta":{"content":"hello"}}]}', + '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local chat-completions stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: Behavior[]): Promise { + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() + if (!behavior) { + response.writeHead(500).end('mock script exhausted') + return + } + if (behavior.kind === 'http-error') { + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) + response.end(behavior.body) + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const write = (index: number): void => { + if (index >= behavior.events.length) { + if (behavior.kind === 'sse') response.end() + else response.destroy() // close-early: drop the socket mid-stream + return + } + response.write(`data: ${behavior.events[index]}\n\n`) + setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) + } + write(0) + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + headers, + script, + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e7b070b7..c7f3ae3f7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2949,12 +2949,18 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout From c0426142c5fc0a95e67e8ec7adadd8fbeb00e3db Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:35:38 +0800 Subject: [PATCH 16/97] feat(llm-pi-ai): route-keyed profiles with per-request resolution and in-place route swaps providers becomes a dict keyed by provider route, so the composition base and the llm-pi-ai settings section merge per provider and the route set is structural; the pre-release array shape and per-profile provider field fail loud with migration directions. The adapter reads a profiles thunk once per operation and resolves the credential per stream call (literal apiKey, then apiKeyEnv through ctx.credentials with an ambient env fallback, then pi-ai's provider-native discovery), so key, endpoint, and knob changes reach the next request without restarts. Route-set or captured-retry-policy changes re-register the same adapter instance in one synchronous section; an invalid settings snapshot keeps the last good profiles. --- packages/llm/llm-pi-ai/package.json | 4 + packages/llm/llm-pi-ai/src/adapter.ts | 41 ++-- packages/llm/llm-pi-ai/src/config.ts | 85 +++++--- packages/llm/llm-pi-ai/src/index.ts | 104 +++++++-- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 13 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 203 +++++++----------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 116 ++++++++++ packages/llm/llm-pi-ai/tests/mock-server.ts | 82 +++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 5 +- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 6 +- packages/llm/llm-pi-ai/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 12 files changed, 470 insertions(+), 201 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/mock-server.ts diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index e2b639624b..43b97a14f0 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,9 +39,11 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0cc6dda739..fd40c79c73 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -30,15 +30,20 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { resolveProfiles } from './config.ts' -import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}. */ +/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { - /** Validated provider profiles this adapter instance owns. */ - profiles: readonly PiAiProviderProfile[] + /** Current validated profiles by provider route; called once per operation. */ + profiles: () => ReadonlyMap + /** + * Resolve the credential for one already-resolved profile; called once per + * stream call and frozen for that call. `undefined` defers to pi-ai's + * provider-native ambient discovery. + */ + resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -46,7 +51,7 @@ export interface PiAiAdapterOptions { * override, preserving the catalog's API/capability/compatibility metadata. */ function resolvePiModel( - profile: Omit, + profile: ResolvedPiAiProviderProfile, modelId: string, ): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined @@ -58,12 +63,13 @@ function resolvePiModel( /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( - profile: Omit, + profile: ResolvedPiAiProviderProfile, reasoning: ModelThinkingLevel | undefined, + apiKey: string | undefined, ): SimpleStreamOptions { const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning return { - ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...apiKey === undefined ? {} : { apiKey }, ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, @@ -104,19 +110,16 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap - - constructor(options: PiAiAdapterOptions) { + constructor(private readonly config: PiAiAdapterOptions) { super() - this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.profiles.get(provider)?.retryPolicy + return this.config.profiles().get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -132,7 +135,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -165,7 +168,10 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - const profile = this.profiles.get(options.provider) + // One resolution per stream call: the profile snapshot and the credential + // freeze here and hold for this whole request, so an in-flight stream + // never observes a configuration change and the next call re-resolves. + const profile = this.config.profiles().get(options.provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } @@ -174,6 +180,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) + const apiKey = await this.config.resolveApiKey(profile) const consumer = new AbortController() const upstream = options.signal === undefined @@ -184,7 +191,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile, reasoning), + ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 8c7da2badd..b644527097 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,5 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. + * Profiles are a dict keyed by provider route, so the composition base and a + * user-settings layer merge per provider and the route set is structural. * * @module dsh-llm-pi-ai/config */ @@ -7,6 +9,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' @@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -42,18 +46,22 @@ export interface PiAiProviderProfile { retryPolicy?: RetryPolicyConfig } -/** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { +/** Validated profile with its route stamped and every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends Omit { + /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ + provider: string + /** Validated credential reference, when one is configured. */ + apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy } -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } const thinkingBudgets = z.object({ @@ -64,8 +72,8 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - provider: z.string().required(), - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string(), baseURL: z.string(), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -80,54 +88,61 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.array(profile).required(), + providers: z.dict(profile).required(), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * shallow copy suitable for adapter construction. - * @param profiles - configured provider profiles. + * route-keyed map suitable for per-request reads. + * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { - if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') +export function resolveProfiles(providers: Readonly>): Map { + if (Array.isArray(providers)) { + throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') + } + const entries = Object.entries(providers) + if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getBuiltinProviders()) - const seen = new Set() - return profiles.map((source) => { + const resolved = new Map() + for (const [provider, source] of entries) { const legacy = source as PiAiProviderProfile & { + provider?: unknown maxRetries?: unknown maxRetryDelayMs?: unknown } + if ('provider' in legacy) { + throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') + } if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') } - if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) - if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) + if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') + if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { throw new Error( - `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - seen.add(source.provider) - return { - ...source, + const { apiKeyEnv, retryPolicy, ...rest } = source + resolved.set(provider, { + ...rest, + provider, + ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - retryPolicy: resolveRetryPolicy( - source.retryPolicy, - `llm-pi-ai: provider "${source.provider}" retryPolicy`, - ), - ...source.headers === undefined ? {} : { headers: { ...source.headers } }, - ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, - } - }) + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, + ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + }) + } + return resolved } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index da104cb22d..4856dbe7e5 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,22 +1,27 @@ /** - * Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an - * explicit set of provider profiles; requests select a profile by provider and - * resolve the model dynamically from pi-ai's installed catalog. + * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of + * provider routes; requests select a profile by provider and resolve the + * model dynamically from pi-ai's installed catalog. Profile facts resolve per + * request over the optional `llm-pi-ai` user-settings section and the + * optional credential seam, so a changed key, endpoint, or knob reaches the + * next request without a restart; a changed *route set* (or a route's + * registration-captured retry policy) re-registers the same adapter instance + * in place. * * ```yaml * - id: llm * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: - * - provider: openai - * apiKey: !!js process.env.OPENAI_API_KEY + * openai: + * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 - * - provider: anthropic - * apiKey: !!js process.env.ANTHROPIC_API_KEY - * - provider: openrouter - * apiKey: !!js process.env.OPENROUTER_API_KEY + * anthropic: + * apiKeyEnv: ANTHROPIC_API_KEY + * openrouter: + * apiKeyEnv: OPENROUTER_API_KEY * baseURL: https://proxy.example.com/v1 * ``` * @@ -25,20 +30,93 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' +import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile } from './config.ts' +export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] +const NS = settingsNamespace('llm-pi-ai') + +/** The registry captures these per route; a change here must re-register. */ +function registrationFacts(profiles: ReadonlyMap): unknown { + return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) +} + /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { - const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles: config.providers }) - ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ReadonlyMap | undefined + const profiles = (): ReadonlyMap => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing catalog or bound checks: + // keep serving the last good profiles and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + profiles() + + const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise => { + if (profile.apiKey !== undefined) return profile.apiKey + const ref = profile.apiKeyEnv + if (ref === undefined) return undefined + const credentials = ctx.get('credentials') + if (credentials !== undefined) return (await credentials.resolve(ref))?.value + // Without the seam, keep an ambient fallback so a plain cordis.yml + // composition works from the environment alone; an empty variable defers + // to pi-ai's own provider-native discovery like an absent one. + const ambient = process.env[ref] + return ambient !== undefined && ambient.length > 0 ? ambient : undefined + } + + const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // even when a swap runs inside the scoped settings callback below. + let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + let registeredFacts = registrationFacts(profiles()) + const ensureRegistrationFacts = (): void => { + const facts = registrationFacts(profiles()) + if (deepEqualJson(facts, registeredFacts)) return + // The registry captures the route set and each route's retry policy at + // registration: swap the registration in one synchronous section (same + // adapter instance, no NO_ADAPTER window). + disposeRoutes() + disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + registeredFacts = facts + } + + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(NS, Config, { base: config }) + current = () => scope.get() + sctx.effect(() => () => { + // Settings detached (provider disposed or reloading): fall back to the + // composition entry so the plugin keeps working exactly as configured. + current = () => config + ensureRegistrationFacts() + }) + ensureRegistrationFacts() + scope.watch(() => { + ensureRegistrationFacts() + }) + }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 352f2067d8..a3a949fea2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial = {} contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'deepseek', - ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, - ...config, - }], + providers: { + deepseek: { + ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, + ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, + ...config, + }, + }, }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb70b6d3b8..02d7af5b2d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' @@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' - -interface MockServer { - url: string - paths: string[] - requests: unknown[] - headers: IncomingMessage['headers'][] - readonly closedResponses: number - responseClosed: Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' afterEach(async () => { vi.unstubAllEnvs() - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() }) -async function mockServer(script: { - status?: number - events?: string[] - body?: string - delayMs?: number - headers?: Record -}[]): Promise { - const paths: string[] = [] - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - let closedResponses = 0 - const responseClosed = Promise.withResolvers() - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - response.on('close', () => { - closedResponses += 1 - responseClosed.resolve(undefined) - }) - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - paths.push(request.url ?? '') - requests.push(body.length === 0 ? undefined : JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } - if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) - response.end(behavior.body ?? '{}') - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - let index = 0 - const writeNext = (): void => { - const event = behavior.events?.[index++] - if (event === undefined) { response.end(); return } - response.write(`data: ${event}\n\n`) - if (behavior.delayMs === undefined) writeNext() - else setTimeout(writeNext, behavior.delayMs) - } - writeNext() - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - paths, - requests, - headers, - responseClosed: responseClosed.promise, - get closedResponses() { return closedResponses }, - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }], + providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, }) return ctx } +/** Direct adapter over the real profile resolver, with literal-key resolution. */ +function adapterOf(providers: Record): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles(providers), + resolveApiKey: profile => Promise.resolve(profile.apiKey), + }) +} + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ - profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }], + ctx.llm.registerAdapter(['deepseek'], adapterOf({ + deepseek: { apiKey: 'test-key', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - apiKey: 'test-key', - baseURL: `${server.url}/api/projects/openai/openai/v1`, - headers: { 'api-key': 'test-key', Authorization: '' }, - }], + providers: { + openai: { + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key', Authorization: '' }, + }, + }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) expect(result.finish.kind).toBe('error') @@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [ - { - provider: 'openai', + providers: { + openai: { retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, }, }, - { provider: 'anthropic' }, - ], + anthropic: {}, + }, }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, @@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => { it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] }) + await ctx.plugin(LlmPiAi, { providers: { openai: {} } }) const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', @@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + providers: { deepseek: {}, openai: {} }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) @@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => { const supported = new Context() await supported.plugin(LlmService) await supported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'max' }], + providers: { deepseek: { reasoning: 'max' } }, }) await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) @@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => { const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'medium' }], + providers: { deepseek: { reasoning: 'medium' } }, }) await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) @@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => { const disabled = new Context() await disabled.plugin(LlmService) await disabled.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'off' }], + providers: { deepseek: { reasoning: 'off' } }, }) await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) @@ -443,24 +377,45 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) - it('validates empty, duplicate, unknown, and explicitly blank profiles', () => { - expect(() => resolveProfiles([])).toThrow(/at least one/) - expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/) - expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/) - expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) + it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') + }) + + it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', '') + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + }) + + it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { + expect(() => resolveProfiles({})).toThrow(/at least one/) + expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // The pre-release array shape and its per-profile provider field fail + // loud with migration directions instead of half-working. + expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) + expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) + expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) + expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) it.each(['maxRetries', 'maxRetryDelayMs'] as const)( 'rejects removed profile field %s instead of silently restoring hidden SDK retries', async (field) => { - const legacy = { provider: 'openai', [field]: 2 } - expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const legacy = { [field]: 2 } + expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i) const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } })) .rejects.toThrow(/removed.*agent recovery/i) }, ) @@ -476,30 +431,26 @@ describe('provider profile lifecycle', () => { for (const entry of invalid) { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } })) .rejects.toThrow() } }) it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { - expect(() => resolveProfiles([{ - provider: 'openai', - retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, - }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + expect(() => resolveProfiles({ + openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } }, + })).toThrow(/retryPolicy\.backoff\.jitterRatio/) const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - }], + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } }, })).rejects.toThrow(/retryPolicy/) expect(ctx.llm.listProviders()).toEqual([]) }) it('constructs the adapter directly and rejects routes it does not own', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) + const adapter = adapterOf({ openai: {} }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) @@ -511,12 +462,12 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) - it('validates direct-constructor profiles at the embedding boundary', () => { - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + it('validates profiles at the shared resolver boundary', () => { + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: 0 }, })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, })).toThrow(/streamIdleTimeoutMs.*no greater/) }) }) @@ -527,7 +478,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -548,7 +499,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -562,7 +513,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..5b7c9e5e3a --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-pi-ai') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +/** Real dynamic composition mirroring the deepseek twin's harness. */ +async function boot(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('request-level dynamic profiles', () => { + it('adds a provider route from settings and drops it when the user layer resets', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + }) + + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer live-key') + + // Reset the user layer: the settings-born route unregisters, the + // composition route stays. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + }) + + it('rotates the per-request credential referenced by apiKeyEnv', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + + await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + }) + + it('re-registers routes in place when a captured retry policy changes', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + await ctx.settings.update(NS, { + providers: { + openai: { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }, + }, + }) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) + + it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + // Schema-valid but catalog-invalid: the resolver rejects it and the + // last good route set keeps serving. + await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/mock-server.ts b/packages/llm/llm-pi-ai/tests/mock-server.ts new file mode 100644 index 0000000000..573c61a9a2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +export interface MockServer { + url: string + paths: string[] + requests: unknown[] + headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation in pi-ai's chat-completions shape. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local provider stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { + const paths: string[] = [] + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + paths.push(request.url ?? '') + requests.push(body.length === 0 ? undefined : JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } + if (behavior.status !== undefined && behavior.status !== 200) { + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) + response.end(behavior.body ?? '{}') + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + let index = 0 + const writeNext = (): void => { + const event = behavior.events?.[index++] + if (event === undefined) { response.end(); return } + response.write(`data: ${event}\n\n`) + if (behavior.delayMs === undefined) writeNext() + else setTimeout(writeNext, behavior.delayMs) + } + writeNext() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d08e9b93d..f59859bfad 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -43,12 +43,11 @@ async function harness(): Promise { contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: providerCases.map(profile => ({ - provider: profile.provider, + providers: Object.fromEntries(providerCases.map(profile => [profile.provider, { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, ...profile.headers === undefined ? {} : { headers: profile.headers }, - })), + }])), }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index d96297c242..3f12ef4460 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { }) import { PiAiAdapter } from '../src/adapter.ts' +import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) @@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const adapter = new PiAiAdapter({ + profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + resolveApiKey: () => Promise.resolve('test-key'), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'openai', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7f3ae3f7d..1f28171d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2977,6 +2977,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2986,6 +2989,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout From d77db29f01e798dcef9dcab7edf1fff95bfddbdb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:44:24 +0800 Subject: [PATCH 17/97] test: real-Loader dynamic composition, keyless onboarding snapshot, and .env-only e2e llm-deepseek gains a Loader+Include composition spec proving external settings.yaml/.env edits reach the very next request, and a real-API e2e where only a credentials-local document holds the key. The headless example pins the first-run missing-credential UX as a keyless stream-json snapshot (new credentials.cordis.snapshot.yml scenario); runLoaderSmoke learns expectedExitCode so a designed failure surface can be pinned instead of masked. --- .../credentials.cordis.snapshot.yml | 27 ++++ .../headless-agent/tests/headless.snapshot.ts | 33 ++++ .../stream-json.expected.jsonl | 8 + examples/package.json | 8 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 34 ++++- .../tests/loader-composition.spec.ts | 142 ++++++++++++++++++ packages/support/loader-smoke/src/index.ts | 12 +- .../loader-smoke/tests/loader-smoke.spec.ts | 27 +++- pnpm-lock.yaml | 6 + 9 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 examples/headless-agent/credentials.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl create mode 100644 packages/llm/llm-deepseek/tests/loader-composition.spec.ts diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml new file mode 100644 index 0000000000..7e85b90df7 --- /dev/null +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -0,0 +1,27 @@ +# Keyless dynamic-configuration composition: the settings and credentials +# providers live under the run cwd, no API key exists anywhere, and the +# deepseek route still registers — so the prompt fails with the actionable +# MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: settings + name: '@deepseek-ai/dsh-settings-local' + config: + dshHome: ./.dsh + debounceMs: 10 + - id: credentials + name: '@deepseek-ai/dsh-credentials-local' + config: + dshHome: ./.dsh + # The endpoint is never dialed: credential resolution fails first. + - id: llm-deepseek-keyless + name: '@deepseek-ai/dsh-llm-deepseek' + config: + baseURL: 'http://127.0.0.1:9' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 2581d8a042..fba7bf7338 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -27,6 +27,8 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') +const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -168,6 +170,37 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('surfaces actionable missing-credential guidance through the one-shot app', async () => { + const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'missing-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-missing-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // First-run posture: no key in the environment, none under ./.dsh. + DEEPSEEK_API_KEY: '', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + // The designed failure surface: the one-shot app reports the failed turn. + expectedExitCode: 1, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe( + 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' + + ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,' + + ' or export DEEPSEEK_API_KEY\n', + ) + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..d7d72f6a86 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -0,0 +1,8 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..35d9a74e3e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", @@ -32,7 +33,6 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", @@ -44,13 +44,15 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-settings-local": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f6d97031c4..e59af1185d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' @@ -53,6 +57,34 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('serves a real request with the key held only by a credentials-local document', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, {}) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + it('flash dynamically switches from off to high', async () => { const ctx = await harness(FLASH, { reasoningEffort: 'off' }) const withoutThinking = await assemble(ctx,{ diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9ca8c87367 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -0,0 +1,142 @@ +/** + * Real-composition guard for the dynamic-configuration chain: LlmService, + * settings-local, credentials-local, and llm-deepseek boot from a test-only + * cordis.yml through the actual Loader + Include path, external edits of + * settings.yaml and .env hot-publish through their providers, and the very + * next request carries the fresh base URL and credential. The same adapter + * composition without settings or credentials entries keeps entry-config + * behavior — the documented optional-inject fallback. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function loadComposition( + options: { withDynamic: boolean; baseURL: string }, +): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) + const settingsPath = join(root, 'settings.yaml') + const envPath = join(root, '.env') + if (options.withDynamic) { + await writeFile(settingsPath, '# personal settings\n') + await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + ...options.withDynamic + ? [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: credentials', + " name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: ${JSON.stringify(envPath)}`, + ' debounceMs: 10', + ] + : [], + '- id: llm-deepseek', + " name: '@deepseek-ai/dsh-llm-deepseek'", + ' config:', + ` baseURL: ${JSON.stringify(options.baseURL)}`, + ...options.withDynamic ? [] : [' apiKey: entry-key'], + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath, envPath } +} + +describe('llm-deepseek real dynamic composition', () => { + it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + + // External edits, exactly as a user or the web UI would leave them on disk. + await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) + await vi.waitFor(() => { + expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) + }, { timeout: 5000 }) + await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await vi.waitFor(async () => { + expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) + }, { timeout: 5000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + }) + + it('boots the same adapter without settings or credentials entries on entry config alone', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) + + expect(ctx.get('settings')).toBeUndefined() + expect(ctx.get('credentials')).toBeUndefined() + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + }) +}) diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index e573684a4e..4ee5cfec7d 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -141,6 +141,13 @@ export interface LoaderSmokeOptions { readonly prepare?: (cwd: string) => Promise | void /** Optional world-state assertion run in the isolated cwd before cleanup. */ readonly inspect?: (cwd: string) => Promise | void + /** + * Exact process exit code this smoke expects; defaults to `0`. Scenarios + * pinning a designed failure surface (a one-shot turn ending in an error + * result) declare its nonzero exit here, and a run that exits any other + * way — including succeeding — still fails the smoke. + */ + readonly expectedExitCode?: number } /** Captured output from a Loader smoke that exited successfully. */ @@ -187,8 +194,9 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { libBinScript: fixture('fail'), configPath, tsconfigPath, - })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') + })).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed') + }) + + it('accepts a declared expected failure exit and rejects any other outcome', async () => { + // A scenario pinning a designed failure surface declares its exit code… + const declared = await runLoaderSmoke({ + label: 'declared failure fixture', + tempDirPrefix: 'loader-smoke-declared-fail-', + binScript: fixture('fail'), + libBinScript: fixture('fail'), + configPath, + tsconfigPath, + expectedExitCode: 7, + }) + expect(declared.stderr).toBe('fixture failed\n') + + // …and a run that succeeds instead still fails the smoke. + await expect(runLoaderSmoke({ + label: 'unexpectedly clean fixture', + tempDirPrefix: 'loader-smoke-clean-', + binScript: fixture('success'), + libBinScript: fixture('success'), + configPath, + tsconfigPath, + expectedExitCode: 7, + })).rejects.toThrow(/exited 0 \(expected 7\)/) }) it('kills a process at its deadline and reports captured output', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f28171d9e..de87482bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:* version: link:../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:* + version: link:../packages/credentials/credentials-local '@deepseek-ai/dsh-fs-local': specifier: workspace:* version: link:../packages/fs/fs-local @@ -529,6 +532,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:* + version: link:../packages/settings/settings-local '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local From b0a2011d95277d4cbacfe2a05082a4f51c5db9f0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:20:06 +0800 Subject: [PATCH 18/97] docs: bilingual credentials/settings-consumer documentation, catalogs, and gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New credentials data-structure page (type-equiv manifested), group README, rewritten llm-deepseek/llm-pi-ai READMEs (dynamic configuration, dict profiles, credential chain), capability-seams/service-role registration, Agent Note (bilingual), demo compositions mounting settings-local + credentials-local with no inline key plumbing, installSettingsSection consumer helper on the settings seam (deduplicating both adapters' wiring), jscpd symmetry markers for the provider twins, runtime-closure additions for python/sdk-runtime, and doc-budget ceilings AGENTS.md 1750→1755 / packages/README.md 850→865 for the structural one-line group rows. --- ...est-level-llm-config-credentials.i18n.yaml | 6 +++ ...29-request-level-llm-config-credentials.md | 29 ++++++++++ ...request-level-llm-config-credentials.zh.md | 29 ++++++++++ AGENTS.md | 1 + docs/capability-seams.md | 12 ++++- docs/config-catalog.md | 53 +++++++++++++------ docs/cordis-catalog/events.md | 21 ++++++++ docs/cordis-catalog/services.md | 46 ++++++++++++++++ docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + .../credentials.i18n.yaml | 6 +++ docs/core-data-structures/credentials.md | 50 +++++++++++++++++ docs/core-data-structures/credentials.zh.md | 50 +++++++++++++++++ docs/event-producer-consumer.md | 1 + examples/headless-agent/composition.md | 6 +++ examples/headless-agent/cordis.yml | 25 ++++++--- .../credentials.cordis.snapshot.yml | 15 ++---- examples/package.json | 2 +- examples/tui-agent/composition.md | 6 +++ examples/tui-agent/cordis.yml | 14 ++++- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 41 ++++++++++++++ packages/credentials/README.i18n.yaml | 6 +++ packages/credentials/README.md | 14 +++++ packages/credentials/README.zh.md | 14 +++++ .../credentials-local/README.i18n.yaml | 6 +++ .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 16 +++--- .../credentials-local/src/index.ts | 8 +++ .../credentials/credentials/README.i18n.yaml | 6 +++ packages/credentials/credentials/README.md | 5 +- packages/credentials/credentials/README.zh.md | 29 +++++----- .../credentials/credentials/tests/memory.ts | 2 - packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 18 +++++-- packages/llm/llm-deepseek/README.zh.md | 18 +++++-- packages/llm/llm-deepseek/src/index.ts | 20 +++---- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 30 +++++++---- packages/llm/llm-pi-ai/README.zh.md | 30 +++++++---- packages/llm/llm-pi-ai/src/index.ts | 20 +++---- packages/settings/settings/src/index.ts | 50 +++++++++++++++++ .../settings/settings/tests/settings.spec.ts | 45 +++++++++++++++- packages/util/README.i18n.yaml | 6 +-- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/atomic-write/README.i18n.yaml | 6 +++ packages/util/atomic-write/README.md | 6 +++ packages/util/atomic-write/README.zh.md | 18 ++++--- pnpm-lock.yaml | 9 ++++ python/sdk-runtime/package.json | 25 +++++---- scripts/doc-budgets.manifest.json | 4 +- scripts/gen-cordis-catalog.ts | 3 ++ scripts/gen-doc-graphs.ts | 13 ++++- scripts/project-doc-site.spec.ts | 2 +- scripts/type-equiv.manifest.json | 15 ++++++ .../verify-package-readme-model-experience.ts | 3 ++ website/docs.ts | 1 + 61 files changed, 732 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md create mode 100644 docs/core-data-structures/credentials.i18n.yaml create mode 100644 docs/core-data-structures/credentials.md create mode 100644 docs/core-data-structures/credentials.zh.md create mode 100644 packages/credentials/README.i18n.yaml create mode 100644 packages/credentials/README.md create mode 100644 packages/credentials/README.zh.md create mode 100644 packages/credentials/credentials-local/README.i18n.yaml create mode 100644 packages/credentials/credentials/README.i18n.yaml create mode 100644 packages/util/atomic-write/README.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml new file mode 100644 index 0000000000..f54a4bdbe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea +2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md new file mode 100644 index 0000000000..13fefff9fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -0,0 +1,29 @@ +# Agent Note: request-level LLM configuration and the credential seam + +Status: implemented + +English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) + +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. + +## Problem + +The [settings seam](2026-07-28-user-settings-seam.md) shipped without a production consumer, and the LLM adapters were the motivating one: both froze `apiKey`/`baseURL`/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were also headed the wrong way: the natural move (put `apiKey` in the settings document) would have forced masking, server-side backfill on `replace`, and dotfiles-sync warnings, a mitigation stack for a problem peer products simply do not have — Codex (`env_key` + auth.json), Reasonix (`api_key_env` + home `.env`), OpenCode/Pi (`auth.json`), Claude Code (`apiKeyHelper`) all keep secrets out of configuration files. + +## Decision + +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. + +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. + +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions. + +## Alternatives considered + +- **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. +- **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. + +## Consequences + +Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md new file mode 100644 index 0000000000..5e29946ade --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -0,0 +1,29 @@ +# Agent Note:请求级 LLM 配置与凭据 seam + +Status: implemented + +[English](2026-07-29-request-level-llm-config-credentials.md) | 中文 + +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 + +## 问题 + +[settings seam](2026-07-28-user-settings-seam.md) 落地时没有生产消费方,而 LLM 适配器正是当初驱动该 seam 的那个消费方:两个适配器都在插件加载时把 `apiKey`/`baseURL`/catalog 冻结进适配器实例,改密钥或端点就要重启进程,密钥缺失则直接使插件加载失败——对个人配置页而言,这是最糟糕的首次运行姿态(「先存密钥,再重启」)。机密的走向也不对:顺理成章的做法(把 `apiKey` 放进设置文档)会被迫引入脱敏、`replace` 时的服务端回填与 dotfiles 同步告警,为一个同类产品根本没有的问题堆起一整摞缓解措施——Codex(`env_key` + auth.json)、Reasonix(`api_key_env` + 家目录 `.env`)、OpenCode/Pi(`auth.json`)、Claude Code(`apiKeyHelper`)全都把机密挡在配置文件之外。 + +## 决策 + +**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 + +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 + +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。 + +## 曾考虑的替代方案 + +- **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 +- **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 +- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 + +## 后果 + +上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。 diff --git a/AGENTS.md b/AGENTS.md index 4fb65caa11..959013e110 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends settings/ user-settings seam + file-backed provider + credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6d324f222a..8dbd7b964f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,9 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_local["settings-local"] + pkg_credentials["credentials"] + svc_credentials["ctx.credentials
Credential seam"] + pkg_credentials_local["credentials-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -167,6 +170,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_credentials --> svc_credentials + pkg_credentials_local --> svc_credentials pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -247,6 +252,8 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_credentials --> pkg_llm_deepseek + svc_credentials --> pkg_llm_pi_ai svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -284,6 +291,8 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess + svc_settings --> pkg_llm_deepseek + svc_settings --> pkg_llm_pi_ai svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain @@ -332,7 +341,8 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3dfe2c95f6..d3844268ac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -379,6 +379,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-credentials-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -562,15 +580,18 @@ Requires: `llm` ```ts config-catalog /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -602,25 +623,25 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -646,7 +667,7 @@ export interface PiAiProviderProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -2232,6 +2253,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@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-credentials` — abstract `Credentials` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/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)) @@ -2250,6 +2272,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) +- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c7587e3932..e38a806254 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -422,6 +422,27 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +## `credentials/*` + +### `credentials/updated` — emit + +Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. + +```ts cordis-catalog +/** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ +'credentials/updated'(ref: CredentialRef): void +``` + +Types: [CredentialRef](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts) + ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 35459c471b..d4b9c54c4b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -488,6 +488,52 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.credentials` — `Credentials` (abstract seam) + +Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. + +```ts cordis-catalog +/** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ +abstract resolve(ref: CredentialRef): Promise + +/** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ +abstract describe(ref: CredentialRef): Promise + +/** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ +abstract set(ref: CredentialRef, value: string): Promise + +/** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ +abstract unset(ref: CredentialRef): Promise +``` + +Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b583b53f2a..0663e38f9e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 docs/core-data-structures/core.md -core.md: ca178edc941903f0432ed9b340db963f1a95f223 -core.zh.md: d325cfefca60f5247492b64d5ceecdb552e57efb +core.md: e2ba74e5922f55c71ebc9f08691659603ef1fa6a +core.zh.md: 3ce9212f35e9c8367f462d6ab0cac695f745c3b0 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ca178edc94..e2ba74e592 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,6 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | +| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d325cfefca..3ce9212f35 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,6 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | +| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml new file mode 100644 index 0000000000..23bb940afe --- /dev/null +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/core-data-structures/credentials.md +credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 +credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md new file mode 100644 index 0000000000..3f6fcd127d --- /dev/null +++ b/docs/core-data-structures/credentials.md @@ -0,0 +1,50 @@ +# User Credentials + +English | [中文](credentials.zh.md) + +The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## Identity + +A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## Resolution + +`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## Description + +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## Change commits + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md new file mode 100644 index 0000000000..b5d2d9e164 --- /dev/null +++ b/docs/core-data-structures/credentials.zh.md @@ -0,0 +1,50 @@ +# 用户凭据 + +[English](credentials.md) | 中文 + +[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## 标识 + +引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## 解析 + +`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## 描述 + +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## 变更提交 + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 90f830408a..e1120093e6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..38774195e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -8,6 +8,10 @@ The headless demo combines the real DeepSeek adapter and coding capabilities wit ```mermaid flowchart LR cfg["examples/headless-agent
cordis.yml"] + plugin_headless_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_headless_settings + plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_headless_credentials plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -55,6 +59,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..3fc363ea0f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -1,16 +1,27 @@ # One-shot coding agent with format-pure stdout. The app bin loads the -# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. +# gitignored root `.env` into the process environment; entry configs here are +# the composition base, while user-plane values resolve per request through +# the two providers below. + +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# twin (a `providers` dict keyed by route; `reasoning: high` replaces +# thinking/reasoningEffort). Shipped default: full thinking at max effort on +# every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml index 7e85b90df7..10bc2591c8 100644 --- a/examples/headless-agent/credentials.cordis.snapshot.yml +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -1,6 +1,6 @@ -# Keyless dynamic-configuration composition: the settings and credentials -# providers live under the run cwd, no API key exists anywhere, and the -# deepseek route still registers — so the prompt fails with the actionable +# Keyless dynamic-configuration composition: the base settings and credentials +# providers see only the isolated run home, no API key exists anywhere, and +# the deepseek route still registers — so the prompt fails with the actionable # MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. - id: base name: '@cordisjs/plugin-include' @@ -11,15 +11,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: settings - name: '@deepseek-ai/dsh-settings-local' - config: - dshHome: ./.dsh - debounceMs: 10 - - id: credentials - name: '@deepseek-ai/dsh-credentials-local' - config: - dshHome: ./.dsh # The endpoint is never dialed: credential resolution fails first. - id: llm-deepseek-keyless name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/package.json b/examples/package.json index 35d9a74e3e..48885a1c35 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index c6fc223113..69cd9704a3 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -10,6 +10,10 @@ flowchart LR cfg["examples/tui-agent
cordis.yml"] plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_tui_settings + plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_tui_credentials plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -70,6 +74,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7c8b03db05..23f13a0218 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -10,13 +10,23 @@ config: root: ['.'] +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + # The native DeepSeek adapter. Shipped default: full thinking at max effort on # every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e720be54b1..d7bb88710c 100644 --- a/packages/README.i18n.yaml +++ b/packages/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 packages/README.md -README.md: 205fd060de43b33b1e9ddbd72a2a8fd2b526a889 -README.zh.md: 57d15ad3d78e941393059001eafffd7468633696 +README.md: 48fa3272f7e024a295e7beaa68b9365aac379319 +README.zh.md: 686a9123f5f89ac244f8ef880e78609a131eb8b9 diff --git a/packages/README.md b/packages/README.md index 205fd060de..48fa3272f7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -39,6 +39,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface | | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface | +| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 57d15ad3d7..686a9123f5 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -39,6 +39,7 @@ | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 | | [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 | +| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f90451084a..2cdfa4449a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -264,6 +264,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'credentials', + summary: 'Abstract credential service.', + methods: [ + { + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', + }, + { + signature: 'abstract describe(ref: CredentialRef): Promise', + jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', + }, + { + signature: 'abstract set(ref: CredentialRef, value: string): Promise', + jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', + }, + { + signature: 'abstract unset(ref: CredentialRef): Promise', + jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1208,6 +1230,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, + { + name: 'credentials/updated', + mode: 'emit', + signature: '\'credentials/updated\'(ref: CredentialRef): void', + jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', + summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', + }, { name: 'domain/changed', mode: 'emit', @@ -1674,6 +1703,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, + { + name: 'CredentialInfo', + declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', + }, + { + name: 'CredentialRef', + declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', + }, { name: 'DiffCallView', declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}', @@ -2058,6 +2095,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, + { + name: 'ResolvedCredential', + declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', + }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml new file mode 100644 index 0000000000..e8b35ba48e --- /dev/null +++ b/packages/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/credentials/README.md +README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 +README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b diff --git a/packages/credentials/README.md b/packages/credentials/README.md new file mode 100644 index 0000000000..1d450cbeef --- /dev/null +++ b/packages/credentials/README.md @@ -0,0 +1,14 @@ +# credentials/ + +English | [中文](README.zh.md) + +The credential capability seam, as three-package shape dictates (interface / implementation / consumers): + +| Package | Role | +|---|---| +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | + +Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. + +The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md new file mode 100644 index 0000000000..843230c3ce --- /dev/null +++ b/packages/credentials/README.zh.md @@ -0,0 +1,14 @@ +# credentials/ + +[English](README.md) | 中文 + +凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): + +| 包 | 角色 | +|---|---| +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | + +配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 + +seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml new file mode 100644 index 0000000000..23cf5bb09b --- /dev/null +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/credentials/credentials-local/README.md +README.md: 277c7db02836819e34a5c2db8aaf542c8eeec162 +README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 55b73c2ac6..277c7db028 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 2563c973a7..af1b840142 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| | 活跃进程环境 | `env` | 否 | 恒定优先 | | `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | -环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 ## 配置 @@ -18,25 +18,25 @@ | `path` | `/.env` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | -| `debounceMs` | `100` | watcher 写入沉降窗口。 | +| `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`。 +dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 853a1ce015..c1fcd37f16 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -118,6 +118,9 @@ function upsertLine(text: string | undefined, ref: CredentialRef, line: string | /** File-backed credentials provider (`$DSH_HOME/.env`). */ export class CredentialsLocal extends Credentials { + /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with + settings-local (prefer symmetry for parallel values); extracting the shared + shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), @@ -145,6 +148,7 @@ export class CredentialsLocal extends Credentials { private isClosed(): boolean { return this.closed } + /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) @@ -162,6 +166,9 @@ export class CredentialsLocal extends Credentials { } await this.loadInitial() if (!this.spec.watch) return + /* jscpd:ignore-start -- same watcher discipline as settings-local by design: + the serialized-refresh and quiesce-on-dispose shape is the reviewed + lifecycle contract, not accidental repetition. */ const watcher = chokidarWatch(this.spec.filename, { ignoreInitial: true, awaitWriteFinish: { @@ -183,6 +190,7 @@ export class CredentialsLocal extends Credentials { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) + /* jscpd:ignore-end */ yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any // queued or in-flight refresh so nothing publishes after disposal. diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml new file mode 100644 index 0000000000..10fe5f0ffe --- /dev/null +++ b/packages/credentials/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/credentials/credentials/README.md +README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc +README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 48b7b0f952..1c18c47623 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -13,8 +13,11 @@ Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: ## Surface ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' +declare const ctx: Context + const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value @@ -32,7 +35,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index ef9e32ebad..751fb7c1e8 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -4,42 +4,45 @@ 抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: -**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 +**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 -**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 -**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 +**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 ## 接口面 ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +declare const ctx: Context + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 -await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 -await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule ``` -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 -`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 +`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带秘密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work -- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 - **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 - **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index c562d8ab0a..dc1ed77a06 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -47,5 +47,3 @@ export class MemoryCredentials extends Credentials { return Promise.resolve() } } - -export default MemoryCredentials diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 7f7619b003..48dac1d70f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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 packages/llm/llm-deepseek/README.md -README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8 -README.zh.md: 523bbbfd29b4598c024a1fff4a7121a7cb88bf41 +README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303 +README.zh.md: 5331a4d44c08e2fc4a5f8486128079d9b01e8454 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a2314ea2c..88f4fd7c01 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +## Dynamic configuration (settings + credentials) + +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: + +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.credentials`** — the API key resolves per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. + +The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. @@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -96,6 +106,8 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work +- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 523bbbfd29..5331a4d44c 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -14,8 +14,9 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE `streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。 +## 动态配置(settings + credentials) + +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: + +- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 + +唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 + ## 应用归因 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 @@ -62,7 +72,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 +- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 跨越协议。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 054f1984b0..bb2ccdaa11 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -17,7 +17,7 @@ import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/ds import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -231,18 +231,10 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') }) - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 38b9c093f6..98e7579f80 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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 packages/llm/llm-pi-ai/README.md -README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0 -README.zh.md: a3d864ed9068d8bdaff4c5b73a4b7b339802ae05 +README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0 +README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ac47cf6a21..21e1f6f117 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file; omitting both delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ Configure credentials and deployment-specific transport settings per provider. O initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. + +## Dynamic configuration (settings + credentials) + +The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. + +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -71,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -105,6 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a3d864ed90..4f8423bd9a 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 包根目录公开 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭证与部署特定传输设置。省略 `apiKey` 会将认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## 动态配置(settings + credentials) + +适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 + +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()` 与 `providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -71,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 ## 模型体验 @@ -105,6 +111,8 @@ pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 fini ## 已知限制与暂缓事项 +- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4856dbe7e5..dd79113da1 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -30,7 +30,7 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' @@ -105,18 +105,10 @@ export function apply(ctx: Context, config: Config): void { registeredFacts = facts } - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a7e9366048..2df73528e8 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -442,4 +442,54 @@ export abstract class Settings extends Service { } } +/** Hooks a consumer hands to {@link installSettingsSection}. */ +export interface SettingsSectionHooks { + /** + * Receive the active configuration source: the resolved settings scope + * while one is attached, the composition entry otherwise. Called before + * the matching `onChange` at attach and at detach. + * @param current - thunk returning the currently authoritative value. + */ + setSource(current: () => T): void + /** + * Re-judge anything derived from the source — registration-level facts, + * memoized resolutions — after an attach, a detach, or a committed change. + */ + onChange(): void +} + +/** + * Install the canonical optional-settings consumer wiring: while a settings + * service exists, register `ns` with the consumer's composition entry as the + * `base` layer and point the source thunk at the resolved scope; when the + * service goes away (disposal, provider reload), fall back to the entry so + * the consumer keeps working exactly as composed. The registration rides the + * scoped fiber, so no settings service ever mounted means none of this runs. + * @param ctx - consumer plugin context owning the wiring. + * @param ns - the consumer-owned settings namespace. + * @param schema - schema resolving the namespace (typically the plugin Config). + * @param entry - the consumer's composition entry config, used as `base`. + * @param hooks - source sink and change notification. + */ +export function installSettingsSection( + ctx: Context, + ns: SettingsNamespace, + schema: z, + entry: T, + hooks: SettingsSectionHooks, +): void { + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(ns, schema, { base: entry }) + hooks.setSource(() => scope.get()) + sctx.effect(() => () => { + hooks.setSource(() => entry) + hooks.onChange() + }) + hooks.onChange() + scope.watch(() => { + hooks.onChange() + }) + }) +} + export default Settings diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index a989d9a5cc..6ad290779f 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' /** A provider implementing only the three primitives: the seam owns init. */ @@ -558,3 +558,46 @@ describe('watch', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) }) }) + +describe('installSettingsSection', () => { + const HelperSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('default'), + }) + + it('drives the source through attach, live commits, and detach', async () => { + const ctx = new Context() + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + let changes = 0 + installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes += 1 + }, + }) + // No settings service mounted: nothing ran, the entry stays authoritative. + expect(current()).toEqual({ theme: 'entry' }) + expect(changes).toBe(0) + + const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) + await fiber + await vi.waitFor(() => { + expect(current()).toEqual({ theme: 'user' }) + }) + expect(changes).toBe(1) + + await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) + await vi.waitFor(() => { + expect(changes).toBe(2) + }) + expect(current()).toEqual({ theme: 'live' }) + + await fiber.dispose() + await vi.waitFor(() => { + expect(changes).toBe(3) + }) + expect(current()).toEqual({ theme: 'entry' }) + }) +}) diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 1fc811bc8b..1662cd7149 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: 140df90571d84320fb4eb888508c67e60aa29a22 -README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 +# pnpm run verify-translation-pairing --write packages/util/README.md +README.md: 3c7fd29e40c07cb25dc6ad040f86c4e31cd41931 +README.zh.md: 3b4626c7bac8c294dcbdf52ef3b0da46d39a03c8 diff --git a/packages/util/README.md b/packages/util/README.md index 140df90571..3c7fd29e40 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 4c16df2a56..3b4626c7ba 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml new file mode 100644 index 0000000000..e1f2ea37f5 --- /dev/null +++ b/packages/util/atomic-write/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/util/atomic-write/README.md +README.md: 2cd57a0fa42601e393a41de68af3f9b1e2f033b5 +README.zh.md: e8f18a8ec6ef6077f15cebed0062fabc0638ee0e diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 42c65c820a..2cd57a0fa4 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -9,6 +9,8 @@ Zero-dependency atomic file replacement shared by file-backed stores that must n ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` @@ -24,6 +26,10 @@ One export. The contract, in the order failures would exploit it: None, as this is a pure filesystem primitive; nothing here reaches a model request. +#### KV Cache effect + +None; nothing here enters a request prefix. + ## Known Limitations and Deferred Work - **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 4a59eaea9d..e8f18a8ec6 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -2,29 +2,35 @@ [English](README.md) | 中文 -零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 +零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 ## 接口面 ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` -仅一个导出。契约按攻击面利用顺序列出: +仅一个导出。契约按故障利用它的先后顺序列出: - **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 - **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 - **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 -- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。 +- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 ## Model Experience -None, as this is a pure filesystem primitive; nothing here reaches a model request. +无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 + +#### KV Cache effect + +无;此处没有任何内容会进入请求前缀。 ## Known Limitations and Deferred Work -- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。 -- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。 +- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 +- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de87482bb1..8a6c4b7544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5472,6 +5472,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../packages/credentials/credentials '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -5574,6 +5577,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -5688,6 +5694,9 @@ importers: cordis: specifier: workspace:^ version: link:../../vendor/cordis + schemastery: + specifier: workspace:^ + version: link:../../vendor/schemastery vendor/cordis: dependencies: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..976ea1146d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,8 +10,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -32,34 +34,31 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -67,11 +66,14 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", @@ -91,9 +93,10 @@ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "workspace:^" + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "workspace:^", + "schemastery": "workspace:^" } } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 48a570a20d..d569811b56 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1750, + "AGENTS.md": 1755, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 850 + "packages/README.md": 865 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0654cd2277..59a39eae1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -194,6 +194,9 @@ export const LINK_MAP: Record = { SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', + CredentialRef: 'credentials.md', + CredentialInfo: 'credentials.md', + ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index aa599370e1..54467c3710 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -143,8 +143,17 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-local'], - consumers: [], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section.', + }, + { + key: 'credentials', + pkg: 'credentials', + title: 'Credential seam', + mode: 'seam', + implementations: ['credentials-local'], + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request.', }, { key: 'telemetry', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 28f854fe9b..3892a0e5f3 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(19) + expect(translated).toHaveLength(20) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5b35d7427e..7ecdcea55c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1328,6 +1328,21 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsUpdateSource", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialRef", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "ResolvedCredential", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialInfo", + "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c3c9cba8a1..f558046e51 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, + 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, + 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, + 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/website/docs.ts b/website/docs.ts index cbc8d9e627..a640d01e18 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -257,6 +257,7 @@ const coreDataReference = pairedPages(([ ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], ['settings.md', '用户设置', 'User settings', 21], + ['credentials.md', '用户凭据', 'User credentials', 22], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`, From 2a53abd491862e85eae9db53bf2449dfac21b8c8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:26:00 +0800 Subject: [PATCH 19/97] fix(examples): drop the dev HMR row the native source launch cannot load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendored @cordisjs/plugin-hmr uses decorators — non-erasable syntax the --experimental-transform-types demo:tui launch refuses — so its row made the shipped TUI composition unbootable from source while the tsx-launched snapshot harness masked it. The stale !!js head comment goes with it; restoring dev HMR is tracked separately. --- examples/tui-agent/cordis.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 23f13a0218..583cf9f3cc 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,14 +1,11 @@ # Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. -# HMR remains a leaf because it depends on Loader internals. The app bin loads -# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. - -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] +# The app bin loads the gitignored root `.env` into the process environment; +# entry configs here are the composition base, while user-plane values resolve +# per request through the settings and credentials providers below. +# No dev HMR row: vendored @cordisjs/plugin-hmr uses decorators, which the +# native --experimental-transform-types source launch cannot load. # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` section there overrides the adapter entry below without a From 4bb101e002c765df1b8da25617b6c31c27ccd240 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:29:12 +0800 Subject: [PATCH 20/97] Revert "fix(examples): drop the dev HMR row the native source launch cannot load" This reverts commit 8ca499e33dd2b6e1f4e80189a1a3ab9e6873ddf7. --- examples/tui-agent/cordis.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 583cf9f3cc..23f13a0218 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,11 +1,14 @@ # Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. -# The app bin loads the gitignored root `.env` into the process environment; -# entry configs here are the composition base, while user-plane values resolve -# per request through the settings and credentials providers below. -# No dev HMR row: vendored @cordisjs/plugin-hmr uses decorators, which the -# native --experimental-transform-types source launch cannot load. +# HMR remains a leaf because it depends on Loader internals. The app bin loads +# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional +# `DEEPSEEK_BASE_URL` through `!!js`. + +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` section there overrides the adapter entry below without a From 9336fed1e9e0bb7ddc22ab67f9ab8d0a199f8634 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:45:01 +0800 Subject: [PATCH 21/97] feat(examples): mount the pi-ai adapter in the TUI demo composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai + anthropic routes register keyless beside the direct deepseek adapter — the catalog stays browsable and requests fail actionably until a key arrives — with per-request apiKeyEnv resolution, so a user's settings section (proxy baseURL, extra routes) and .env keys are the only steps to turn them on. --- examples/tui-agent/cordis.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 23f13a0218..2c35d84e41 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -30,6 +30,20 @@ thinking: enabled reasoningEffort: max +# The pi-ai multi-provider twin beside the direct adapter: openai + anthropic +# routes register keyless (the catalog stays browsable; a request needs a +# key). Keys resolve per request through the apiKeyEnv references, and a +# `llm-pi-ai:` settings section overrides per provider — proxy baseURL, added +# routes — without a restart. +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + openai: + apiKeyEnv: OPENAI_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess From 4e9916b3e54e6727b8ecb08c12de6667a45abf08 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:56:09 +0800 Subject: [PATCH 22/97] =?UTF-8?q?feat(llm-pi-ai):=20dormant=20bare=20mount?= =?UTF-8?q?=20=E2=80=94=20routes=20live=20entirely=20in=20the=20settings?= =?UTF-8?q?=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty or omitted providers dict is now the valid dormant posture: the adapter mounts with zero routes and no catalog entries, registers routes the moment the llm-pi-ai settings section supplies profiles, and drops them when it empties. The TUI demo mounts the adapter bare, so adding an openai/anthropic provider is purely a settings.yaml (or, next PR, web form) operation with per-request apiKeyEnv credential resolution. --- ...est-level-llm-config-credentials.i18n.yaml | 4 ++-- ...29-request-level-llm-config-credentials.md | 2 +- ...request-level-llm-config-credentials.zh.md | 2 +- docs/config-catalog.md | 10 +++++--- examples/tui-agent/composition.md | 3 +++ examples/tui-agent/cordis.yml | 17 +++++-------- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 23 +++++++++++------- packages/llm/llm-pi-ai/src/index.ts | 15 ++++++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +++- .../llm-pi-ai/tests/dynamic-config.spec.ts | 24 +++++++++++++++++++ 13 files changed, 76 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index f54a4bdbe6..c8cde9db07 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea -2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71 +2026-07-29-request-level-llm-config-credentials.md: 67baec4b70d0c754f22573d87fb4492de5ca16a4 +2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index 13fefff9fe..67baec4b70 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -16,7 +16,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 5e29946ade..36182b77f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -16,7 +16,7 @@ Status: implemented **机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 ## 曾考虑的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d3844268ac..b032b5a91b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -630,10 +630,14 @@ Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepse Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider routes this instance owns. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** Non-empty dict of pi-ai provider routes, keyed by provider. */ - providers: Record + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 69cd9704a3..2091bfc75e 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -16,6 +16,8 @@ flowchart LR cfg --> plugin_tui_credentials plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] @@ -77,6 +79,7 @@ flowchart LR | `settings` | `@deepseek-ai/dsh-settings-local` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2c35d84e41..2fc3728e8a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -30,19 +30,14 @@ thinking: enabled reasoningEffort: max -# The pi-ai multi-provider twin beside the direct adapter: openai + anthropic -# routes register keyless (the catalog stays browsable; a request needs a -# key). Keys resolve per request through the apiKeyEnv references, and a -# `llm-pi-ai:` settings section overrides per provider — proxy baseURL, added -# routes — without a restart. +# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra +# models in the picker) until a `llm-pi-ai:` settings section supplies +# provider profiles — then those routes register live, keys resolving per +# request through their apiKeyEnv references, and drop again when the +# section empties. Which adapters exist is composition; which providers run +# is the user's settings document. - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - openai: - apiKeyEnv: OPENAI_API_KEY - anthropic: - apiKeyEnv: ANTHROPIC_API_KEY # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 98e7579f80..325fb0bf50 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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 packages/llm/llm-pi-ai/README.md -README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0 -README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae +README.md: fb8145d58a7c74c70498468044282c740460a947 +README.zh.md: e49243d81d204ea0567a6930ec99e4fa97f78df4 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 21e1f6f117..fb8145d58a 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,7 +35,7 @@ Configure credentials and deployment-specific transport settings per provider, k X-Deployment: production ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Dynamic configuration (settings + credentials) diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 4f8423bd9a..e49243d81d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,7 +35,7 @@ X-Deployment: production ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## 动态配置(settings + credentials) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b644527097..053d6d56e6 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -58,10 +58,14 @@ export interface ResolvedPiAiProviderProfile extends Omit + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } const thinkingBudgets = z.object({ @@ -88,21 +92,24 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.dict(profile).required(), + providers: z.dict(profile).default({}), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * route-keyed map suitable for per-request reads. + * route-keyed map suitable for per-request reads. This is the one explicit + * resolve step, so an omitted dict resolves to the empty (dormant) route set + * here rather than through a hidden fallback. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(providers: Readonly>): Map { +export function resolveProfiles( + providers: Readonly> | undefined, +): Map { if (Array.isArray(providers)) { throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } - const entries = Object.entries(providers) - if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') + const entries = Object.entries(providers ?? {}) const supported = new Set(getBuiltinProviders()) const resolved = new Map() for (const [provider, source] of entries) { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index dd79113da1..6140b2456d 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -91,19 +91,24 @@ export function apply(ctx: Context, config: Config): void { const adapter = new PiAiAdapter({ profiles, resolveApiKey }) // Route effects bind to this apply fiber via the stable `ctx` reference, - // even when a swap runs inside the scoped settings callback below. - let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) - let registeredFacts = registrationFacts(profiles()) + // even when a swap runs inside the scoped settings callback below. A bare + // mount (zero routes) is the dormant posture: nothing registers until a + // settings section supplies profiles, and routes drop when it empties. + let disposeRoutes: (() => void) | undefined + let registeredFacts: unknown const ensureRegistrationFacts = (): void => { const facts = registrationFacts(profiles()) if (deepEqualJson(facts, registeredFacts)) return // The registry captures the route set and each route's retry policy at // registration: swap the registration in one synchronous section (same // adapter instance, no NO_ADAPTER window). - disposeRoutes() - disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + disposeRoutes?.() + disposeRoutes = undefined + const routes = [...profiles().keys()] + if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter) registeredFacts = facts } + ensureRegistrationFacts() installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 02d7af5b2d..4b97e7b2a7 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -395,7 +395,9 @@ describe('provider profile lifecycle', () => { }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { - expect(() => resolveProfiles({})).toThrow(/at least one/) + // Empty and omitted dicts are the dormant zero-route posture, not errors. + expect(resolveProfiles({}).size).toBe(0) + expect(resolveProfiles(undefined).size).toBe(0) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) // The pre-release array shape and its per-profile provider field fail diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 5b7c9e5e3a..4bf4d6425a 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -42,6 +42,30 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { } describe('request-level dynamic profiles', () => { + it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') + const server = await mockServer([{ events: textEvents }]) + // The exact product posture: `- id: llm-pi-ai` with no config at all. + const ctx = await boot(dir, {}) + + expect(ctx.llm.listProviders()).toEqual([]) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings') + + // Emptying the user layer returns the adapter to its dormant state. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('adds a provider route from settings and drops it when the user layer resets', async () => { const dir = await home() const server = await mockServer([{ events: textEvents }]) From 7f1496c996760ebea79de4fc0457111307510b4c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:16:18 +0800 Subject: [PATCH 23/97] docs: regenerate the module graph for the credentials family and atomic-write --- docs/module-graph.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..010fc596c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -8,6 +8,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid flowchart TD subgraph group_util["packages/util"] + pkg_atomic_write["atomic-write"] pkg_brand["brand"] pkg_paths["paths"] pkg_retention["retention"] @@ -173,6 +174,10 @@ flowchart TD pkg_time_context["time-context"] pkg_workspace_context["workspace-context"] end + subgraph group_credentials["packages/credentials"] + pkg_credentials["credentials"] + pkg_credentials_local["credentials-local"] + end subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] pkg_agent_spine_demo["agent-spine-demo"] @@ -216,6 +221,10 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_settings["packages/settings"] + pkg_settings["settings"] + pkg_settings_local["settings-local"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -244,6 +253,7 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end + pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -301,12 +311,16 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_credentials --> pkg_brand + pkg_credentials --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -315,11 +329,15 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants @@ -355,11 +373,19 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session @@ -943,6 +969,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | +| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | @@ -976,14 +1003,16 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -992,8 +1021,10 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | From 8cec74748bb4b894a7a2c139b0cd227d232ecaa1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 18:34:06 +0800 Subject: [PATCH 24/97] feat(host): adaptive directory-picker default via -auto chooser Add @deepseek-ai/dsh-host-directory-picker-auto, a node-half-only chooser that samples the host situation once at boot (bind host via a new httpServer.host getter, SSH markers, platform, DISPLAY/WAYLAND_DISPLAY) and mounts the matching dual-face backend (-native or -browse) as a real Loader entry in the in-memory root tree; the effect disposer removes it. Entry-level mounting keeps the seam's one-row-swaps-both-faces invariant: the client module table discovers the mounted backend's browser half exactly as a config row's. apps/cli now composes -auto as its directory-picker row; composing a backend row directly remains the pin. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- ...irectory-picker-adaptive-default.i18n.yaml | 6 + ...07-29-directory-picker-adaptive-default.md | 29 ++++ ...29-directory-picker-adaptive-default.zh.md | 29 ++++ apps/cli/cordis.yml | 13 +- apps/cli/package.json | 1 + docs/config-catalog.md | 1 + packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 1 + packages/host/README.zh.md | 1 + .../directory-picker-auto/README.i18n.yaml | 6 + packages/host/directory-picker-auto/README.md | 20 +++ .../host/directory-picker-auto/README.zh.md | 20 +++ .../host/directory-picker-auto/package.json | 47 ++++++ .../host/directory-picker-auto/src/index.ts | 52 +++++++ .../directory-picker-auto/src/invariant.ts | 25 ++++ .../host/directory-picker-auto/src/resolve.ts | 46 ++++++ .../tests/loader-composition.spec.ts | 139 ++++++++++++++++++ .../tests/resolve.spec.ts | 37 +++++ .../host/directory-picker-auto/tsconfig.json | 27 ++++ .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/host/webserver/src/index.ts | 5 + pnpm-lock.yaml | 30 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 8 + 32 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md create mode 100644 packages/host/directory-picker-auto/README.i18n.yaml create mode 100644 packages/host/directory-picker-auto/README.md create mode 100644 packages/host/directory-picker-auto/README.zh.md create mode 100644 packages/host/directory-picker-auto/package.json create mode 100644 packages/host/directory-picker-auto/src/index.ts create mode 100644 packages/host/directory-picker-auto/src/invariant.ts create mode 100644 packages/host/directory-picker-auto/src/resolve.ts create mode 100644 packages/host/directory-picker-auto/tests/loader-composition.spec.ts create mode 100644 packages/host/directory-picker-auto/tests/resolve.spec.ts create mode 100644 packages/host/directory-picker-auto/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..0500ccfb3f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: c44717d46445a992e563497ed011930a6044a1bb +2026-07-28-directory-picker-capability-seam.zh.md: 42fedf64ba97fed032c4847b68ce321ae32dc463 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..c44717d464 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -33,7 +33,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative. +- `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction. - The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..42fedf64ba 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -33,7 +33,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。 +- `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。 - 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml new file mode 100644 index 0000000000..cd921886f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md +2026-07-29-directory-picker-adaptive-default.md: ae5748259f162503300360d6cca43bec996afdb6 +2026-07-29-directory-picker-adaptive-default.zh.md: acabeb80604b473289fa441e6d9d913e4a8f87d0 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md new file mode 100644 index 0000000000..ae5748259f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md @@ -0,0 +1,29 @@ +# Agent Note: Adaptive default for the directory-picker interaction + +Status: implemented + +English | [中文](2026-07-29-directory-picker-adaptive-default.zh.md) + +## Problem + +The [directory-picker seam](../architecture/2026-07-28-directory-picker-capability-seam.md) made the interaction a `cordis.yml` swap point, but the shipped composition still had to pin one backend: `-browse` everywhere meant a local operator never got the OS chooser, `-native` everywhere breaks every remote deployment. The right default depends on facts only the running host knows — where the server binds, whether the process was launched over SSH, whether a display session exists — so no static row is correct for all deployments. + +## Decision + +A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY` — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry again. `native` requires every attended-host signal (loopback bind ∧ no SSH markers ∧ display session, assumed on darwin/win32); anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin. + +Why entry-level mounting is the load-bearing mechanism: the client module table (`dsh-client-modules`) reconciles **Loader entries** reactively over `internal/plugin`, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (`AppCLIEntry`) is the mechanism precedent. Root-tree targeting matters: the root tree's `write()` is a no-op, so the resolved row can never be persisted back into `cordis.yml` (the Include subtree *does* write). + +## Alternatives considered + +- **Boot-glue resolution in `AppCLIEntry`** (ship both rows with static `disabled`, patch `disabled` from a `--directory-picker=auto|native|browse` flag). Works — `PatchOptions` patches metadata, and the modules scan skips disabled rows — but leaves the decision app-private where every future composition re-implements it; the chooser plugin gives any `cordis.yml` the same one-row adaptivity. Reintroduce the flag only when a deployment needs to *force* a backend without editing its yml. +- **One merged plugin branching per call** (client tries `pick`, falls back to the browse dialog on `directory-picker-unavailable`). Rejected: the client would need both flows in one bundle — the bundle-purity gate forbids cross-plugin value imports and jscpd forbids copying the dialog — and per-call probing pays a doomed RPC on every open of a browse host. +- **Resurrecting the wire advertisement** so both client flows mount and branch on the host's kind. Rejected: reverses the seam note's deletion for no consumer the chooser doesn't already serve, and collides with the `single` directory-flow holes. +- **Per-connection adaptivity** (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no deployment serves both operator shapes at once today. + +## Consequences + +- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, or headless host → in-app browser. Detection is a heuristic (a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed) — a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` pins the safe interaction. +- One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them. +- Mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service; duplicate flow in the `single` holes). +- The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's `webserver` reference. diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md new file mode 100644 index 0000000000..acabeb8060 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md @@ -0,0 +1,29 @@ +# Agent Note:目录选择交互的自适应默认值 + +状态:已实现 + +[English](2026-07-29-directory-picker-adaptive-default.md) | 中文 + +## 问题 + +[目录选择 seam](../architecture/2026-07-28-directory-picker-capability-seam.md)把交互形态做成了 `cordis.yml` 的切换点,但随附的组合仍必须固定一个后端:处处用 `-browse` 意味着本地操作者永远得不到 OS 选择器,处处用 `-native` 则弄坏所有远程部署。正确的默认值取决于只有运行中的宿主才知道的事实——服务器绑定在哪里、进程是否经 SSH 启动、是否存在显示会话——因此没有哪一静态行对所有部署都正确。 + +## 决策 + +第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会再次移除该条目。`native` 要求全部有人值守宿主信号(回环绑定 ∧ 无 SSH 标记 ∧ 显示会话,darwin/win32 上视为存在);任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。 + +条目级挂载之所以是承重机制:client 模块表(`dsh-client-modules`)基于 `internal/plugin` 对 **Loader 条目**做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(`AppCLIEntry`)是该机制的先例。瞄准根树很关键:根树的 `write()` 是 no-op,因此判定出的行绝不会被持久化回 `cordis.yml`(Include 子树*会*写回)。 + +## 曾考虑的替代方案 + +- **在 `AppCLIEntry` 里做启动胶水判定**(随附两行并带静态 `disabled`,由 `--directory-picker=auto|native|browse` 标志修补 `disabled`)。可行——`PatchOptions` 能修补元数据,模块扫描也会跳过禁用行——但把决策留成应用私有,此后每个组合都要重新实现;选择器插件让任何 `cordis.yml` 都获得同样的一行自适应。只有当某个部署需要不改自己的 yml 就*强制*指定后端时,才重新引入该标志。 +- **合并成一个按调用分支的插件**(client 先试 `pick`,收到 `directory-picker-unavailable` 再回退到浏览对话框)。否决:client 得把两套流程装进同一个 bundle——bundle 纯净门禁禁止跨插件的值导入,jscpd 禁止复制对话框——而且按调用探测让 browse 宿主每次打开都付出一次注定失败的 RPC。 +- **复活 wire 广播**,让两套 client 流程都挂载并按宿主的 kind 分支。否决:推翻 seam Agent Note 的那次删除,却服务不了任何选择器尚未服务的消费方,还与 `single` 目录流洞相冲突。 +- **按连接自适应**(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;今天没有部署同时服务两种操作者形态。 + +## 后果 + +- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定或无头宿主 → 应用内浏览器。探测是启发式的(脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示)——错误的 `native` 选择会退化为后端既有的可重试失败对话框,组合 `-browse` 即固定住安全的交互。 +- 每次启动只判定一次,维持 seam 的能力稳定性契约;按连接的形态在有部署提出需求前仍不在范围内。 +- 同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务;`single` 洞中的重复流程)。 +- host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 `webserver` 的引用互为镜像。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index ed44f75c7f..d9cbd4b08a 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -314,12 +314,15 @@ # The API gateway: the transport-agnostic dispatch face every client shape # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). -# Directory-picking package, dual-face: the node half serves the gateway's -# host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-native' instead for the host-display OS chooser. +# Directory-picking composition, adaptive default: the chooser resolves the +# host's situation once at boot (bind host, SSH launch, display session) and +# mounts the matching dual-face backend row — its node half serves the +# gateway's host.* picker RPCs, its browser half fills ui-workspace's +# directory-flow slots. Swap point: mount '-native' (host-display OS chooser) +# or '-browse' (in-app browsing, remote-capable) directly to pin the +# interaction. - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-browse' + name: '@deepseek-ai/dsh-host-directory-picker-auto' - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..4538d326cf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..8a169a4dbe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2224,6 +2224,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..cd09dca013 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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 packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: 4f56afc45d594bf1f3f0784b848958cf62f52ae5 +README.zh.md: 888301282e272966b9d99e299b893afc90670906 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..4f56afc45d 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -11,5 +11,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | | `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) | | `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) | +| `directory-picker-auto/` | Adaptive chooser: resolves the host's situation once at boot (bind host, SSH, display) and mounts the matching dual-face backend as an in-memory Loader entry | (mounts a backend row) | `apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..888301282e 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -11,5 +11,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承 | `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | | `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) | | `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) | +| `directory-picker-auto/` | 自适应选择器:启动时一次性判定宿主处境(绑定宿主、SSH、显示),并把匹配的双面后端挂载为内存中的 Loader 条目 | (挂载一个后端行) | `apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml new file mode 100644 index 0000000000..fa4907698b --- /dev/null +++ b/packages/host/directory-picker-auto/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/host/directory-picker-auto/README.md +README.md: 73692d1fb5af1e7b23b2a99d0a5cd48507f68ce5 +README.zh.md: 5bc9ced01f86d021db256df2c9e69e97ec6a7ab1 diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md new file mode 100644 index 0000000000..73692d1fb5 --- /dev/null +++ b/packages/host/directory-picker-auto/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-host-directory-picker-auto + +English | [中文](README.zh.md) + +The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it. + +Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a display session (assumed on darwin/win32; `DISPLAY`/`WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes). + +## Model Experience + +None, as the chooser only composes the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Detection is a heuristic, not a proof** — a tmux session detached from its SSH launch loses the `SSH_*` markers, and a darwin process outside an Aqua session still counts as displayed; a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction. +- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md new file mode 100644 index 0000000000..5bc9ced01f --- /dev/null +++ b/packages/host/directory-picker-auto/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-host-directory-picker-auto + +[English](README.md) | 中文 + +[目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op)。由于后端以普通条目的形式到达,其 browser half 被 client 模块表发现的方式与配置行完全相同,因此对判定出的选择,seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。 + +判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及存在显示会话(darwin/win32 上视为存在,其余平台看 `DISPLAY`/`WAYLAND_DISPLAY`)。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。 + +## 模型体验 + +无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **探测是启发式,不是证明**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记,Aqua 会话之外的 darwin 进程也仍被算作有显示;错误的 `native` 选择会退化为后端既有的可重试失败对话框,而直接组合 `-browse` 即固定住安全的交互。 +- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。 diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json new file mode 100644 index 0000000000..888637231e --- /dev/null +++ b/packages/host/directory-picker-auto/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker-auto", + "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1", + "@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts new file mode 100644 index 0000000000..12568d8f8b --- /dev/null +++ b/packages/host/directory-picker-auto/src/index.ts @@ -0,0 +1,52 @@ +/** + * Adaptive chooser of the directory-picker seam: resolves the host's + * situation once at boot (bind host, SSH launch, display session) and mounts + * the matching dual-face backend — `-native` or `-browse` — as a real Loader + * entry in the in-memory root tree. Because the backend arrives as an + * ordinary entry, its browser half is discovered exactly as a config-row's + * would be, so the seam's one-row-swaps-both-faces invariant holds for the + * resolved choice; pinning an interaction remains composing that backend row + * directly instead of this one. + * @module @deepseek-ai/dsh-host-directory-picker-auto + */ + +import type { Context } from 'cordis' +// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below. +import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { DirectoryPickerBackendKind } from './resolve.ts' +import { resolveDirectoryPickerBackend } from './resolve.ts' + +export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts' +export { resolveDirectoryPickerBackend } from './resolve.ts' + +/** Cordis plugin name. */ +export const name = 'directory-picker-auto' +/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */ +export const inject = ['httpServer', 'loader'] + +/** Backend package per resolved kind — fixed composition vocabulary, not a tunable. */ +const BACKEND_PACKAGES: Record = { + native: '@deepseek-ai/dsh-host-directory-picker-native', + browse: '@deepseek-ai/dsh-host-directory-picker-browse', +} + +/** + * Resolve the backend from one boot-time sample and mount it as a Loader + * entry; the effect's disposer removes the entry, so unloading this plugin + * unloads both faces of the mounted backend with it. + * @param ctx - cordis context carrying the injected `httpServer` and `loader`. + */ +export async function apply(ctx: Context): Promise { + const backend = resolveDirectoryPickerBackend({ + bindHost: ctx.httpServer.host, + platform: process.platform, + env: process.env, + }) + await ctx.effect(async () => { + // Root-tree create: the Loader root is in-memory (write() is a no-op), so + // the mounted row can never be persisted back into a config file. + const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] }) + return () => { ctx.loader.remove(id) } + }, 'directory-picker-auto: backend entry') +} diff --git a/packages/host/directory-picker-auto/src/invariant.ts b/packages/host/directory-picker-auto/src/invariant.ts new file mode 100644 index 0000000000..8b3f251447 --- /dev/null +++ b/packages/host/directory-picker-auto/src/invariant.ts @@ -0,0 +1,25 @@ +/** + * Package-owned invariant companion for the adaptive directory-picker chooser. + * @module @deepseek-ai/dsh-host-directory-picker-auto/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-auto-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. */ +const install: InvariantInstaller = () => {} + +/** + * Register the adaptive directory-picker invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/host/directory-picker-auto/src/resolve.ts b/packages/host/directory-picker-auto/src/resolve.ts new file mode 100644 index 0000000000..d2a22e2fa8 --- /dev/null +++ b/packages/host/directory-picker-auto/src/resolve.ts @@ -0,0 +1,46 @@ +/** + * Boot-time backend resolution for the adaptive directory-picker composition: + * one pure decision from sampled host facts to a concrete backend kind. The + * caller samples exactly once per boot, so the mounted capability stays + * stable for the service lifetime as the seam requires. + * @module @deepseek-ai/dsh-host-directory-picker-auto/resolve + */ + +/** Concrete interaction backend the resolver chooses between. */ +export type DirectoryPickerBackendKind = 'native' | 'browse' + +/** Environment keys the resolution reads (a `process.env` subset). */ +export type DirectoryPickerEnv = Readonly< + Partial> +> + +/** Host facts the backend choice is a pure function of, sampled once at boot. */ +export interface DirectoryPickerHostFacts { + /** Effective webserver bind host (`127.0.0.1` or `0.0.0.0`). */ + bindHost: string + /** Host process platform. */ + platform: NodeJS.Platform + /** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */ + env: DirectoryPickerEnv +} + +/** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */ +const present = (value: string | undefined): boolean => value !== undefined && value !== '' + +/** + * Resolve which backend serves this boot. `native` requires every signal that + * the operator can see the host display: a loopback-only bind (an + * all-interfaces bind admits remote browsers no OS chooser can reach), no SSH + * launch (under SSH port-forwarding the chooser would open on the unattended + * server), and a display session (assumed on darwin/win32, `DISPLAY`/ + * `WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`, + * which works everywhere. + * @param facts - the sampled host facts. + * @returns the backend kind to mount. + */ +export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind { + if (facts.bindHost !== '127.0.0.1') return 'browse' + if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse' + if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native' + return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse' +} diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..7e094035fb --- /dev/null +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -0,0 +1,139 @@ +/** + * REAL-composition coverage: a test-only cordis.yml booted through the + * vendored Loader mounts the webserver row plus the adaptive chooser, and the + * assertions observe the durable outcome — which backend entry the chooser + * mounted into the Loader store, the capability the seam then serves, and + * that disposing the chooser removes the mounted entry again (HMR safety). + */ + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import HttpServer from '@deepseek-ai/dsh-host-webserver' +import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' +import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' +import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' +import * as DirectoryPickerAuto from '../src/index.ts' + +const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' +const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' +const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + vi.unstubAllEnvs() + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ +async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) + const dist = join(root, 'dist') + await mkdir(dist) + const distIndex = join(dist, 'index.html') + await writeFile(distIndex, 'shell') + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-host-webserver'", + ' config:', + ` host: '${bindHost}'`, + ' port: 0', + ` distIndex: '${distIndex}'`, + `- name: '${AUTO}'`, + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-host-webserver', HttpServer], + [AUTO, DirectoryPickerAuto], + [NATIVE, NativeDirectoryPicker], + [BROWSE, BrowseDirectoryPicker], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return { ctx: context, configPath } +} + +/** Entry names currently present in the loader store (root tree plus subtrees). */ +function entryNames(ctx: Context): string[] { + return [...ctx.loader.entries()].map(entry => entry.options.name) +} + +/** Force every signal of an attended host: no SSH launch, a display on any platform. */ +function stubAttendedHost(): void { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') + vi.stubEnv('DISPLAY', ':0') +} + +describe('real Loader composition', () => { + // Real-Loader composition resolves workspace packages through tsx at test + // time; first resolution after the host/client program split is slow enough + // to trip the default 5s budget on cold caches. + it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => { + stubAttendedHost() + const { ctx, configPath } = await loadComposition('127.0.0.1') + + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + expect(entryNames(ctx)).toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(BROWSE) + const picker = ctx.get('directoryPicker') as DirectoryPicker + expect(picker.capability().kind).toBe('native') + // The mounted row lives in the Loader's in-memory root tree only — the + // booted config file must never gain the resolved backend row. + expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) + + // HMR safety: disposing the chooser's fiber removes the entry it created. + const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + await autoEntry.fiber!.dispose() + await ctx.loader.await() + expect(entryNames(ctx)).not.toContain(NATIVE) + expect(ctx.get('directoryPicker')).toBeUndefined() + }) + + it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => { + stubAttendedHost() + vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22') + const { ctx } = await loadComposition('127.0.0.1') + + expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).not.toContain(NATIVE) + const picker = ctx.get('directoryPicker') as DirectoryPicker + expect(picker.capability().kind).toBe('browse') + }) + + it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => { + stubAttendedHost() + const { ctx } = await loadComposition('0.0.0.0') + + expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).not.toContain(NATIVE) + }) +}) diff --git a/packages/host/directory-picker-auto/tests/resolve.spec.ts b/packages/host/directory-picker-auto/tests/resolve.spec.ts new file mode 100644 index 0000000000..3beac44961 --- /dev/null +++ b/packages/host/directory-picker-auto/tests/resolve.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { resolveDirectoryPickerBackend } from '../src/resolve.ts' +import type { DirectoryPickerHostFacts } from '../src/resolve.ts' + +/** Baseline facts that resolve to `native`; each case overrides one signal. */ +const attended: DirectoryPickerHostFacts = { + bindHost: '127.0.0.1', + platform: 'darwin', + env: {}, +} + +describe('resolveDirectoryPickerBackend', () => { + it('resolves native for a loopback bind on a display platform', () => { + expect(resolveDirectoryPickerBackend(attended)).toBe('native') + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'win32' })).toBe('native') + }) + + it('resolves browse for an all-interfaces bind regardless of other signals', () => { + expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse') + }) + + it('resolves browse under an SSH launch (either env marker)', () => { + expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse') + expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse') + }) + + it('requires a display session on platforms without an implied one', () => { + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux' })).toBe('browse') + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: ':0' } })).toBe('native') + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native') + }) + + it('treats blank env exports as unset', () => { + expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native') + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: '', WAYLAND_DISPLAY: '' } })).toBe('browse') + }) +}) diff --git a/packages/host/directory-picker-auto/tsconfig.json b/packages/host/directory-picker-auto/tsconfig.json new file mode 100644 index 0000000000..4e9a1955a7 --- /dev/null +++ b/packages/host/directory-picker-auto/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../webserver" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 3e5bae41b5..8bb3c0afec 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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 packages/host/directory-picker/README.md -README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f -README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d +README.md: 3749b238b56578ec68610bc13550760aa084bad6 +README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 8ef8889c87..3749b238b5 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 8aefffa7b2..488da5129e 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 40d5fcf9d3..0160db9f01 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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 packages/host/webserver/README.md -README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0 -README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc +README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 +README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index e715e4452d..ace8c09e43 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. +Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 05e7e10d78..b9948e3d38 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 +朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 936dd4f5a1..37298cb178 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -78,6 +78,11 @@ export class HttpServerService extends Service { return this.listenedPort } + /** The configured bind host (the loopback or all-interfaces literal). */ + get host(): Config['host'] { + return this.config.host + } + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..d4813589a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-auto': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-auto '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ version: link:../../packages/host/directory-picker-browse @@ -2934,6 +2937,33 @@ 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/host/directory-picker-auto: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/host/directory-picker-browse: dependencies: '@deepseek-ai/dsh-host-directory-picker': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fd74c6d13e..0e48167920 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -78,6 +78,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' }, + 'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; registers no model surface.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..69a7550593 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -172,6 +172,14 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, + { "path": "./packages/host/directory-picker-auto" }, + // Dual-face backend leaves stay client-registered (their tests and client + // halves are excluded above); these references only let the adaptive + // chooser's composition test import each backend's NODE entry, whose + // declarations carry no client-side Context merge — the mirror of the + // client aggregate's webserver reference. + { "path": "./packages/host/directory-picker-browse" }, + { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, From f0f897ef029a448172c48de4cf48e3c066a4ea35 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:09:02 +0800 Subject: [PATCH 25/97] fix(host): address ds-review-bot v7 on the adaptive picker chooser - resolve.ts: gate the display branch on linux (the native backend drives exactly darwin/win32/linux) and require a zenity/kdialog binary on PATH, probed once at boot (new probe.ts, injected predicate for tests); type bindHost as the webserver schema's closed union. - index.ts: the disposer now joins the removed entry's fiber teardown so unloading the chooser settles only after the backend quiesced; export BACKEND_PACKAGES as the runtime-string source of truth. - verify-cordis-config: a composition mounting -auto must declare both backends as dependencies (negative-tested), since keyless Linux CI only ever resolves browse and would hide a dropped -native dep. - apps/web scaffold: pin -browse via disable+insert (goldens are interaction-specific); fix the stale workspace-flow comment. - docs/module-graph.md regenerated; README + Agent Note document the ssh -L shape, the PATH-only probe, and the new gate (zh pairs re-paired). - composition spec: assert teardown quiescence without a loader await, cover external entry removal, and await the loader's self-dispose disabled-persist so it cannot race temp-dir teardown. --- ...irectory-picker-adaptive-default.i18n.yaml | 4 +- ...07-29-directory-picker-adaptive-default.md | 5 +- ...29-directory-picker-adaptive-default.zh.md | 5 +- apps/web/tests/scaffold.ts | 8 +++ apps/web/tests/workspace-flow.snapshot.ts | 3 +- docs/module-graph.md | 6 ++ .../directory-picker-auto/README.i18n.yaml | 4 +- packages/host/directory-picker-auto/README.md | 5 +- .../host/directory-picker-auto/README.zh.md | 5 +- .../host/directory-picker-auto/src/index.ts | 43 ++++++++---- .../host/directory-picker-auto/src/probe.ts | 44 ++++++++++++ .../host/directory-picker-auto/src/resolve.ts | 23 ++++--- .../tests/loader-composition.spec.ts | 57 +++++++++++++--- .../tests/resolve.spec.ts | 68 +++++++++++++++++-- scripts/verify-cordis-config.ts | 26 ++++++- 15 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 packages/host/directory-picker-auto/src/probe.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml index cd921886f6..3ade5b93e1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.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 .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md -2026-07-29-directory-picker-adaptive-default.md: ae5748259f162503300360d6cca43bec996afdb6 -2026-07-29-directory-picker-adaptive-default.zh.md: acabeb80604b473289fa441e6d9d913e4a8f87d0 +2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4 +2026-07-29-directory-picker-adaptive-default.zh.md: a2a2d8ec4c91a347eedfc3aa3413b091ee847934 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md index ae5748259f..7ff6529bb8 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md @@ -10,7 +10,7 @@ The [directory-picker seam](../architecture/2026-07-28-directory-picker-capabili ## Decision -A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY` — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry again. `native` requires every attended-host signal (loopback bind ∧ no SSH markers ∧ display session, assumed on darwin/win32); anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin. +A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY`, and a `PATH` probe for a Linux chooser binary (zenity/kdialog) — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry and joins the backend fiber's teardown (`remove()` alone only starts it), so unloading the chooser settles only after the backend quiesced. `native` requires every attended-and-servable signal: loopback bind ∧ no SSH markers ∧ a display session the native backend can drive — assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and never true elsewhere (the native backend supports exactly darwin/win32/linux). Anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin. Why entry-level mounting is the load-bearing mechanism: the client module table (`dsh-client-modules`) reconciles **Loader entries** reactively over `internal/plugin`, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (`AppCLIEntry`) is the mechanism precedent. Root-tree targeting matters: the root tree's `write()` is a no-op, so the resolved row can never be persisted back into `cordis.yml` (the Include subtree *does* write). @@ -23,7 +23,8 @@ Why entry-level mounting is the load-bearing mechanism: the client module table ## Consequences -- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, or headless host → in-app browser. Detection is a heuristic (a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed) — a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` pins the safe interaction. +- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, headless host, unsupported platform, or Linux without a chooser binary → in-app browser. Detection infers operator location from launch context, which no launch-side signal can prove: a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, arriving from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation — per-connection adaptivity could not fix that last case either. A wrong `native` choice degrades to the backend's existing retryable failure dialog; deployments in these shapes compose `-browse` directly. +- The chooser mounts backends by runtime string (`BACKEND_PACKAGES`, exported), which yml-row scanning cannot see; `verify-cordis-config` therefore requires every composition mounting `-auto` to declare both backends as dependencies, so keyless Linux CI (which only ever resolves `browse`) cannot hide a dropped `-native` dependency. The shipped-tree web e2e/snapshot lane (`apps/web/tests/scaffold.ts`) pins `-browse` by disable+insert patch — its goldens are interaction-specific and must not depend on the host running the suite. - One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them. - Mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service; duplicate flow in the `single` holes). - The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's `webserver` reference. diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md index acabeb8060..a2a2d8ec4c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md @@ -10,7 +10,7 @@ ## 决策 -第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会再次移除该条目。`native` 要求全部有人值守宿主信号(回环绑定 ∧ 无 SSH 标记 ∧ 显示会话,darwin/win32 上视为存在);任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。 +第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`、以及对 Linux 选择器二进制(zenity/kdialog)的一次 `PATH` 探查——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会移除该条目并汇入后端 fiber 的拆卸(单靠 `remove()` 只是启动拆卸),因此卸载选择器要到后端静止之后才落定。`native` 要求全部“有人值守且可服务”信号:回环绑定 ∧ 无 SSH 标记 ∧ native 后端能驱动的显示会话——darwin/win32 上视为存在,linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY` 外加一个选择器二进制,其余平台一律不成立(native 后端恰好支持 darwin/win32/linux)。任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。 条目级挂载之所以是承重机制:client 模块表(`dsh-client-modules`)基于 `internal/plugin` 对 **Loader 条目**做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(`AppCLIEntry`)是该机制的先例。瞄准根树很关键:根树的 `write()` 是 no-op,因此判定出的行绝不会被持久化回 `cordis.yml`(Include 子树*会*写回)。 @@ -23,7 +23,8 @@ ## 后果 -- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定或无头宿主 → 应用内浏览器。探测是启发式的(脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示)——错误的 `native` 选择会退化为后端既有的可重试失败对话框,组合 `-browse` 即固定住安全的交互。 +- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定、无头宿主、不支持的平台,或没有选择器二进制的 Linux → 应用内浏览器。探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点:脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上——即便按连接自适应也修不了最后这一情形。错误的 `native` 选择会退化为后端既有的可重试失败对话框;处于这些形态的部署直接组合 `-browse`。 +- 选择器按运行时字符串(已导出的 `BACKEND_PACKAGES`)挂载后端,yml 行扫描看不到这一点;因此 `verify-cordis-config` 要求每个挂载 `-auto` 的组合把两个后端都声明为依赖,使无密钥的 Linux CI(它永远只会判定出 `browse`)无法掩盖被丢掉的 `-native` 依赖。随附树的 web e2e/快照通道(`apps/web/tests/scaffold.ts`)以 disable+insert 补丁固定 `-browse`——其 golden 是交互特定的,绝不能依赖运行该套件的宿主。 - 每次启动只判定一次,维持 seam 的能力稳定性契约;按连接的形态在有部署提出需求前仍不在范围内。 - 同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务;`single` 洞中的重复流程)。 - host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 `webserver` 的引用互为镜像。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..d7970036f9 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -175,6 +175,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -1041,6 +1046,7 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index fa4907698b..ea430abe70 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/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 packages/host/directory-picker-auto/README.md -README.md: 73692d1fb5af1e7b23b2a99d0a5cd48507f68ce5 -README.zh.md: 5bc9ced01f86d021db256df2c9e69e97ec6a7ab1 +README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6 +README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546 diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index 73692d1fb5..10d1784590 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it. -Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a display session (assumed on darwin/win32; `DISPLAY`/`WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes). +Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes). ## Model Experience @@ -16,5 +16,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Detection is a heuristic, not a proof** — a tmux session detached from its SSH launch loses the `SSH_*` markers, and a darwin process outside an Aqua session still counts as displayed; a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction. +- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments. +- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot. - **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 5bc9ced01f..86ec9f2c3a 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op)。由于后端以普通条目的形式到达,其 browser half 被 client 模块表发现的方式与配置行完全相同,因此对判定出的选择,seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。 -判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及存在显示会话(darwin/win32 上视为存在,其余平台看 `DISPLAY`/`WAYLAND_DISPLAY`)。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。 +判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。 ## 模型体验 @@ -16,5 +16,6 @@ ## 已知限制与延期工作 -- **探测是启发式,不是证明**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记,Aqua 会话之外的 darwin 进程也仍被算作有显示;错误的 `native` 选择会退化为后端既有的可重试失败对话框,而直接组合 `-browse` 即固定住安全的交互。 +- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 +- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 - **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。 diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 12568d8f8b..5766e36b98 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -1,12 +1,12 @@ /** * Adaptive chooser of the directory-picker seam: resolves the host's - * situation once at boot (bind host, SSH launch, display session) and mounts - * the matching dual-face backend — `-native` or `-browse` — as a real Loader - * entry in the in-memory root tree. Because the backend arrives as an - * ordinary entry, its browser half is discovered exactly as a config-row's - * would be, so the seam's one-row-swaps-both-faces invariant holds for the - * resolved choice; pinning an interaction remains composing that backend row - * directly instead of this one. + * situation once at boot (bind host, SSH launch, display session, Linux + * chooser binary) and mounts the matching dual-face backend — `-native` or + * `-browse` — as a real Loader entry in the in-memory root tree. Because the + * backend arrives as an ordinary entry, its browser half is discovered + * exactly as a config-row's would be, so the seam's one-row-swaps-both-faces + * invariant holds for the resolved choice; pinning an interaction remains + * composing that backend row directly instead of this one. * @module @deepseek-ai/dsh-host-directory-picker-auto */ @@ -14,9 +14,11 @@ import type { Context } from 'cordis' // Empty type imports carry the `loader` and `httpServer` Context merges for the reads below. import type {} from '@cordisjs/plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' +import { canExecute, hasLinuxChooserBinary } from './probe.ts' import type { DirectoryPickerBackendKind } from './resolve.ts' import { resolveDirectoryPickerBackend } from './resolve.ts' +export { canExecute, hasLinuxChooserBinary } from './probe.ts' export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts' export { resolveDirectoryPickerBackend } from './resolve.ts' @@ -25,16 +27,22 @@ export const name = 'directory-picker-auto' /** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */ export const inject = ['httpServer', 'loader'] -/** Backend package per resolved kind — fixed composition vocabulary, not a tunable. */ -const BACKEND_PACKAGES: Record = { +/** + * Backend package per resolved kind — fixed composition vocabulary, not a + * tunable. Exported because the reference is a runtime string the static + * config gate cannot see in a yml row: `verify-cordis-config` requires every + * app composing this chooser to declare both values as dependencies. + */ +export const BACKEND_PACKAGES: Record = { native: '@deepseek-ai/dsh-host-directory-picker-native', browse: '@deepseek-ai/dsh-host-directory-picker-browse', } /** * Resolve the backend from one boot-time sample and mount it as a Loader - * entry; the effect's disposer removes the entry, so unloading this plugin - * unloads both faces of the mounted backend with it. + * entry; the effect's disposer removes the entry and joins the backend + * fiber's teardown, so unloading this plugin returns only after both faces + * of the mounted backend (and their dependents) quiesced. * @param ctx - cordis context carrying the injected `httpServer` and `loader`. */ export async function apply(ctx: Context): Promise { @@ -42,11 +50,22 @@ export async function apply(ctx: Context): Promise { bindHost: ctx.httpServer.host, platform: process.platform, env: process.env, + linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute), }) await ctx.effect(async () => { // Root-tree create: the Loader root is in-memory (write() is a no-op), so // the mounted row can never be persisted back into a config file. const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] }) - return () => { ctx.loader.remove(id) } + return async () => { + // Tree teardown (group.stop) can have removed the entry already; + // nothing is left to unmount or await then. + const entry = ctx.loader.store[id] + if (entry === undefined) return + const fiber = entry.fiber + ctx.loader.remove(id) + // remove() only starts the fiber's dispose; join it so the chooser's + // unload signals completion only after the backend quiesced. + await fiber?.dispose() + } }, 'directory-picker-auto: backend entry') } diff --git a/packages/host/directory-picker-auto/src/probe.ts b/packages/host/directory-picker-auto/src/probe.ts new file mode 100644 index 0000000000..17fd0c99f8 --- /dev/null +++ b/packages/host/directory-picker-auto/src/probe.ts @@ -0,0 +1,44 @@ +/** + * PATH probe for the native backend's Linux chooser binaries: one boot-time + * sampled fact for the resolver, so an attended Linux host without + * zenity/kdialog keeps the working `browse` interaction instead of a backend + * whose every pick fails. + * @module @deepseek-ai/dsh-host-directory-picker-auto/probe + */ + +import { accessSync, constants } from 'node:fs' +import { delimiter, join } from 'node:path' + +/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */ +const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const + +/** + * Whether the current process may execute the candidate path. + * @param candidate - absolute or PATH-joined file path. + * @returns true only for an existing executable file. + */ +export function canExecute(candidate: string): boolean { + try { + accessSync(candidate, constants.X_OK) + } catch { + // Absent or non-executable candidate — the only signals accessSync(X_OK) emits. + return false + } + return true +} + +/** + * Scan a PATH value for one of the native backend's Linux chooser binaries. + * @param pathValue - the `PATH` environment value (absent or empty scans nothing). + * @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests). + * @returns whether any PATH directory holds an executable chooser binary. + */ +export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean { + for (const dir of (pathValue ?? '').split(delimiter)) { + if (dir === '') continue + for (const name of LINUX_CHOOSER_BINARIES) { + if (isExecutable(join(dir, name))) return true + } + } + return false +} diff --git a/packages/host/directory-picker-auto/src/resolve.ts b/packages/host/directory-picker-auto/src/resolve.ts index d2a22e2fa8..395e2da55f 100644 --- a/packages/host/directory-picker-auto/src/resolve.ts +++ b/packages/host/directory-picker-auto/src/resolve.ts @@ -6,6 +6,8 @@ * @module @deepseek-ai/dsh-host-directory-picker-auto/resolve */ +import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver' + /** Concrete interaction backend the resolver chooses between. */ export type DirectoryPickerBackendKind = 'native' | 'browse' @@ -16,12 +18,14 @@ export type DirectoryPickerEnv = Readonly< /** Host facts the backend choice is a pure function of, sampled once at boot. */ export interface DirectoryPickerHostFacts { - /** Effective webserver bind host (`127.0.0.1` or `0.0.0.0`). */ - bindHost: string + /** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */ + bindHost: HttpServerConfig['host'] /** Host process platform. */ platform: NodeJS.Platform /** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */ env: DirectoryPickerEnv + /** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */ + linuxChooser: boolean } /** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */ @@ -29,12 +33,14 @@ const present = (value: string | undefined): boolean => value !== undefined && v /** * Resolve which backend serves this boot. `native` requires every signal that - * the operator can see the host display: a loopback-only bind (an - * all-interfaces bind admits remote browsers no OS chooser can reach), no SSH - * launch (under SSH port-forwarding the chooser would open on the unattended - * server), and a display session (assumed on darwin/win32, `DISPLAY`/ - * `WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`, - * which works everywhere. + * the operator can see the host display and the native backend can serve it: + * a loopback-only bind (an all-interfaces bind admits remote browsers no OS + * chooser can reach), no SSH launch (under SSH port-forwarding the chooser + * would open on the unattended server), and a servable display session — + * assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a + * chooser binary on linux, and never true elsewhere (the native backend + * drives exactly darwin/win32/linux). Anything ambiguous resolves to + * `browse`, which works everywhere. * @param facts - the sampled host facts. * @returns the backend kind to mount. */ @@ -42,5 +48,6 @@ export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): if (facts.bindHost !== '127.0.0.1') return 'browse' if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse' if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native' + if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse' return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse' } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 7e094035fb..ce86a8d4ec 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -3,10 +3,12 @@ * vendored Loader mounts the webserver row plus the adaptive chooser, and the * assertions observe the durable outcome — which backend entry the chooser * mounted into the Loader store, the capability the seam then serves, and - * that disposing the chooser removes the mounted entry again (HMR safety). + * that disposing the chooser removes the mounted entry again (HMR safety), + * joining the backend's own teardown before the disposer settles. */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -25,21 +27,27 @@ const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' let root: string | undefined +let fakeBin: string | undefined let context: Context | undefined afterEach(async () => { vi.unstubAllEnvs() await context?.fiber.dispose() context = undefined - if (root !== undefined) await rm(root, { recursive: true, force: true }) + for (const dir of [root, fakeBin]) { + // maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late + // file handle) that can otherwise race the recursive scan into ENOTEMPTY. + if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) + } root = undefined + fakeBin = undefined }) /** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) const dist = join(root, 'dist') - await mkdir(dist) + mkdirSync(dist) const distIndex = join(dist, 'index.html') await writeFile(distIndex, 'shell') const configPath = join(root, 'cordis.yml') @@ -83,17 +91,26 @@ function entryNames(ctx: Context): string[] { return [...ctx.loader.entries()].map(entry => entry.options.name) } -/** Force every signal of an attended host: no SSH launch, a display on any platform. */ +/** + * Force every signal of an attended host on any platform: no SSH launch, a + * display, and a PATH holding one executable chooser binary so the real + * probe resolves identically on hosts with and without zenity/kdialog. + */ function stubAttendedHost(): void { + fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-')) + const zenity = join(fakeBin, 'zenity') + writeFileSync(zenity, '#!/bin/sh\n') + chmodSync(zenity, 0o755) + vi.stubEnv('PATH', fakeBin) vi.stubEnv('SSH_CONNECTION', '') vi.stubEnv('SSH_TTY', '') vi.stubEnv('DISPLAY', ':0') } describe('real Loader composition', () => { - // Real-Loader composition resolves workspace packages through tsx at test - // time; first resolution after the host/client program split is slow enough - // to trip the default 5s budget on cold caches. + // The 60s budget covers this file's static imports (webserver plus both + // backend node halves through tsx), which dominate on cold caches; the + // Loader itself resolves nothing here — `loader.internal` is a module map. it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => { stubAttendedHost() const { ctx, configPath } = await loadComposition('127.0.0.1') @@ -110,12 +127,19 @@ describe('real Loader composition', () => { // booted config file must never gain the resolved backend row. expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) - // HMR safety: disposing the chooser's fiber removes the entry it created. + // HMR safety: disposing the chooser's fiber removes the entry it created, + // and the disposer joins the backend's teardown — the service is gone the + // moment dispose() settles, with no further loader await. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await autoEntry.fiber!.dispose() - await ctx.loader.await() expect(entryNames(ctx)).not.toContain(NATIVE) expect(ctx.get('directoryPicker')).toBeUndefined() + // Self-disposing an include-tree entry persists `disabled: true` (loader + // behavior, not the chooser's); await that debounced write so it cannot + // race the temp-dir removal, and pin that the persisted row is the + // chooser itself — the resolved backend still never reaches the file. + await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) }) it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => { @@ -136,4 +160,17 @@ describe('real Loader composition', () => { expect(entryNames(ctx)).toContain(BROWSE) expect(entryNames(ctx)).not.toContain(NATIVE) }) + + it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => { + stubAttendedHost() + const { ctx, configPath } = await loadComposition('127.0.0.1') + + const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! + ctx.loader.remove(backendEntry.id) + const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() + expect(entryNames(ctx)).not.toContain(NATIVE) + // Same self-dispose persistence as above: let the write land before teardown. + await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + }) }) diff --git a/packages/host/directory-picker-auto/tests/resolve.spec.ts b/packages/host/directory-picker-auto/tests/resolve.spec.ts index 3beac44961..bf8792cfa9 100644 --- a/packages/host/directory-picker-auto/tests/resolve.spec.ts +++ b/packages/host/directory-picker-auto/tests/resolve.spec.ts @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { canExecute, hasLinuxChooserBinary } from '../src/probe.ts' import { resolveDirectoryPickerBackend } from '../src/resolve.ts' import type { DirectoryPickerHostFacts } from '../src/resolve.ts' -/** Baseline facts that resolve to `native`; each case overrides one signal. */ +/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */ const attended: DirectoryPickerHostFacts = { bindHost: '127.0.0.1', platform: 'darwin', env: {}, + linuxChooser: false, } describe('resolveDirectoryPickerBackend', () => { @@ -24,14 +29,63 @@ describe('resolveDirectoryPickerBackend', () => { expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse') }) - it('requires a display session on platforms without an implied one', () => { - expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux' })).toBe('browse') - expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: ':0' } })).toBe('native') - expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native') + it('requires a display session and a chooser binary on linux', () => { + const linux: DirectoryPickerHostFacts = { ...attended, platform: 'linux', linuxChooser: true } + expect(resolveDirectoryPickerBackend(linux)).toBe('browse') + expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' } })).toBe('native') + expect(resolveDirectoryPickerBackend({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native') + expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' }, linuxChooser: false })).toBe('browse') + }) + + it('resolves browse on platforms the native backend cannot serve, display or not', () => { + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'freebsd', env: { DISPLAY: ':0' }, linuxChooser: true })).toBe('browse') + expect(resolveDirectoryPickerBackend({ ...attended, platform: 'openbsd', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('browse') }) it('treats blank env exports as unset', () => { expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native') - expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: '', WAYLAND_DISPLAY: '' } })).toBe('browse') + expect(resolveDirectoryPickerBackend({ + ...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' }, + })).toBe('browse') + }) +}) + +let probeRoot: string | undefined + +afterEach(() => { + if (probeRoot !== undefined) rmSync(probeRoot, { recursive: true, force: true }) + probeRoot = undefined +}) + +describe('hasLinuxChooserBinary', () => { + it('finds a chooser binary in any PATH segment, skipping empty segments', () => { + const seen: string[] = [] + const path = ['', '/opt/none', '/usr/local/bin'].join(delimiter) + const found = hasLinuxChooserBinary(path, (candidate) => { + seen.push(candidate) + return candidate === join('/usr/local/bin', 'kdialog') + }) + expect(found).toBe(true) + expect(seen).toEqual([ + join('/opt/none', 'zenity'), join('/opt/none', 'kdialog'), + join('/usr/local/bin', 'zenity'), join('/usr/local/bin', 'kdialog'), + ]) + }) + + it('reports absence when no segment holds a chooser binary', () => { + expect(hasLinuxChooserBinary(['/a', '/b'].join(delimiter), () => false)).toBe(false) + expect(hasLinuxChooserBinary('', () => true)).toBe(false) + expect(hasLinuxChooserBinary(undefined, () => true)).toBe(false) + }) +}) + +describe('canExecute', () => { + it('accepts an executable file and rejects an absent one', () => { + probeRoot = mkdtempSync(join(tmpdir(), 'dsh-picker-probe-')) + const binary = join(probeRoot, 'zenity') + writeFileSync(binary, '#!/bin/sh\n') + chmodSync(binary, 0o755) + expect(canExecute(binary)).toBe(true) + expect(canExecute(join(probeRoot, 'kdialog'))).toBe(false) }) }) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 66d6e0f2a3..5ff429a2f4 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -30,6 +30,20 @@ interface PluginReference { const root = resolve(import.meta.dirname, '..') const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const + +/** The adaptive directory-picker chooser package (mounts a backend row at boot). */ +const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' + +/** + * The backends the chooser mounts by runtime string (mirror of its exported + * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting + * the chooser must resolve both, or keyless Linux CI (which only ever + * resolves `browse`) hides a dropped `-native` dependency until a macOS boot. + */ +const CHOOSER_BACKEND_PACKAGES = [ + '@deepseek-ai/dsh-host-directory-picker-native', + '@deepseek-ai/dsh-host-directory-picker-browse', +] const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', resolve: data => typeof data === 'string', @@ -134,12 +148,18 @@ function missingPluginDependencies( manifestPath: string, ): string[] { const requiredPackages = new Map>() + const require = (packageName: string, file: string): void => { + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(file) + requiredPackages.set(packageName, locations) + } for (const reference of references) { const packageName = packageNameFromSpecifier(reference.name) if (packageName === undefined) continue - const locations = requiredPackages.get(packageName) ?? new Set() - locations.add(reference.file) - requiredPackages.set(packageName, locations) + require(packageName, reference.file) + if (packageName === CHOOSER_PACKAGE) { + for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file) + } } return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies ? [] From b6bb24bfa1da69abce3c17c1f1b9ffb0bef5cfe5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 13:20:47 +0800 Subject: [PATCH 26/97] docs: raise AGENTS.md and packages/README.md budget ceilings after the master merge Both files fit their ceilings on each parent; the merge union of this branch's settings rows with master's typert row and source-launch rewrite overflows by 6 and 2 words. Every added row is a fixed-format layout or package-table entry with nothing to relocate, so the ceilings move to the union size. --- scripts/doc-budgets.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 0682f78640..537abeff00 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1755, + "AGENTS.md": 1765, "docs/AGENTS.md": 1150, "docs/architecture.md": 1920, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 900 + "packages/README.md": 905 } From bdc6d95d561b400a08e34307520c4bdb0c838b57 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 13:29:52 +0800 Subject: [PATCH 27/97] fix(settings): close third-review watcher-lifecycle and write-boundary gaps Review round three found four seam defects: - A watch() disposer only removed the observer from the set; an invocation already chained onto the watcher tail still ran after disposal. Watchers now carry an active flag checked when a queued invocation would start, and the service dispose drain awaits started invocations (pendingTails) beside the write queues, so disposal is quiescent. - The settings/updated manual fan-out caught only synchronous throws; an async listener rejection escaped as an unhandled rejection. Thenable returns are now contained through the shared listener diagnostic, and the event contract documents that the INVARIANT rethrow serves synchronous listeners only. - structuredClone admitted Dates, Maps, BigInts, and cycles that YAML/JSON storage silently distorts on reload (a Date lands as a timestamp string, a Map as a plain map, a BigInt as a number). The write snapshot is now a single-pass cloneJsonShaped walk that rejects non-JSON values with their path before anything persists. - mergeLayers' per-entry undefined guard became dead code once the clone strips undefined entries at the boundary; removed, with the sparse-patch contract restated at its enforcement point. --- packages/settings/settings/src/index.ts | 142 +++++++++++++++--- .../settings/settings/tests/settings.spec.ts | 105 ++++++++++++- 2 files changed, 222 insertions(+), 25 deletions(-) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a7e9366048..27decc7db5 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -60,20 +60,24 @@ export interface SettingsScope { /** * Observe committed changes to this namespace's resolved value. Invocations * of one callback run asynchronously, one at a time, in commit order; a - * rejection is contained and logged like a sync throw. + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section. + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. */ replace(section: object): Promise } @@ -88,6 +92,11 @@ declare module 'cordis' { * Committed change to one registered namespace's resolved value. Emitted * after the provider persisted (for `update`) or published (`provider`) * the change; never emitted when the resolved value is deep-equal. + * Listener failures are contained and logged — a sync throw and an async + * rejection alike — except `INVARIANT`-coded failures, which rethrow + * after every listener ran; that rethrow reaches the emitter only from + * synchronous listeners, so invariant checks on this event must not be + * async functions. * @param ns - the namespace whose resolved value changed. * @param next - the new resolved value. * @param prev - the previous resolved value. @@ -127,17 +136,76 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || proto === null } +/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */ +function describeRejected(value: unknown): string { + if (value === undefined) return 'undefined' + if (typeof value === 'object' && value !== null) { + const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null + const name = proto?.constructor?.name + return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}` + } + return `a ${typeof value}` +} + +/** + * Detach one write input in a single walk that doubles as the durable-boundary + * shape check: only JSON data (plain objects, arrays, strings, finite numbers, + * booleans, `null`) may reach a provider document. `structuredClone` alone + * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then + * silently distorts on the reload round-trip. `undefined` entries in objects + * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while + * an `undefined` array entry is rejected rather than coerced. + * @param root - plain-object write input (caller-checked). + * @param reject - builds the boundary error from a value label and its `$`-rooted path. + * @returns the detached JSON-shaped clone. + */ +function cloneJsonShaped( + root: Record, + reject: (label: string, path: string) => TypeError, +): Record { + const visiting = new WeakSet() + const clone = (value: unknown, path: string): unknown => { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw reject('a non-finite number', path) + return value + } + if (Array.isArray(value)) { + if (visiting.has(value)) throw reject('a circular reference', path) + visiting.add(value) + const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`)) + // Un-mark on exit so one object referenced twice without a cycle passes. + visiting.delete(value) + return entries + } + if (isPlainObject(value)) { + if (visiting.has(value)) throw reject('a circular reference', path) + visiting.add(value) + const out: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (entry === undefined) continue + out[key] = clone(entry, `${path}.${key}`) + } + visiting.delete(value) + return out + } + throw reject(describeRejected(value), path) + } + return clone(root, '$') as Record +} + /** * Layer `over` onto `under`: plain objects merge recursively, every other - * value (arrays included) replaces the lower layer wholesale, and `undefined` - * entries in `over` are ignored so a sparse patch cannot erase lower keys. + * value (arrays included) replaces the lower layer wholesale. `over` never + * carries `undefined` entries — sections come from parsed documents and write + * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch + * cannot erase lower keys. */ function mergeLayers(under: unknown, over: unknown): unknown { if (over === undefined) return under if (!isPlainObject(under) || !isPlainObject(over)) return over const merged: Record = { ...under } for (const [key, value] of Object.entries(over)) { - if (value === undefined) continue merged[key] = key in merged ? mergeLayers(merged[key], value) : value } return merged @@ -155,6 +223,8 @@ interface SettingsWatcher { callback: (next: never, prev: never) => void | Promise /** Settled tail: invocations of this callback run one at a time, in commit order. */ tail: Promise + /** Cleared by the disposer: a queued invocation checks this before starting. */ + active: boolean } /** One live namespace registration owned by a registrant fiber. */ @@ -179,6 +249,8 @@ export abstract class Settings extends Service { private document: Record = {} /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */ private readonly writeQueues = new Map>() + /** In-flight watcher invocation segments, drained by the dispose teardown. */ + private readonly pendingTails = new Set>() /** Set at service dispose: refuse new writes while queued ones drain. */ private stopped = false @@ -199,10 +271,12 @@ export abstract class Settings extends Service { */ async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { - // Teardown: refuse new writes, then wait until every queued write chain - // settles so disposal completes only once storage is quiescent. + // Teardown: refuse new writes and new watcher starts, then wait until + // every queued write chain and every started watcher invocation settles + // so disposal completes only once storage and observers are quiescent. + // Invocations queued but not yet started skip via the stopped check. this.stopped = true - await Promise.allSettled([...this.writeQueues.values()]) + await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]) } this.publish(await this.load()) } @@ -252,9 +326,12 @@ export abstract class Settings extends Service { return { get: () => registration.resolved as T, watch: (callback) => { - const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve() } + const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true } registration.watchers.add(watcher) - return () => registration.watchers.delete(watcher) + return () => { + watcher.active = false + registration.watchers.delete(watcher) + } }, update: patch => this.update(ns, patch), replace: section => this.replace(ns, section), @@ -325,13 +402,10 @@ export abstract class Settings extends Service { throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`) } // Snapshot at call time: the queue must never read a caller-owned object - // the caller may keep mutating while the write waits its turn. - let snapshot: Record - try { - snapshot = structuredClone(input) - } catch { - throw new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped (structured-cloneable) data`) - } + // the caller may keep mutating while the write waits its turn. The same + // walk is the JSON-shape boundary check (see cloneJsonShaped). + const snapshot = cloneJsonShaped(input, (label, path) => + new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`)) const previous = this.writeQueues.get(ns) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the // namespace queue for every later caller. @@ -407,11 +481,20 @@ export abstract class Settings extends Service { // Serialize per watcher: invocations of one callback run one at a time // in commit order, so a slow stale invocation can never apply after a // newer one. Sync throws and async rejections land in the same handler. - watcher.tail = watcher.tail - .then(() => watcher.callback(next as never, prev as never)) + // The activity check runs when the queued invocation would start, so a + // disposer (or service stop) that ran while it waited prevents the + // start entirely; started invocations drain at service dispose. + const segment = watcher.tail + .then(() => { + if (!watcher.active || this.isStopped()) return + return watcher.callback(next as never, prev as never) + }) .then(() => undefined, (error: unknown) => { this.warnWatcherFailure(registration.ns, error) }) + watcher.tail = segment + this.pendingTails.add(segment) + void segment.then(() => this.pendingTails.delete(segment)) } // Fan the event out one listener at a time (the plain emit stops at the // first throwing listener, starving the rest). Invariant violations are @@ -422,14 +505,21 @@ export abstract class Settings extends Service { const args = ['settings/updated', registration.ns, next, prev, source] for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { try { - listener(registration.ns, next, prev, source) + const returned = listener(registration.ns, next, prev, source) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + // An emit listener may still be an async function; its rejection + // cannot reach the synchronous INVARIANT rethrow below, so it is + // contained here instead of becoming an unhandled rejection. + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnListenerFailure(registration.ns, error) + }) + } } catch (error) { if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { invariantFailure ??= error continue } - this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns) - this.ctx.logger.warn(error) + this.warnListenerFailure(registration.ns, error) } } if (invariantFailure !== undefined) throw invariantFailure as Error @@ -440,6 +530,12 @@ export abstract class Settings extends Service { this.ctx.logger.warn('settings: watcher for "%s" failed', ns) this.ctx.logger.warn(error) } + + /** Contained-listener diagnostic shared by the sync and async failure paths. */ + private warnListenerFailure(ns: SettingsNamespace, error: unknown): void { + this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns) + this.ctx.logger.warn(error) + } } export default Settings diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index a989d9a5cc..cfd88b166f 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -228,6 +228,14 @@ describe('update', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) }) + it('ignores an explicit undefined entry in the composition base layer', async () => { + const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + base: { theme: undefined, fontSize: 16 }, + }) + expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) + }) + it('rejects a non-object patch', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) @@ -433,11 +441,11 @@ describe('second review regressions', () => { expect(applied).toEqual([1, 2]) }) - it('rejects a plain object that is not structured-cloneable', async () => { + it('rejects a function value as not JSON-shaped', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await expect(scope.update({ theme: () => 'dark' })) - .rejects.toThrow(/JSON-shaped/) + .rejects.toThrow(/JSON-shaped.*function at \$\.theme/) }) it('rejects a write still queued when the service disposes', async () => { @@ -532,6 +540,99 @@ describe('publish', () => { }) }) +describe('third review regressions', () => { + it('skips a queued watch invocation whose disposer ran before it started', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const watcher = vi.fn() + const dispose = scope.watch(watcher) + // The commit chains the invocation as a microtask; the disposer runs in + // the same synchronous frame, before that invocation could start. + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + dispose() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(watcher).not.toHaveBeenCalled() + }) + + it('waits for an in-flight watch invocation at service dispose', async () => { + const { ctx, provider, fiber } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + let release: (() => void) | undefined + let finished = false + scope.watch(async () => { + await new Promise((resolve) => { release = resolve }) + finished = true + }) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + await vi.waitFor(() => { expect(release).toBeDefined() }) + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setTimeout(resolve, 15)) + expect(disposed).toBe(false) + release!() + await disposal + expect(finished).toBe(true) + }) + + it('rejects a Date at its path before anything persists', async () => { + const { ctx, provider } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + await expect(scope.update({ value: { at: new Date(0) } })) + .rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/) + expect(provider.persisted).toEqual([]) + }) + + it.each([ + ['a Map', { value: new Map() }, /Map at \$\.value/], + ['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/], + ['a symbol', { value: Symbol('x') }, /symbol at \$\.value/], + ['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/], + ['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/], + ['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/], + ])('rejects %s that structuredClone would admit', async (_label, patch, message) => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + await expect(scope.update(patch)).rejects.toThrow(message) + }) + + it('rejects a circular patch instead of storing an alias-looped document', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + const cyclic: Record = {} + cyclic['self'] = cyclic + await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/) + const loop: unknown[] = [] + loop.push(loop) + await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/) + }) + + it('accepts one object referenced twice without a cycle', async () => { + const { ctx } = await boot() + const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) + const shared = { leaf: 1 } + await scope.update({ value: { left: shared, right: shared } }) + expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } }) + }) + + it('contains an async settings/updated listener rejection and keeps other listeners running', async () => { + const { ctx, provider } = await boot() + // An async listener violates the event's synchronous signature (typed + // consumers get a lint error for it), but an unlinted JS plugin can still + // register one; the cast simulates exactly that caller. + ctx.on('settings/updated', async () => { + throw new Error('async listener boom') + }) + const second = vi.fn() + ctx.on('settings/updated', second) + ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + provider.pushExternal({ 'ui-theme': { theme: 'light' } }) + expect(second).toHaveBeenCalledTimes(1) + // Containment gives the rejection a handler; vitest observes no unhandled + // rejection out of this test. + await new Promise(resolve => setTimeout(resolve, 10)) + }) +}) + describe('watch', () => { it('stops after its disposer runs', async () => { const { ctx, provider } = await boot() From 85a3a158dde4e532e83a93c3889172518a950b6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 13:39:22 +0800 Subject: [PATCH 28/97] fix(settings-local): one operation chain, read-modify-write under a writer lock, and diff-shaped YAML edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round three found the provider's write path could destroy state it never observed: - Watcher reloads and document writes ran on two independent promise chains, and a write rendered the whole next document from the cached text. An external edit still inside the debounce window (or missed outright) was overwritten, and the follow-up reload no-oped because the post-rename content matched the cache — the edit vanished without a trace. Reloads and writes now share one operation chain, and every write starts by reconciling the on-disk text into the seam before rendering, so unobserved sibling sections survive and publish first. An unparsable on-disk document fails the write loud instead of being overwritten. - The initial load raced the watcher's own setup: a change written between that read and the watcher becoming active never fired an event. The watcher's ready signal now queues one reconcile, closing the gap. - Two processes sharing a harness home rendered from independent caches, last writer winning. Writes now hold a wx-created .lock sibling around the read-render-rename cycle with bounded backoff, a crashed- holder stale takeover, and a deadline failure; readers stay lock-free because the rename commit is atomic. - renderYaml replaced the whole namespace node, dropping every comment inside the section. The next section now lands as a leaf-level diff (set changed values, delete removed keys), so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; arrays still replace wholesale when unequal. --- packages/settings/settings-local/src/index.ts | 247 ++++++++++++++---- .../settings-local/tests/concurrency.spec.ts | 103 ++++++++ .../settings-local/tests/local.spec.ts | 96 +++++++ .../settings-local/tests/lock-race.spec.ts | 100 +++++++ .../settings-local/tests/watcher.spec.ts | 55 +++- 5 files changed, 545 insertions(+), 56 deletions(-) create mode 100644 packages/settings/settings-local/tests/concurrency.spec.ts create mode 100644 packages/settings/settings-local/tests/lock-race.spec.ts diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index b305f61fe7..b9df71f9e1 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -9,11 +9,11 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' /** Plugin config: file location and hot-reload behavior. */ export interface Config { @@ -64,11 +64,53 @@ export function resolveSpec(config: Config): ResolvedSpec { } } +/** Whether a parsed YAML value is a map for diffing purposes. */ +function isMapLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Apply the difference between one node's stored and next value as minimal + * `setIn`/`deleteIn` edits, recursing through maps, so every untouched node — + * and the key node of every changed pair — keeps its comments, anchors, and + * formatting. Non-map values (arrays and scalars) replace wholesale when + * unequal, taking any comments inside them along. + */ +function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void { + if (isMapLike(current) && isMapLike(next)) { + for (const key of Object.keys(current)) { + if (!(key in next)) document.deleteIn([...path, key]) + } + for (const [key, value] of Object.entries(next)) { + patchNode(document, [...path, key], current[key], value) + } + return + } + if (!deepEqualJson(current, next)) document.setIn([...path], next) +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** + * Writer-lock protocol constants. These are robustness invariants of the + * cross-process write protocol, not deployment tunables: a holder rewrites one + * small document in milliseconds, so contention resolves well inside the + * retry deadline, and a lock older than the stale age can only belong to a + * crashed holder. + */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z = z.object({ @@ -85,10 +127,13 @@ export class SettingsLocal extends Settings { * this cache are no-ops, which is also the self-write suppression. */ private text: string | undefined - /** Serializes watcher-triggered reloads so reads never interleave. */ - private refreshTask: Promise = Promise.resolve() - /** Serializes whole-document writes across namespace queues; settled tail. */ - private persistChain: Promise = Promise.resolve() + /** + * Single exclusive operation chain: watcher reloads and document writes run + * one at a time in queue order (settled tail), so a write can never render + * from text a concurrent reload is busy replacing, and a reload can never + * read a half-committed write. + */ + private operations: Promise = Promise.resolve() /** Set at dispose: refuse new watcher events and let in-flight work no-op. */ private closed = false @@ -125,32 +170,107 @@ export class SettingsLocal extends Settings { protected persist(ns: SettingsNamespace, section: Record): Promise { // One document backs every namespace, so writes from different namespace - // queues must serialize here: each render must see the text the previous - // write committed, or the loser's section silently vanishes from disk. - // The stored tail is settled on both outcomes, so chaining needs no catch. - const task = this.persistChain.then(() => this.persistSection(ns, section)) - this.persistChain = task.then(() => undefined, () => undefined) + // queues serialize with each other and with watcher reloads on the one + // operation chain: each render must see the text the previous operation + // committed, or a sibling section silently vanishes from disk. + return this.enqueue(() => this.persistSection(ns, section)) + } + + /** Queue one exclusive document operation behind every earlier one. */ + private enqueue(operation: () => Promise): Promise { + const task = this.operations.then(operation) + this.operations = task.then(() => undefined, () => undefined) return task } + /** Queue a reload; only an invariant violation escaping a commit can reject it. */ + private queueRefresh(): void { + void this.enqueue(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the commit path can reject a + // refresh; keep the operation queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + } + private async persistSection(ns: SettingsNamespace, section: Record): Promise { - const output = this.spec.format === 'yaml' - ? this.renderYaml(ns, section) - : this.renderJson(ns, section) await mkdir(dirname(this.spec.filename), { recursive: true }) - // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to - // follow any planted symlink at a guessable temp path, and the fresh inode - // carries owner-only permissions that survive the rename — a document that - // may hold personal values is never world-readable and never a symlink. - const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) - await rename(temp, this.spec.filename) - } catch (error) { - await rm(temp, { force: true }) - throw error + await this.withWriterLock(async () => { + // Read-modify-write: fold in any on-disk state this process has not + // observed yet — an external edit still inside the watcher debounce + // window, a change the watcher missed, or another process's write — so + // the render below can never resurrect a stale document. An unparsable + // on-disk document fails the write loud instead of silently overwriting + // a user's manual edit. + await this.reconcileFromDisk() + const output = this.spec.format === 'yaml' + ? this.renderYaml(ns, section) + : this.renderJson(ns, section) + // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to + // follow any planted symlink at a guessable temp path, and the fresh inode + // carries owner-only permissions that survive the rename — a document that + // may hold personal values is never world-readable and never a symlink. + const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) + await rename(temp, this.spec.filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } + this.text = output + }) + } + + /** + * Hold the cross-process writer lock around one read-render-rename cycle. + * The lock is a `wx`-created sibling (`.lock`); the rename-based + * commit keeps readers lock-free, so only writers contend. A lock older + * than {@link LOCK_STALE_MS} is a crashed holder and is broken with a + * warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write. + */ + private async withWriterLock(operation: () => Promise): Promise { + const lockPath = `${this.spec.filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await this.lockAgeMs(lockPath) + // The holder released between the failed create and the stat: the lock + // is free right now, so retry without burning backoff or deadline. + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + + /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ + private async lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined } - this.text = output } override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { @@ -168,13 +288,14 @@ export class SettingsLocal extends Settings { }) watcher.on('all', () => { if (this.closed) return - this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the commit path can reject a - // refresh; keep the reload queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) + this.queueRefresh() + }) + watcher.on('ready', () => { + // The base init's load raced the watcher's own setup: a change written + // between that read and the watcher becoming active never fires an + // event. One reconcile at ready closes the gap. + if (this.closed) return + this.queueRefresh() }) watcher.on('error', (error) => { this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename) @@ -182,10 +303,10 @@ export class SettingsLocal extends Settings { }) yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight refresh so nothing publishes after disposal. + // queued or in-flight operation so nothing publishes after disposal. this.closed = true await watcher.close() - await this.refreshTask + await this.operations } } @@ -212,46 +333,62 @@ export class SettingsLocal extends Settings { * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable or unparsable * document keeps the last good sections and warns — a live hot-reload must - * never take the process down. + * never take the process down. An invariant violation escaping a commit is + * not a reload failure and propagates to the queue's error surface. */ private async refresh(): Promise { if (this.closed) return - let text: string + try { + await this.reconcileFromDisk() + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error + this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + } + } + + /** + * Compare the on-disk text against the cache and publish any difference + * into the seam. Absence publishes the empty document; an unreadable or + * unparsable file throws, so each caller picks its policy — a reload warns + * and keeps the last good document, a write fails loud. + */ + private async reconcileFromDisk(): Promise { + let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { - if (!isENOENT(error)) { - this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - return - } - if (this.text === undefined || this.isClosed()) return + if (!isENOENT(error)) throw error + text = undefined + } + if (text === this.text || this.isClosed()) return + if (text === undefined) { this.text = undefined this.publish({}) return } - if (text === this.text || this.isClosed()) return - let doc: Record - try { - doc = this.parse(text) - } catch (error) { - this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - return - } + const doc = this.parse(text) this.text = text this.publish(doc) } - /** Render the next YAML text by patching one namespace in the comment-preserving document. */ + /** + * Render the next YAML text by patching one namespace in the + * comment-preserving document. The next section lands as a leaf-level diff + * against the stored one — only changed values set, only removed keys + * delete — so comments inside the section survive edits to their siblings, + * not just comments outside it. + */ private renderYaml(ns: SettingsNamespace, section: Record): string { if (this.text === undefined) { return new Document({ [ns]: section }).toString() } // this.text only ever caches content that parsed successfully, so this - // re-parse (for the mutable comment-preserving tree) cannot fail. + // re-parse (for the mutable comment-preserving tree) cannot fail, and + // parse() already rejected any non-map root. const document = parseDocument(this.text) - document.set(ns, section) + const root: unknown = document.toJS() + patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section) return document.toString() } diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts new file mode 100644 index 0000000000..ab09866819 --- /dev/null +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -0,0 +1,103 @@ +// Cross-instance and writer-lock behavior: two providers on one document are +// the in-process equivalent of two dsh processes sharing a harness home — +// neither knows the other's cache, so only the read-modify-write cycle under +// the `.lock` sibling keeps both namespaces alive on disk. +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) +const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('cross-instance writes', () => { + it('keeps both namespaces when two providers write the same document concurrently', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const first = await boot({ path, watch: false }) + const second = await boot({ path, watch: false }) + const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema) + const beta = second.settings.register(settingsNamespace('beta'), BetaSchema) + const rounds = [1, 2, 3, 4, 5] + await Promise.all([ + (async () => { for (const value of rounds) await alpha.update({ value }) })(), + (async () => { for (const value of rounds) await beta.update({ value }) })(), + ]) + const text = await readFile(path, 'utf8') + expect(text).toContain('alpha:') + expect(text).toContain('beta:') + // A third instance resolves both final values from the shared document. + const third = await boot({ path, watch: false }) + expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 }) + expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 }) + }) +}) + +describe('writer lock', () => { + it('waits for a busy writer lock instead of failing', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'holder\n') + const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120) + cleanups.push(async () => { clearTimeout(release) }) + await scope.update({ value: 7 }) + expect(await readFile(path, 'utf8')).toContain('value: 7') + }) + + it('breaks a stale writer lock with a warning and writes through', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'crashed-holder\n') + const past = (Date.now() - 60_000) / 1000 + await utimes(`${path}.lock`, past, past) + await scope.update({ value: 9 }) + expect(await readFile(path, 'utf8')).toContain('value: 9') + }) + + it('times out on a lock a live holder never releases', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await writeFile(`${path}.lock`, 'busy-holder\n') + await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/) + }, 10_000) + + it('surfaces a non-contention lock failure as the write error', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + await chmod(dir, 0o500) + cleanups.push(() => chmod(dir, 0o700)) + await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/) + }) +}) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 4c3c24ccd9..0b753675f5 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -204,6 +204,102 @@ describe('persist', () => { expect(written).toContain('theme: light') }) + it('keeps comments inside the section when a sibling key changes', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + ' fontSize: 12', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ fontSize: 18 }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: light') + expect(written).toContain('fontSize: 18') + }) + + it('keeps a changed key\'s own-line comment while replacing its value', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'dark' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: dark') + }) + + it('deletes only the removed key on replace, keeping sibling comments', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, [ + 'ui-theme:', + ' # chosen during onboarding', + ' theme: light', + ' fontSize: 12', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.replace({ theme: 'light' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# chosen during onboarding') + expect(written).toContain('theme: light') + expect(written).not.toContain('fontSize') + }) + + it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const TagsSchema: z<{ tags: string[]; label: string }> = z.object({ + tags: z.array(z.string()).default([]), + label: z.string().default(''), + }) + await writeFile(path, [ + 'workspace:', + ' tags:', + ' # pinned by hand', + ' - alpha', + ' label: draft', + '', + ].join('\n')) + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema) + await scope.update({ label: 'final' }) + const untouched = await readFile(path, 'utf8') + expect(untouched).toContain('# pinned by hand') + expect(untouched).toContain('label: final') + // A changed array replaces wholesale; comments inside it go with it. + await scope.update({ tags: ['beta'] }) + const replaced = await readFile(path, 'utf8') + expect(replaced).not.toContain('# pinned by hand') + expect(replaced).toContain('- beta') + }) + + it('keeps a comment-only document\'s comment when the first section lands', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + // Parses to a null root: the document exists but holds no sections yet. + await writeFile(path, '# reserved for future settings\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + await scope.update({ theme: 'light' }) + const written = await readFile(path, 'utf8') + expect(written).toContain('# reserved for future settings') + expect(written).toContain('theme: light') + }) + it('creates a json document from scratch', async () => { const dir = await tempDir() const path = join(dir, 'settings.json') diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts new file mode 100644 index 0000000000..09eb025654 --- /dev/null +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -0,0 +1,100 @@ +// Writer-lock races that cannot be timed from outside: a contender whose lock +// vanishes between the failed exclusive create and the stat, a stat failing +// for a reason other than absence, and a temp-file write failing mid-cycle. +// The fs/promises seam is partially mocked to inject exactly one failure at a +// chosen path suffix; everything else passes through to the real filesystem. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '../src/index.ts' + +const state = vi.hoisted(() => ({ + /** One-shot failure injections keyed by operation, matched on a path suffix. */ + failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const inject = (op: 'writeFile' | 'stat', path: unknown): void => { + const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix)) + if (index === -1) return + const [failure] = state.failures.splice(index, 1) + throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code }) + } + return { + ...actual, + writeFile: (async (path: unknown, ...rest: never[]) => { + inject('writeFile', path) + return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.writeFile, + stat: (async (path: unknown, ...rest: never[]) => { + inject('stat', path) + return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.stat, + } +}) + +const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) }) + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + state.failures.length = 0 + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(SettingsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('writer-lock races', () => { + it('retries immediately when the contending lock vanished before the stat', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + // The exclusive create loses to a holder that releases before the stat: + // no lock file actually exists, so the stat sees honest absence and the + // very next attempt takes the lock. + state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + await scope.update({ value: 3 }) + expect(await readFile(path, 'utf8')).toContain('value: 3') + }) + + it('propagates a stat failure that does not mean absence', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' }) + await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/) + }) + + it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'alpha:\n value: 1\n') + const ctx = await boot({ path, watch: false }) + const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) + state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' }) + await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/) + // The document is untouched and the writer lock was released on the way out. + expect(await readFile(path, 'utf8')).toContain('value: 1') + await expect(access(`${path}.lock`)).rejects.toThrow() + }) +}) diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts index 439f213473..7b289b22a3 100644 --- a/packages/settings/settings-local/tests/watcher.spec.ts +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -155,6 +155,7 @@ describe('watcher pipeline', () => { await fiber.dispose() disposed = true instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('ready') await new Promise(resolve => setTimeout(resolve, 100)) expect(postDisposeCommits).toBe(0) }) @@ -169,4 +170,56 @@ describe('watcher pipeline', () => { await new Promise(resolve => setTimeout(resolve, 50)) expect(scope.get()).toEqual({ theme: 'dark' }) }) + + it('folds an unobserved external edit into a write instead of overwriting it', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const editor = ctx.settings.register(settingsNamespace('editor'), z.object({ + tabWidth: z.number().default(2), + })) + // The external edit has landed on disk but its watcher event has not + // fired yet (a debounce window, or a missed event): the write must fold + // it in, not resurrect the stale document. + await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n') + await theme.update({ theme: 'darker' }) + const text = await readFile(path, 'utf8') + expect(text).toContain('tabWidth: 8') + expect(text).toContain('theme: darker') + // The fold published the unobserved section before the write committed. + expect(editor.get()).toEqual({ tabWidth: 8 }) + }) + + it('reconciles at watcher ready so a change during setup is not missed', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + // Written after the initial load but before the watcher became active: + // no 'all' event will ever fire for it. + await writeFile(path, 'ui-theme:\n theme: written-before-ready\n') + const [instance] = await fakeInstances() + instance!.watcher.emit('ready') + await vi.waitFor(() => { + expect(scope.get().theme).toBe('written-before-ready') + }) + }) + + it('fails a write loud when the on-disk document turned invalid unobserved', async () => { + const dir = await tempDir() + const path = join(dir, 'settings.yaml') + await writeFile(path, 'ui-theme:\n theme: light\n') + const ctx = await boot({ path, debounceMs: 5 }) + const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) + const broken = 'ui-theme: [unclosed\n flow: {\n' + await writeFile(path, broken) + await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/) + // The user's manual edit stays on disk untouched and the cache keeps the + // last good value. + expect(await readFile(path, 'utf8')).toBe(broken) + expect(scope.get()).toEqual({ theme: 'light' }) + }) }) From 3b1b9125180c23c70f8a090b215a7f1d3f05692d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 14:09:04 +0800 Subject: [PATCH 29/97] docs(settings): third-review contracts across READMEs, catalogs, and the write-path integrity note The seam README states the JSON-shaped write boundary, watch-disposer quiescence, async listener containment, and the drained teardown; the provider README rewrites Behavior around the operation chain, read-modify-write, writer lock, ready reconcile, and leaf-level YAML diffs, and updates Known Limitations to the residual guarantees. A new Agent Note records the round's decisions and supersedes the original note's deferred-lockfile alternative (cross-linked in place). Chinese counterparts updated pair-by-pair (three briefed minimal updates, one whole-document translation); type-equiv, config, cordis, and module-graph catalogs re-recorded. --- .../2026-07-28-user-settings-seam.i18n.yaml | 4 +- .../2026-07-28-user-settings-seam.md | 4 +- .../2026-07-28-user-settings-seam.zh.md | 4 +- ...30-settings-write-path-integrity.i18n.yaml | 6 +++ ...026-07-30-settings-write-path-integrity.md | 35 ++++++++++++++++ ...-07-30-settings-write-path-integrity.zh.md | 41 +++++++++++++++++++ docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 9 +++- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/settings.i18n.yaml | 4 +- docs/core-data-structures/settings.md | 10 +++-- docs/core-data-structures/settings.zh.md | 10 +++-- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 17 +++++--- packages/settings/settings-local/README.zh.md | 17 +++++--- packages/settings/settings-local/src/index.ts | 4 +- packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 8 ++-- packages/settings/settings/README.zh.md | 8 ++-- 21 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml index cc409d8403..736a372f27 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.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 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md -2026-07-28-user-settings-seam.md: f0f45d77c8f98fc15625b1a1bf116ec10b965676 -2026-07-28-user-settings-seam.zh.md: eb562099b1ff0b5b0a019e9956d6e36e71857236 +2026-07-28-user-settings-seam.md: bf93f95168b1b6d0dec5a9fc2c9aac5531f0564a +2026-07-28-user-settings-seam.zh.md: 8cd4dfcbb2facdd590b2c24d453ad79e9badda4d diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md index f0f45d77c8..bf93f95168 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md @@ -14,7 +14,7 @@ User-editable configuration had no owner: `dsh web` read a cwd-anchored profile **Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section. -**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `/settings.yaml`), chokidar watch, atomic `0600` tmp+rename writes, comment-preserving YAML patching of exactly one namespace key, and content-equality self-write suppression. +**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `/settings.yaml`), chokidar watch, read-modify-write persists under a cross-process writer lock with atomic `0600` tmp+rename commits, leaf-level diff patching of the written namespace (comments survive untouched nodes), and content-equality self-write suppression ([write-path integrity note](2026-07-30-settings-write-path-integrity.md)). **Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner. @@ -28,7 +28,7 @@ User-editable configuration had no owner: `dsh web` read a cwd-anchored profile - **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic. - **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema. - **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend. -- **A cross-process lockfile now** (Pi's proper-lockfile): atomic replace plus watcher convergence (last write wins) is documented behavior until real contention shows up. +- **A cross-process lockfile now** (Pi's proper-lockfile): initially deferred as "atomic replace plus watcher convergence until real contention shows up" — review showed convergence loses unobserved sibling namespaces, so the deferral is superseded by the [write-path integrity note](2026-07-30-settings-write-path-integrity.md)'s hand-rolled writer lock. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md index eb562099b1..8cd4dfcbb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-user-settings-seam.zh.md @@ -14,7 +14,7 @@ Status: implemented **两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。 -**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `/settings.yaml` 的 YAML/JSON、chokidar 监听、`0600` tmp+rename 原子写、只修补目标 namespace 键的保注释 YAML 写回、按内容相等抑制自写。 +**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `/settings.yaml` 的 YAML/JSON、chokidar 监听、跨进程写锁下以 `0600` tmp+rename 原子提交的读-改-写 persist、对被写 namespace 的叶子级 diff 修补(未触碰节点的注释得以保留)、按内容相等抑制自写([write-path integrity note](2026-07-30-settings-write-path-integrity.md))。 **注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。 @@ -28,7 +28,7 @@ Status: implemented - **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。 - **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。 - **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。 -- **现在就上跨进程锁**(Pi 的 proper-lockfile):原子替换加 watcher 收敛(后写胜出)是已记录的行为,真实冲突出现再说。 +- **现在就上跨进程锁**(Pi 的 proper-lockfile):最初以"原子替换加 watcher 收敛,真实冲突出现再说"为由延后——评审发现收敛会丢失未观察到的同级 namespace,因此该延后已被 [write-path integrity note](2026-07-30-settings-write-path-integrity.md) 的手写写锁取代。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml new file mode 100644 index 0000000000..fa54d657a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc +2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md new file mode 100644 index 0000000000..07bd095162 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -0,0 +1,35 @@ +# Agent Note: settings write-path integrity and observer lifecycle + +Status: implemented + +English | [中文](2026-07-30-settings-write-path-integrity.zh.md) + +> Scope: the third review round over `packages/settings/` — write-path data integrity in `dsh-settings-local` (operation chain, read-modify-write, cross-process writer lock, diff-shaped YAML edits) and observer lifecycle in `dsh-settings` (watch disposal, async listener containment, the JSON-shape write boundary). This note reverses one deferral recorded in the [user-settings seam note](2026-07-28-user-settings-seam.md): the cross-process lockfile now ships. + +## Problem + +Review found the provider's write path could destroy state it never observed, and the seam's observer lifecycle leaked past disposal. Concretely: watcher reloads and document writes ran on two independent promise chains while every write rendered the whole next document from the cached text, so an external edit still inside the debounce window was overwritten — and the follow-up reload no-oped because the post-rename content matched the cache, erasing the edit without a trace. The initial `load()` raced the watcher's own setup, leaving a startup window whose changes never fire an event. Two processes sharing a harness home rendered from independent caches, last writer winning whole namespaces. On the seam side, a `watch()` disposer only removed the observer from its set — an invocation already chained onto the watcher tail still ran after disposal, and nothing drained started invocations at service dispose; the `settings/updated` manual fan-out caught only synchronous throws, so an async listener's rejection escaped as an unhandled rejection; and `structuredClone` admitted Dates, Maps, BigInts, and cycles that YAML/JSON storage silently distorts on the reload round-trip (a Date lands as a timestamp string, a BigInt as a plain number). YAML writes replaced the whole namespace node, deleting every comment inside the section a comment-preserving provider had promised to keep. + +## Decision + +**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. + +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. + +**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. + +**The write boundary admits JSON data only.** The call-time snapshot is a single `cloneJsonShaped` walk that detaches the patch and rejects any non-JSON value — Date, Map, BigInt, non-finite number, function, symbol, class instance, `undefined` array entry, circular reference — with its `$`-rooted path before anything persists. Object entries that are explicitly `undefined` still skip (the sparse-patch contract), now enforced at the boundary instead of inside `mergeLayers`. + +**YAML edits are leaf-level diffs.** `renderYaml` diffs the stored section against the next one and applies only `setIn` for changed values and `deleteIn` for removed keys, recursing through maps. Comments, anchors, and formatting survive on every untouched node and on the key node of every changed pair; arrays and other non-map values replace wholesale when unequal (`deepEqualJson` is the shared predicate), taking comments inside them along. + +## Alternatives considered + +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. +- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. +- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. +- **Keeping `structuredClone` and validating in the provider** — the seam is the durable boundary's owner (every provider stores JSON-shaped documents), and rejecting at call time gives the caller the offending path; a provider-side check would reject after merge, blaming the merged section instead of the caller's value. + +## Consequences + +`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md new file mode 100644 index 0000000000..5d02177073 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -0,0 +1,41 @@ +# Agent Note: settings 写路径完整性与观察者生命周期 + +Status: implemented + +[English](2026-07-30-settings-write-path-integrity.md) | 中文 + +> 范围:对 `packages/settings/` 的第三轮评审——`dsh-settings-local` 的写路径数据完整性(操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑)与 `dsh-settings` 的观察者生命周期(watch 的 dispose(资源释放)、异步监听器收容、JSON 形态写入边界)。本 note 推翻了[用户设置 seam note](2026-07-28-user-settings-seam.md)所记录的一项延后决定:跨进程锁文件现已交付。 + +## 问题 + +评审发现,提供方的写路径可能销毁它从未观察到的状态,而 seam 的观察者生命周期会泄漏到 dispose 之后。具体而言:watcher 重载与文档写入跑在两条相互独立的 promise 链上,而每次写入都从缓存文本渲染出完整的下一份文档,于是仍处于防抖窗口内的外部编辑会被覆盖——随后的重载又因 rename 后的内容与缓存一致而成为空操作,这次编辑就被无痕抹去。初始 `load()` 与 watcher 自身的建立过程存在竞态,留下一个启动窗口:落在这个窗口内的变更永远不会触发事件。共享同一 harness home 的两个进程各自从独立的缓存渲染,后写者以整个 namespace 为单位胜出。 + +在 seam 一侧,`watch()` 的释放器只把观察者从集合中移除——已经接到 watcher 链尾的调用在 dispose 之后照常运行,服务 dispose 时也没有任何环节排空已启动的调用;`settings/updated` 的手动扇出只捕获同步抛错,异步监听器的 rejection 会以 unhandled rejection 的形式逃逸;`structuredClone` 则放行 Date、Map、BigInt 与循环引用,而 YAML/JSON 存储会在重载往返中悄悄扭曲这些值(Date 会变成时间戳字符串,BigInt 会变成普通数字)。 + +YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删掉——而这个保注释的提供方承诺过要保住它们。 + +## 决策 + +**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 + +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 + +**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 + +**写入边界只放行 JSON 数据。**调用时刻的快照就是一次 `cloneJsonShaped` 遍历:它把 patch 从调用方分离出来,并在任何内容持久化之前拒绝一切非 JSON 值——Date、Map、BigInt、非有限数值、函数、symbol、类实例、值为 `undefined` 的数组元素、循环引用——拒绝时附带该值以 `$` 为根的路径。显式为 `undefined` 的对象条目仍会跳过(稀疏 patch 契约),这一契约如今在边界处强制执行,而不再放在 `mergeLayers` 内部。 + +**YAML 编辑是叶子级 diff。**`renderYaml` 对比已存储分节与下一份分节,只对变化的值应用 `setIn`、对移除的键应用 `deleteIn`,并沿 map 递归。注释、锚点与格式在每个未触碰节点上、以及每个被改键值对的键节点上全部保留;数组等非 map 值在不相等时整体替换(`deepEqualJson` 是共享的判定谓词),其内部注释随之一并被带走。 + +## 曾考虑的替代方案 + +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 +- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 +- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 +- **保留 `structuredClone`、在提供方里做校验**——seam 才是持久化边界的所有者(每个提供方存储的都是 JSON 形态文档),而且在调用时刻拒绝能把违规值的路径给到调用方;提供方侧的检查要到合并之后才拒绝,归咎的是合并后的分节,而不是调用方传入的值。 + +## 后果 + +`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 + +[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8a960f7c04..ce8fd03d49 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1247,7 +1247,7 @@ export interface Config { } ``` -Source: [`packages/settings/settings-local/src/index.ts:19`](../packages/settings/settings-local/src/index.ts) +Source: [`packages/settings/settings-local/src/index.ts:21`](../packages/settings/settings-local/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6cd530e4fd..a1a7efadf4 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -663,13 +663,18 @@ Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/s ### `settings/updated` — emit -Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. +Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for `update`) or published (`provider`) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. ```ts cordis-catalog /** * Committed change to one registered namespace's resolved value. Emitted * after the provider persisted (for `update`) or published (`provider`) * the change; never emitted when the resolved value is deep-equal. + * Listener failures are contained and logged — a sync throw and an async + * rejection alike — except `INVARIANT`-coded failures, which rethrow + * after every listener ran; that rethrow reaches the emitter only from + * synchronous listeners, so invariant checks on this event must not be + * async functions. * @param ns - the namespace whose resolved value changed. * @param next - the new resolved value. * @param prev - the previous resolved value. @@ -681,7 +686,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:97`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:106`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3fe2e69334..e53d981d03 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1693,7 +1693,7 @@ async replace(ns: SettingsNamespace, section: object): Promise Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:176`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:246`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index cca43c251b..7a971156e7 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.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 docs/core-data-structures/settings.md -settings.md: abbfecb35f67b27e16dffb9558a35cf368c90beb -settings.zh.md: c746e3cc181f8347cbe634b9231cfee1b3beecd3 +settings.md: 381b36b3ff2f45a2090a2a2eac0f700bd00270c4 +settings.zh.md: bc6547db3b05c5a78f112462ae205d848f93da60 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index abbfecb35f..381b36b3ff 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -48,20 +48,24 @@ interface SettingsScope { /** * Observe committed changes to this namespace's resolved value. Invocations * of one callback run asynchronously, one at a time, in commit order; a - * rejection is contained and logged like a sync throw. + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section. + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. */ replace(section: object): Promise } diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index c746e3cc18..bc6547db3b 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -48,20 +48,24 @@ interface SettingsScope { /** * Observe committed changes to this namespace's resolved value. Invocations * of one callback run asynchronously, one at a time, in commit order; a - * rejection is contained and logged like a sync throw. + * rejection is contained and logged like a sync throw. After the disposer + * returns, no further invocation starts — one already queued is skipped; + * one already started still settles, and service disposal waits for it. * @param callback - invoked after each commit with the next and previous values. * @returns the disposer removing this observer. */ watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section. + * @param patch - plain-object patch over the user section; JSON-shaped data + * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section. + * @param section - the complete next user section; JSON-shaped data only, + * as for {@link update}. */ replace(section: object): Promise } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c3a30a2ba7..9638ecfedf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:97`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:106`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e2e08d757f..811be436b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1345,7 +1345,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'settings/updated', mode: 'emit', signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void', - jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */', + jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */', summary: 'Committed change to one registered namespace\'s resolved value.', }, { diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 5d44f50f9d..9638a62f96 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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 packages/settings/settings-local/README.md -README.md: af8df7c030757b330e034a1c46507fbe75c9bab8 -README.zh.md: fc8943263b339baad1a92a1d0b0977b926e40f6e +README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257 +README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index af8df7c030..2c0817afd2 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded. +File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet. ## Config @@ -18,9 +18,13 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension ## Behavior - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. -- **Write-back is atomic, owner-only, and symlink-proof.** `persist` exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes. -- **Cross-namespace writes serialize on one document.** Every namespace shares the file, so persists from different namespace queues chain internally; each render sees the text the previous write committed. -- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal. +- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit. +- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent. +- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. +- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. +- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. +- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. ## Model Experience @@ -33,6 +37,7 @@ No direct invalidation; the consuming plugin owns any request-prefix changes. ## Known Limitations and Deferred Work -- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up. -- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting. +- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check. +- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart. +- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described. - **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index fc8943263b..547abb0353 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。 +文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。 ## 配置 @@ -18,9 +18,13 @@ ## 行为 - **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 -- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。 -- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。 -- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。 +- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。 +- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 +- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。 +- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 +- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 +- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 ## Model Experience @@ -33,6 +37,7 @@ ## Known Limitations and Deferred Work -- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。 -- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。 +- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 +- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 +- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。 - **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index b9df71f9e1..04ba6808a3 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -1,7 +1,9 @@ /** * File-backed settings provider. One YAML or JSON document under the user's * harness home carries every namespace section; external edits hot-publish - * through the seam and `update()` writes back preserving the user's comments. + * through the seam, and every write re-reads the document under a + * cross-process writer lock before patching it as a comment-preserving + * leaf-level diff. * @module @deepseek-ai/dsh-settings-local */ diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 63a274dd4d..4f03498b06 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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 packages/settings/settings/README.md -README.md: ff6cdeb57a265dbaa9d5f50de1d558f1e3cb581f -README.zh.md: d820a5c1fa804455c439f1a155e7628f5118a49b +README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5 +README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index ff6cdeb57a..ec9f0e09c4 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -9,10 +9,10 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces. - `get(ns)` — resolved value, `undefined` while unregistered. -- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). -- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest. -- Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody. +- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners. +- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody. ## Provider contract @@ -33,5 +33,5 @@ No direct invalidation; a consumer that folds a settings value into the request ## Known Limitations and Deferred Work - **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. -- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins). +- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins). - **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index d820a5c1fa..6d0a760f9b 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -9,10 +9,10 @@ - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 -- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 - `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。 -- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener。 -- 服务卸载先拒绝新写入并排干全部排队写入后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 +- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。 +- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。 ## Provider 契约 @@ -33,5 +33,5 @@ ## Known Limitations and Deferred Work - **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。 -- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 为后写胜出)。 +- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。 - **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。 From 2b379799ba5e38b378e11c8ae2ad0c5b860a6969 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 14:13:15 +0800 Subject: [PATCH 30/97] test(settings): make third-review specs conform to strict optional and misused-promise contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the explicit-undefined base fixture exactOptionalPropertyTypes forbids (the repository trusts TypeScript at typed same-process seams — no test for an input the static interface excludes; coverage holds), and reshape the async-listener containment fixture as an unknown-returning function: the earlier inline cast was silently stripped by the staged oxlint fixer, leaving a shape the next lint pass rejects. --- .../settings/settings/tests/settings.spec.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index cfd88b166f..379ce02d08 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -228,14 +228,6 @@ describe('update', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 }) }) - it('ignores an explicit undefined entry in the composition base layer', async () => { - const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } }) - const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { - base: { theme: undefined, fontSize: 16 }, - }) - expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) - }) - it('rejects a non-object patch', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) @@ -616,12 +608,13 @@ describe('third review regressions', () => { it('contains an async settings/updated listener rejection and keeps other listeners running', async () => { const { ctx, provider } = await boot() - // An async listener violates the event's synchronous signature (typed - // consumers get a lint error for it), but an unlinted JS plugin can still - // register one; the cast simulates exactly that caller. - ctx.on('settings/updated', async () => { - throw new Error('async listener boom') - }) + // An async listener violates the event's synchronous signature, but an + // unlinted JS plugin can still register one. Declaring the return as + // unknown keeps this file's typed surface legal (unknown-returning + // functions are assignable to void positions) while the runtime value is + // still the rejected promise the containment guard must handle. + const boom = (): unknown => Promise.reject(new Error('async listener boom')) + ctx.on('settings/updated', boom) const second = vi.fn() ctx.on('settings/updated', second) ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) From 90c3118302fdf717a237e9f6de3b1443325ecaf7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:40:09 +0800 Subject: [PATCH 31/97] fix(credentials-local): one operation chain, read-modify-write under the shared writer lock, and a quote-aware line editor Review round three, credentials half. dsh-atomic-write grows the cross-process writer-lock primitive (withFileLock: wx sentinel, bounded backoff, stale takeover via onStaleBreak, deadline failure) plus a dirMode option, and settings-local migrates its private copy to it; both providers now create harness-home directories 0700. credentials-local reuses the reviewed settings-local shape: watcher reloads and line edits share one settled operation chain; every write re-reads the document under the lock and publishes unobserved external entries before editing, so an edit inside the debounce window (or another process's write) can never be overwritten; the watcher's ready signal queues one reconcile closing the startup gap. The line editor is now physical-line aware: continuation lines of a quoted multi-line value are never mistaken for assignments, untouched lines keep their exact bytes (CRLF included), an edited line keeps its own terminator, and appends use the document's dominant ending. A multi-line entry reports writable: false, matching what set() would do. The Credentials base class owns a contained notifyUpdated fan-out: providers publish only after the commit, every listener runs, sync throws and async rejections are logged without failing the committed write, and INVARIANT-coded failures rethrow after the fan-out. --- .../credentials-local/src/index.ts | 257 +++++++++++++----- .../credentials-local/tests/drain.spec.ts | 10 +- .../tests/review-fixes.spec.ts | 202 ++++++++++++++ .../credentials-local/tests/watcher.spec.ts | 16 ++ packages/credentials/credentials/src/index.ts | 50 +++- packages/settings/settings-local/src/index.ts | 82 +----- packages/util/atomic-write/src/index.ts | 117 +++++++- 7 files changed, 579 insertions(+), 155 deletions(-) create mode 100644 packages/credentials/credentials-local/tests/review-fixes.spec.ts diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c1fcd37f16..576b8241f0 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -3,19 +3,21 @@ * a `$DSH_HOME/.env` document. The environment is authoritative and read-only * (a launch-time override must win, and must be visibly read-only rather than * silently shadow writes); the file is the provider-managed writable source: - * `set`/`unset` rewrite only their own line and preserve every other byte, - * external edits hot-publish through the seam, and each reload replaces the - * snapshot wholesale so a deleted entry never lingers in memory. + * every write re-reads the document under a cross-process writer lock before + * rewriting only its own line — preserving every other byte, physical line + * endings and quoted multi-line values included — external edits hot-publish + * through the seam, and each reload replaces the snapshot wholesale so a + * deleted entry never lingers in memory. * @module @deepseek-ai/dsh-credentials-local */ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { parse } from 'dotenv' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -58,11 +60,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Match the physical line(s) assigning one reference (ref chars need no escaping). */ -function refLinePattern(ref: CredentialRef): RegExp { - return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`) -} - /** Values that survive a dotenv round-trip without quoting. */ const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ @@ -90,30 +87,98 @@ function renderLine(ref: CredentialRef, value: string): string { throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) } +/** Split text into physical lines with their terminators attached. */ +function physicalLines(text: string): string[] { + return text.length === 0 ? [] : text.split(/(?<=\n)/) +} + +/** One physical line's content without its terminator. */ +function lineContent(line: string): string { + if (line.endsWith('\r\n')) return line.slice(0, -2) + if (line.endsWith('\n')) return line.slice(0, -1) + return line +} + +/** One physical line's terminator (empty on a final unterminated line). */ +function lineTerminator(line: string): string { + return line.slice(lineContent(line).length) +} + +/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ +const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ + +/** Quote characters dotenv reads across physical lines. */ +const MULTILINE_QUOTES = ['\'', '"', '`'] + /** - * Replace, insert, or delete one reference's assignment while preserving every - * other byte. The first matching line is rewritten in place; further matches - * are dropped (dotenv reads the last one, so duplicates are dead weight that - * would otherwise override the edit). + * The quote character an assignment's value part opens without closing on its + * own line — the following physical lines are that value's continuation, not + * assignments — or `undefined` for a single-line value. */ -function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string { - const lines = text === undefined || text.length === 0 ? [] : text.split('\n') - if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() - const matcher = refLinePattern(ref) +function opensMultiline(valuePart: string): string | undefined { + const trimmed = valuePart.trimStart() + const quote = trimmed[0] + if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined + const rest = trimmed.slice(1) + const body = quote === '"' ? rest.replaceAll('\\"', '') : rest + return body.includes(quote) ? undefined : quote +} + +/** Whether a continuation line closes the given quote. */ +function closesQuote(content: string, quote: string): boolean { + const body = quote === '"' ? content.replaceAll('\\"', '') : content + return body.includes(quote) +} + +/** + * Replace, insert, or delete one reference's assignment while preserving + * every other byte: untouched lines keep their exact content and terminators + * (CRLF included), and the physical lines inside another key's quoted + * multi-line value are never mistaken for assignments. The first matching + * assignment is rewritten in place with its own line ending; later duplicates + * drop (dotenv reads the last one, so a surviving duplicate would override + * the edit); an insert appends in the document's dominant ending style. + */ +function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { + const lines = physicalLines(text ?? '') + const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' const out: string[] = [] let placed = false - for (const current of lines) { - if (matcher.test(current)) { - if (line !== undefined && !placed) { - out.push(line) - placed = true - } + let pendingQuote: string | undefined + for (const line of lines) { + const content = lineContent(line) + if (pendingQuote !== undefined) { + // Inside a quoted multi-line value: never an assignment, always kept. + if (closesQuote(content, pendingQuote)) pendingQuote = undefined + out.push(line) continue } - out.push(current) + const match = ASSIGNMENT.exec(content) + if (match === null) { + out.push(line) + continue + } + const [, key, valuePart] = match + if (key !== ref) { + pendingQuote = opensMultiline(valuePart ?? '') + out.push(line) + continue + } + // The write path refuses multi-line targets before rendering, so the + // matched assignment is single-line and drops or rewrites wholesale. + if (rendered !== undefined && !placed) { + out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) + placed = true + } } - if (line !== undefined && !placed) out.push(line) - return out.length === 0 ? '' : `${out.join('\n')}\n` + if (rendered !== undefined && !placed) { + const last = out[out.length - 1] + if (last !== undefined && lineTerminator(last) === '') { + out[out.length - 1] = `${last}${dominant}` + } + out.push(`${rendered}${dominant}`) + } + return out.join('') } /** File-backed credentials provider (`$DSH_HOME/.env`). */ @@ -137,10 +202,12 @@ export class CredentialsLocal extends Credentials { private text: string | undefined /** Parsed document snapshot; replaced wholesale on every reload. */ private values = new Map() - /** Serializes watcher-triggered reloads so reads never interleave. */ - private refreshTask: Promise = Promise.resolve() - /** Serializes writes to the one document; settled tail. */ - private writeChain: Promise = Promise.resolve() + /** + * Single exclusive operation chain: watcher reloads and line edits run one + * at a time in queue order (settled tail), so an edit can never render from + * text a concurrent reload is busy replacing. + */ + private operations: Promise = Promise.resolve() /** Set at dispose: refuse new writes and let in-flight work no-op. */ private closed = false @@ -159,10 +226,10 @@ export class CredentialsLocal extends Credentials { async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { - // Drain: refuse new writes, then settle the queued ones so disposal + // Drain: refuse new operations, then settle the queued ones so disposal // completes only once storage is quiescent. this.closed = true - await this.writeChain + await this.operations } await this.loadInitial() if (!this.spec.watch) return @@ -178,26 +245,27 @@ export class CredentialsLocal extends Credentials { }) watcher.on('all', () => { if (this.closed) return - this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the update fan-out can reject a - // refresh; keep the reload queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) + this.queueRefresh() + }) + watcher.on('ready', () => { + // The initial load raced the watcher's own setup: a change written + // between that read and the watcher becoming active never fires an + // event. One reconcile at ready closes the gap. + if (this.closed) return + this.queueRefresh() }) watcher.on('error', (error) => { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) - /* jscpd:ignore-end */ yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight refresh so nothing publishes after disposal. + // queued or in-flight operation so nothing publishes after disposal. this.closed = true await watcher.close() - await this.refreshTask + await this.operations } + /* jscpd:ignore-end */ } override resolve(ref: CredentialRef): Promise { @@ -215,7 +283,9 @@ export class CredentialsLocal extends Credentials { } const stored = this.values.get(ref) if (stored !== undefined && stored.length > 0) { - return Promise.resolve({ configured: true, source: 'file', writable: true }) + // A quoted multi-line value resolves fine but the line editor refuses to + // rewrite it, so writability must say what set() would actually do. + return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) } return Promise.resolve({ configured: false, writable: true }) } @@ -231,6 +301,24 @@ export class CredentialsLocal extends Credentials { await this.write(ref, undefined) } + /** Queue one exclusive document operation behind every earlier one. */ + private enqueue(operation: () => Promise): Promise { + const task = this.operations.then(operation) + this.operations = task.then(() => undefined, () => undefined) + return task + } + + /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */ + private queueRefresh(): void { + void this.enqueue(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the update fan-out can reject a + // refresh; keep the operation queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + } + /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ private async write(ref: CredentialRef, value: string | undefined): Promise { const verb = value === undefined ? 'unset' : 'set' @@ -238,32 +326,43 @@ export class CredentialsLocal extends Credentials { throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) } this.assertUnshadowed(ref, verb) - // The stored tail is settled on both outcomes, so chaining needs no catch - // and one rejected write can never poison the queue for later callers. - const previous = this.writeChain - const run = previous.then(async () => { + return this.enqueue(async () => { if (this.isClosed()) { throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) } // Re-judged at run time: the environment may have changed while queued. this.assertUnshadowed(ref, verb) - const existing = this.values.get(ref) - if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) - // 0600: a document holding secrets is never world-readable. - await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 }) - this.text = nextText - if (value === undefined) this.values.delete(ref) - else this.values.set(ref, value) - this.ctx.emit('credentials/updated', ref) + // The writer lock's exclusive create needs the parent to exist; 0700 + // because the harness home holds user-private data. + await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) + await withFileLock(this.spec.filename, async () => { + // Read-modify-write: fold in any on-disk state this process has not + // observed yet — an external edit still inside the watcher debounce + // window, a change the watcher missed, or another process's write — + // so the line edit below can never resurrect a stale document. + await this.reconcileFromDisk() + const existing = this.values.get(ref) + if (value === undefined && existing === undefined) return + if (existing !== undefined && existing.includes('\n')) { + throw new Error( + `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, + ) + } + const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + // 0600: a document holding secrets is never world-readable. + await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) + this.text = nextText + if (value === undefined) this.values.delete(ref) + else this.values.set(ref, value) + // After the commit: a broken observer must never make the durable + // write look failed (an INVARIANT failure still rethrows). + this.notifyUpdated(ref) + }, { + onStaleBreak: (lockPath) => { + this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath) + }, + }) }) - this.writeChain = run.then(() => undefined, () => undefined) - return run } /** Reject a write the live environment would shadow into apparent no-effect. */ @@ -294,19 +393,33 @@ export class CredentialsLocal extends Credentials { * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable document keeps the * last good snapshot and warns — a live hot-reload must never take the - * process down. dotenv parsing is lenient by design and cannot fail. + * process down. An invariant violation escaping the fan-out is not a reload + * failure and propagates to the queue's error surface. */ private async refresh(): Promise { if (this.closed) return + try { + await this.reconcileFromDisk() + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error + this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + } + } + + /** + * Compare the on-disk text against the cache and publish any difference + * into the seam. Absence publishes the empty store; an unreadable file + * throws, so each caller picks its policy — a reload warns and keeps the + * last good snapshot, a write fails loud. dotenv parsing is lenient by + * design and cannot fail. + */ + private async reconcileFromDisk(): Promise { let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { - if (!isENOENT(error)) { - this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - return - } + if (!isENOENT(error)) throw error text = undefined } if (text === this.text || this.isClosed()) return @@ -314,7 +427,7 @@ export class CredentialsLocal extends Credentials { const changed = this.changedRefs(this.values, next) this.text = text this.values = next - for (const ref of changed) this.ctx.emit('credentials/updated', ref) + for (const ref of changed) this.notifyUpdated(ref) } /** Seam-addressable entries whose effective (non-empty) value changed. */ diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index 6c05759b54..baefbd52c5 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -6,11 +6,15 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' -// The atomic write is the only asynchronous hold point inside a queued write; -// gating it makes the dispose-versus-queued-write race fully deterministic. -vi.mock('@deepseek-ai/dsh-atomic-write', () => { +// The atomic write is the gated asynchronous hold point inside a queued +// write; gating it makes the dispose-versus-queued-write race fully +// deterministic. The lock helper passes through so the gated operation still +// runs inside its real acquire/release cycle. +vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => { + const actual = await importOriginal() let gate: Promise = Promise.resolve() return { + ...actual, writeFileAtomic: vi.fn(() => gate), __setGate: (next: Promise) => { gate = next diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts new file mode 100644 index 0000000000..7583cf0813 --- /dev/null +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -0,0 +1,202 @@ +// Third-review behaviors: read-modify-write under the writer lock (external +// edits survive an API write), the contained credentials/updated fan-out (a +// broken observer never fails a committed write), and the physical-line +// editor's multi-line and CRLF discipline. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +const ALPHA = credentialRef('DSH_REVIEW_ALPHA') +const BETA = credentialRef('DSH_REVIEW_BETA') +const INNER = credentialRef('DSH_REVIEW_INNER') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('read-modify-write', () => { + it('folds an unobserved external edit into a write instead of overwriting it', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { seen.push(ref) }) + await ctx.credentials.set(ALPHA, 'one') + // The external edit has landed on disk but no watcher reported it (watch + // is off — the same blind spot as a debounce window or a missed event). + await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) + await ctx.credentials.set(ALPHA, 'two') + const text = await readFile(path, 'utf8') + expect(text).toContain(`${BETA}=external`) + expect(text).toContain(`${ALPHA}=two`) + // The fold published the unobserved entry before the write's own commit. + expect(seen).toEqual([ALPHA, BETA, ALPHA]) + expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) + }) + + it('keeps both refs when two providers write the same document concurrently', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const first = await boot({ path, watch: false }) + const second = await boot({ path, watch: false }) + await Promise.all([ + (async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(), + (async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(), + ]) + const third = await boot({ path, watch: false }) + expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' }) + expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' }) + }) + + it('breaks a stale writer lock with a warning and writes through', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + await writeFile(`${path}.lock`, 'crashed-holder\n') + const past = (Date.now() - 60_000) / 1000 + await utimes(`${path}.lock`, past, past) + await ctx.credentials.set(ALPHA, 'nine') + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`) + }) + + it('creates the credentials directory owner-only', async () => { + const dir = await tempDir() + const home = join(dir, 'home') + const ctx = await boot({ path: join(home, '.env'), watch: false }) + await ctx.credentials.set(ALPHA, 'one') + expect((await stat(home)).mode & 0o777).toBe(0o700) + }) +}) + +describe('contained update fan-out', () => { + it('does not fail a committed set when a listener throws, and later listeners still run', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + ctx.on('credentials/updated', () => { + throw new Error('observer boom') + }) + const second = vi.fn() + ctx.on('credentials/updated', second) + await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() + expect(second).toHaveBeenCalledWith(ALPHA) + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) + }) + + it('contains an async listener rejection', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + // An unknown-returning function keeps the typed surface legal while the + // runtime value is still the rejected promise the containment must handle. + const boom = (): unknown => Promise.reject(new Error('async observer boom')) + ctx.on('credentials/updated', boom) + await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + ctx.on('credentials/updated', () => { + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const second = vi.fn() + ctx.on('credentials/updated', second) + await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) + // Harness-fatal by design — but the write itself committed first. + expect(second).toHaveBeenCalledWith(ALPHA) + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) + }) +}) + +describe('physical-line editor', () => { + it('never mistakes a quoted multi-line continuation for an assignment', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` + await writeFile(path, wrapped) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + // The wrapped value survives byte-for-byte; only ALPHA's line changed. + const afterAlpha = await readFile(path, 'utf8') + expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) + // Setting the inner-looking ref appends a real assignment; the + // continuation line inside the quoted value stays untouched. + await ctx.credentials.set(INNER, 'real') + const afterInner = await readFile(path, 'utf8') + expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) + expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) + }) + + it('preserves CRLF line endings on untouched and edited lines', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) + await ctx.credentials.set(INNER, 'new') + expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) + }) + + it('terminates a final unterminated line before appending', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}=a`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(BETA, 'b') + expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) + }) + + it('rewrites a final unterminated assignment in the dominant ending style', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}=a`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) + }) + + it('tracks a single-quoted multi-line value through its continuation', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'x') + expect(await readFile(path, 'utf8')) + .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) + }) + + it('reports a multi-line entry as unwritable and refuses to edit it', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}="line1\nline2"\n`) + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) + await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) + await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) + // Resolution still serves the multi-line value. + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 798cdc8a88..6ff53252cf 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -151,6 +151,7 @@ describe('watcher pipeline', () => { await fiber.dispose() disposed = true instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('ready') await new Promise(resolve => setTimeout(resolve, 100)) expect(postDisposeCommits).toBe(0) }) @@ -204,4 +205,19 @@ describe('watcher pipeline', () => { await new Promise(resolve => setTimeout(resolve, 50)) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() }) + + it('reconciles at watcher ready so a change during setup is not missed', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${KEY}=a\n`) + const ctx = await boot({ path, debounceMs: 5 }) + // Written after the initial load but before the watcher became active: + // no 'all' event will ever fire for it. + await writeFile(path, `${KEY}=written-before-ready\n`) + const [instance] = await fakeInstances() + instance!.watcher.emit('ready') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' }) + }) + }) }) diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 2df89132ac..b640b42881 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -55,7 +55,12 @@ declare module 'cordis' { /** * Committed change to a provider-managed credential source: a `set`, an * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. + * process-environment changes are not observable and never emit. Listener + * failures are contained and logged — a sync throw and an async rejection + * alike — without changing the committed operation's outcome, except + * `INVARIANT`-coded failures, which rethrow after every listener ran; + * that rethrow reaches the emitter only from synchronous listeners, so + * invariant checks on this event must not be async functions. * @param ref - the reference whose stored value changed. * @mode emit */ @@ -109,6 +114,49 @@ export abstract class Credentials extends Service { * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise + + /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit + fan-out: the contained-dispatch shape is the reviewed listener-lifecycle + contract, and extracting it would couple the two seams' event semantics. */ + /** + * Fan `credentials/updated` out with contained listener failures: every + * listener runs, and a sync throw or async rejection is logged without + * changing the committed operation's outcome — except `INVARIANT`-coded + * failures, which rethrow after every listener ran (the rethrow reaches the + * caller only from synchronous listeners, so invariant checks on this event + * must not be async functions). Providers call this only after the write or + * reload actually committed, so a broken observer can never make a durable + * change look failed. + * @param ref - the reference whose stored value changed. + */ + protected notifyUpdated(ref: CredentialRef): void { + let invariantFailure: unknown + const args = ['credentials/updated', ref] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(ref) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnListenerFailure(ref, error) + }) + } + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { + invariantFailure ??= error + continue + } + this.warnListenerFailure(ref, error) + } + } + if (invariantFailure !== undefined) throw invariantFailure as Error + } + /* jscpd:ignore-end */ + + /** Contained-listener diagnostic shared by the sync and async failure paths. */ + private warnListenerFailure(ref: CredentialRef, error: unknown): void { + this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) + this.ctx.logger.warn(error) + } } export default Credentials diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index c129285ea6..8043e6db45 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,10 +10,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -96,23 +96,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - -/** - * Writer-lock protocol constants. These are robustness invariants of the - * cross-process write protocol, not deployment tunables: a holder rewrites one - * small document in milliseconds, so contention resolves well inside the - * retry deadline, and a lock older than the stale age can only belong to a - * crashed holder. - */ -const LOCK_RETRY_INITIAL_MS = 20 -const LOCK_RETRY_MAX_MS = 200 -const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 - /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z = z.object({ @@ -199,8 +182,9 @@ export class SettingsLocal extends Settings { private async persistSection(ns: SettingsNamespace, section: Record): Promise { // The writer lock's exclusive create needs the parent to exist before // writeFileAtomic gets its own chance to create it. - await mkdir(dirname(this.spec.filename), { recursive: true }) - await this.withWriterLock(async () => { + // 0700: the harness home holds user-private documents. + await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) + await withFileLock(this.spec.filename, async () => { // Read-modify-write: fold in any on-disk state this process has not // observed yet — an external edit still inside the watcher debounce // window, a change the watcher missed, or another process's write — so @@ -212,59 +196,13 @@ export class SettingsLocal extends Settings { ? this.renderYaml(ns, section) : this.renderJson(ns, section) // 0600: a document that may hold personal values is never world-readable. - await writeFileAtomic(this.spec.filename, output, { mode: 0o600 }) + await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) this.text = output - }) - } - - /** - * Hold the cross-process writer lock around one read-render-rename cycle. - * The lock is a `wx`-created sibling (`.lock`); the rename-based - * commit keeps readers lock-free, so only writers contend. A lock older - * than {@link LOCK_STALE_MS} is a crashed holder and is broken with a - * warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write. - */ - private async withWriterLock(operation: () => Promise): Promise { - const lockPath = `${this.spec.filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS - let delay = LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error) { - if (!isEEXIST(error)) throw error - } - const ageMs = await this.lockAgeMs(lockPath) - // The holder released between the failed create and the stat: the lock - // is free right now, so retry without burning backoff or deadline. - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { + }, { + onStaleBreak: (lockPath) => { this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - await rm(lockPath, { force: true }) - continue - } - if (Date.now() >= deadline) { - throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } - } - - /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ - private async lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } + }, + }) } override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index f4a20c10bc..e7148f41ad 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -1,14 +1,17 @@ /** - * Zero-dependency atomic file replacement. `writeFileAtomic` writes a - * random-suffix sibling with exclusive create and the caller's permission - * bits, then renames it over the target, so readers observe either the old or - * the new complete content and a replaced file ends up with exactly the - * stated mode. + * Zero-dependency atomic file replacement and writer coordination. + * `writeFileAtomic` writes a random-suffix sibling with exclusive create and + * the caller's permission bits, then renames it over the target, so readers + * observe either the old or the new complete content and a replaced file ends + * up with exactly the stated mode. `withFileLock` serializes cross-process + * writers of one file through a `wx`-created `.lock` sibling, so a + * read-modify-write cycle can never resurrect a state another writer just + * replaced; readers stay lock-free because the rename commit is atomic. * @module @deepseek-ai/dsh-atomic-write */ import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' /** @@ -21,6 +24,12 @@ export interface WriteFileAtomicOptions { * rename (subject to the process umask, like every fresh inode). */ mode: number + /** + * Permission bits for parent directories this call creates (subject to the + * umask; existing directories keep their mode). Omission uses the mkdir + * default — pass `0o700` when the tree holds user-private data. + */ + dirMode?: number } /** @@ -38,7 +47,10 @@ export interface WriteFileAtomicOptions { * @param options - permission bits for the replacement inode. */ export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { - await mkdir(dirname(filename), { recursive: true }) + await mkdir(dirname(filename), { + recursive: true, + ...options.dirMode === undefined ? {} : { mode: options.dirMode }, + }) const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` try { await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) @@ -48,3 +60,94 @@ export async function writeFileAtomic(filename: string, content: string, options throw error } } + +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** Whether a filesystem error means absence. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** + * Writer-lock protocol constants. These are robustness invariants of the + * cross-process write protocol, not deployment tunables: a holder rewrites one + * small file in milliseconds, so contention resolves well inside the retry + * deadline, and a lock older than the stale age can only belong to a crashed + * holder. + */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + +/** Options for {@link withFileLock}. */ +export interface WithFileLockOptions { + /** + * Called once each time a stale (crashed-holder) lock is broken, so the + * caller can log the takeover in its own voice. + */ + onStaleBreak?: (lockPath: string) => void +} + +/** Age of the lock file, or `undefined` when it vanished after a failed create. */ +async function lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined + } +} + +/** + * Hold the cross-process writer lock for `filename` around one operation. The + * lock is a `wx`-created sibling (`.lock`); paired with the + * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and + * only writers contend. Contention backs off exponentially; a lock older than + * the stale age is a crashed holder and is broken (see + * {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline + * fails the operation with a timed-out error. The parent directory must exist. + * @param filename - the file whose writers this lock serializes. + * @param operation - the read-render-commit cycle to run while holding the lock. + * @param options - stale-takeover notification hook. + * @returns the operation's result; the lock releases on both outcomes. + */ +export async function withFileLock( + filename: string, + operation: () => Promise, + options?: WithFileLockOptions, +): Promise { + const lockPath = `${filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await lockAgeMs(lockPath) + // The holder released between the failed create and the stat: the lock is + // free right now, so retry without burning backoff or deadline. + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + options?.onStaleBreak?.(lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } +} From 8f045bfdbd9c0cf48dde296b705fe18adfb437c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:44:32 +0800 Subject: [PATCH 32/97] fix(cli)!: stop hoisting $DSH_HOME/.env into process.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped surfaces loaded the harness home's .env into the process environment before cordis booted. credentials-local then saw every stored key as an ambient launch override: describe reported source 'env' with writable false, and set/unset rejected as shadowed — so a key the web page or TUI stored was unrotatable and undeletable from the next run onward, and the adapter kept using the value captured at launch. The home's .env is now the credential provider's own store, read by that provider alone and hot-reloaded by it. The genuine launch environment and the invoking directory's .env (loaded by the bin) remain the read-only ambient layer, so a plain composition without the provider still resolves keys exactly as before. Proven by a real restart in the loader composition: store a key through the seam, dispose the tree, re-boot over the same harness home, and the entry is still file-sourced and writable — rotating it lands on the very next request. --- apps/cli/README.md | 2 +- apps/cli/src/app-cli-entry.ts | 17 +++------ apps/cli/src/tui.ts | 17 +++++---- .../tests/loader-composition.spec.ts | 38 +++++++++++++++++-- packages/ui/app-boot/README.md | 4 +- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..1decf018f5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,7 +12,7 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..aa488ab045 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,10 +1,11 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). - * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped cordis.yml (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud triple after the - * tree settles. + * Everything here is what must exist before the Loader runs: the patch + * composition over the shipped cordis.yml (profile json + CLI flags + the + * resolved frontend dist) and the fail-loud triple after the tree settles. + * The environment is what the bin already loaded (ambient plus the invoking + * directory's `.env`); `$DSH_HOME/.env` belongs to the credential provider. */ import { readFileSync } from 'node:fs' @@ -17,7 +18,7 @@ import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, installFailLoud } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -147,7 +148,6 @@ export class AppCLIEntry { * @returns the settled root context and the listening port. */ async run(): Promise<{ ctx: Context; port: number }> { - this.loadEnvLayers() this.composePatches() await this.bootTree() this.assertBoot() @@ -157,11 +157,6 @@ export class AppCLIEntry { return { ctx: this.ctx, port } } - /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ - private loadEnvLayers(): void { - loadEnv('dsh', resolveDshHome()) - } - /** * Compose the patch set from the non-yml config sources: computed * engineering defaults (the global session root), profile json (user diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..ffb241631b 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,9 +1,10 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped * tui-agent config (or the `--config` override) with the personal overlay - * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: - * ambient environment, then the invoking directory's `.env`, then the personal one) - * and its `config.yaml` patches the booted tree. The workspace is the invoking + * from the Harness home (`~/.dsh`): its `config.yaml` patches the booted tree. + * The environment layers are the ambient one and the invoking directory's + * `.env`; `$DSH_HOME/.env` stays the credential provider's own store and is + * never hoisted into `process.env`. The workspace is the invoking * directory: sessions, relative paths, and workspace instructions resolve from * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the * agent's system prompt is told the path to this harness checkout so it can find @@ -17,12 +18,10 @@ import { addHarnessSourceSection, boot, installFailLoud, - loadEnv, loadPersonalPatches, RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Context } from 'cordis' import { TUI_GOODBYE_MESSAGE_KEY, @@ -63,9 +62,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string process.exit(1) } installFailLoud(NAME) - // The bin already loaded the invoking directory's .env; the personal .env - // only fills what is still unset (process.loadEnvFile never overrides). - loadEnv(NAME, resolveDshHome()) + // The bin already loaded the invoking directory's .env as the ambient + // layer. `$DSH_HOME/.env` is deliberately NOT loaded here: it is the + // credential provider's own writable store, and hoisting it into + // process.env would make every stored key look like a read-only launch + // override on the next run, blocking rotation from the TUI and the web page. process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` // flag, so the resumed process rehydrates through this same intake. The host diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 9ca8c87367..402f94441d 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -41,12 +41,15 @@ afterEach(async () => { }) async function loadComposition( - options: { withDynamic: boolean; baseURL: string }, + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, ): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { - root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) + // A reused root is the restart case: the same harness home, its documents + // exactly as the previous process left them. + const fresh = options.reuseRoot === undefined + root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) const settingsPath = join(root, 'settings.yaml') const envPath = join(root, '.env') - if (options.withDynamic) { + if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') } @@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => { expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') }) + it('keeps a stored key writable and rotatable across a real restart', async () => { + // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist + // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const first = await mockServer([{ kind: 'sse', events: textEvents }]) + const second = await mockServer([{ kind: 'sse', events: textEvents }]) + const boot = await loadComposition({ withDynamic: true, baseURL: first.url }) + const home = root! + await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui') + expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) + .toEqual({ configured: true, source: 'file', writable: true }) + await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + await boot.ctx.fiber.dispose() + context = undefined + + // Restart over the same harness home. + const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home }) + const credentials = restarted.ctx.get('credentials')! + // The stored key is still the provider's own writable file entry — not a + // read-only launch override, which is what hoisting it would have made it. + expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' }) + expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true }) + // Rotation still works after the restart, and the next request uses it. + await credentials.set(KEY_REF, 'rotated-after-restart') + await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') + }) + it('boots the same adapter without settings or credentials entries on entry config alone', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const server = await mockServer([{ kind: 'sse', events: textEvents }]) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..47c5de35e6 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -26,8 +26,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`. -- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. From 54f95d7669af550c7c8f986bca223b908a7f1ef6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:51:35 +0800 Subject: [PATCH 33/97] fix(llm): atomic route replacement, whole-snapshot requests, and loud credential misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings across the seam and both adapters. registerAdapter now returns a handle carrying replace(providers): the candidate route set is validated in full before anything moves, so a route another adapter owns leaves the previous registration intact, and the swap itself is one synchronous section with no observable gap. pi-ai uses it instead of dispose-then-register — the old shape dropped every route when the new set conflicted, and its facts cache could then equal the registry's, so reverting to a working configuration never re-applied. Its registration facts are also sorted by provider, so a settings document that merely reorders keys no longer triggers a swap. DeepSeek's per-request snapshot now carries the credential facts, and resolveApiKey receives it instead of re-reading the raw config: a settings generation the resolver rejects can no longer contribute its literal key to a request the previous generation's endpoint serves. pi-ai only defers to the SDK's provider-native discovery when a profile names no credential at all; a configured apiKeyEnv that misses now fails with MISSING_CREDENTIAL naming the route and the reference, instead of handing pi-ai undefined and letting it authenticate with an unrelated ambient key. The eager boot-time credential probe is gone: it could run before the credentials service mounted and reported every failure as a missing key. The route stays registered and browsable; the first request gives the accurate error, whose guidance now leads with the credential store and mentions a literal apiKey last. --- packages/llm/llm-deepseek/src/adapter.ts | 22 +++- packages/llm/llm-deepseek/src/index.ts | 41 ++++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 4 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 23 ++++ packages/llm/llm-pi-ai/src/adapter.ts | 8 +- packages/llm/llm-pi-ai/src/index.ts | 64 ++++++++--- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 14 ++- .../llm-pi-ai/tests/dynamic-config.spec.ts | 51 ++++++++- packages/llm/llm/src/index.ts | 101 +++++++++++++----- 9 files changed, 252 insertions(+), 76 deletions(-) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 0163dd3cab..a4b02a3e39 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -17,6 +17,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -45,6 +46,14 @@ export interface DeepSeekCatalogModel { export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string + /** + * Literal API key of this same resolution, when the configuration carried + * one. Travelling with the endpoint is the point: a request can never pair + * one generation's URL with another generation's secret. + */ + apiKey?: string + /** Credential reference of this same resolution, resolved per request when no literal key exists. */ + apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults /** Positive context capacity used when the selected model has no exact value. */ @@ -62,11 +71,12 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for one request; called once per stream call and - * frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key - * is available anywhere. + * Resolve the bearer token for the connection facts of one request. The + * snapshot is passed in — never re-read — so the key can only ever come + * from the same resolution as the endpoint it is sent to. Throws `LlmError` + * `MISSING_CREDENTIAL` when no key is available anywhere. */ - resolveApiKey: () => Promise + resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -189,8 +199,10 @@ export class DeepSeekAdapter extends LlmAdapter { // One resolution per stream call: connection facts and the credential // freeze here and hold for this whole request, so an in-flight stream // never observes a configuration change and the next call re-resolves. + // The key resolves *from this snapshot*, so an endpoint and the secret + // sent to it can never come from different configuration generations. const connection = this.config.options() - const apiKey = await this.config.resolveApiKey() + const apiKey = await this.config.resolveApiKey(connection) const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index bb2ccdaa11..6623351774 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -16,7 +16,6 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' @@ -32,6 +31,8 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-deepseek') const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' +/** The single provider route this plugin owns. */ +const PROVIDER = 'deepseek' const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, @@ -89,11 +90,13 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' -/** Connection facts plus the plugin-consumed credential reference. */ -export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions { - /** Reference resolved per request when no literal key is configured. */ - apiKeyEnv: CredentialRef -} +/** + * One resolution's complete request facts. Connection and credential facts + * are one value on purpose: a snapshot the resolver rejects keeps the whole + * previous generation, so a request can never pair a stale endpoint with a + * newer key. + */ +export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions /** Resolve, validate, and detach the advisory model catalog. */ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { @@ -147,6 +150,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { ) } return { + ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { @@ -187,10 +191,11 @@ export function apply(ctx: Context, config: Config): void { } options() - const resolveApiKey = async (): Promise => { - const raw = current() - if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey - const ref = options().apiKeyEnv + const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { + // Every credential fact comes from the caller's snapshot, so a rejected + // settings generation cannot leak its key onto the previous endpoint. + if (connection.apiKey !== undefined) return connection.apiKey + const ref = connection.apiKeyEnv const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) @@ -202,8 +207,9 @@ export function apply(ctx: Context, config: Config): void { if (ambient !== undefined && ambient.length > 0) return ambient } throw new LlmError( - 'llm-deepseek: no API key for provider route "deepseek"; set the llm-deepseek "apiKey" setting,' - + ` store ${ref} with the credentials service, or export ${ref}`, + `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` + + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', 'MISSING_CREDENTIAL', ) } @@ -211,7 +217,7 @@ export function apply(ctx: Context, config: Config): void { const adapter = new DeepSeekAdapter({ options, resolveApiKey }) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. - let disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy @@ -220,17 +226,10 @@ export function apply(ctx: Context, config: Config): void { // fact per-request resolution cannot refresh: swap the registration in one // synchronous section (same adapter instance, no NO_ADAPTER window). disposeRoute() - disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) registeredPolicy = policy } - void resolveApiKey().then(() => undefined, () => { - // Expected on a first boot with dynamic sources: the route stays - // registered (the catalog is browsable) and each request fails with the - // actionable MISSING_CREDENTIAL message until a key arrives. - ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') - }) - installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 935235d825..b5a4bc9ff4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -817,8 +817,10 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + // The guidance leads with the credential store — the path that keeps the + // secret out of configuration files — and mentions a literal key last. await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/) + .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) it('prefers explicit config over env for key and base URL', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 79a8afb671..3cd430ec14 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -143,6 +143,29 @@ describe('request-level dynamic configuration', () => { ]) }) + it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const good = await mockServer([{ kind: 'sse', events: textEvents }]) + const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url }) + + // One snapshot moves the endpoint AND the literal key, and fails the + // resolve step beyond the schema (duplicate catalog ids). + await ctx.settings.update(NS, { + apiKey: 'rejected-key', + baseURL: rejected.url, + models: [{ id: 'dup' }, { id: 'dup' }], + }) + + await prompt(ctx) + // The rejected generation contributes nothing: not its endpoint, and — the + // regression this pins — not its key either. + expect(rejected.requests).toHaveLength(0) + expect(good.requests).toHaveLength(1) + expect(good.headers[0]?.authorization).toBe('Bearer good-key') + }) + it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index fd40c79c73..030592f74c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -41,9 +41,11 @@ export interface PiAiAdapterOptions { /** * Resolve the credential for one already-resolved profile; called once per * stream call and frozen for that call. `undefined` defers to pi-ai's - * provider-native ambient discovery. + * provider-native ambient discovery, which the plugin allows only for a + * profile naming no credential at all; a named reference that misses throws + * `LlmError` `MISSING_CREDENTIAL` rather than falling back. */ - resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise + resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -180,7 +182,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) - const apiKey = await this.config.resolveApiKey(profile) + const apiKey = await this.config.resolveApiKey(options.provider, profile) const consumer = new AbortController() const upstream = options.signal === undefined diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 6140b2456d..7ff3825bf2 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -29,7 +29,8 @@ */ import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-llm' +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' @@ -45,9 +46,15 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-pi-ai') -/** The registry captures these per route; a change here must re-register. */ +/** + * The registry captures these per route; a change here must re-register. + * Sorted by provider so a settings document that merely reorders its keys is + * not mistaken for a route change. + */ function registrationFacts(profiles: ReadonlyMap): unknown { - return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + return [...profiles.entries()] + .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + .sort((left, right) => left.provider < right.provider ? -1 : left.provider > right.provider ? 1 : 0) } /** Register one generic pi-ai adapter for all configured provider routes. */ @@ -76,17 +83,31 @@ export function apply(ctx: Context, config: Config): void { } profiles() - const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise => { + const resolveApiKey = async ( + provider: string, + profile: ResolvedPiAiProviderProfile, + ): Promise => { if (profile.apiKey !== undefined) return profile.apiKey const ref = profile.apiKeyEnv + // Only a profile that names no credential at all defers to pi-ai's + // provider-native discovery. Once one is named, a miss must fail loud: + // handing pi-ai `undefined` would let it pick up an unrelated ambient key + // (OPENAI_API_KEY and friends), billing another tenant for a request the + // deployment meant to authenticate differently. if (ref === undefined) return undefined const credentials = ctx.get('credentials') - if (credentials !== undefined) return (await credentials.resolve(ref))?.value - // Without the seam, keep an ambient fallback so a plain cordis.yml - // composition works from the environment alone; an empty variable defers - // to pi-ai's own provider-native discovery like an absent one. - const ambient = process.env[ref] - return ambient !== undefined && ambient.length > 0 ? ambient : undefined + const hit = credentials !== undefined + ? (await credentials.resolve(ref))?.value + // Without the seam, read exactly the named variable so a plain + // cordis.yml composition works from the environment alone. + : process.env[ref] + if (hit !== undefined && hit.length > 0) return hit + throw new LlmError( + `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` + + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` + + ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery', + 'MISSING_CREDENTIAL', + ) } const adapter = new PiAiAdapter({ profiles, resolveApiKey }) @@ -94,18 +115,29 @@ export function apply(ctx: Context, config: Config): void { // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a // settings section supplies profiles, and routes drop when it empties. - let disposeRoutes: (() => void) | undefined + let registration: AdapterRegistrationHandle | undefined let registeredFacts: unknown const ensureRegistrationFacts = (): void => { const facts = registrationFacts(profiles()) if (deepEqualJson(facts, registeredFacts)) return // The registry captures the route set and each route's retry policy at - // registration: swap the registration in one synchronous section (same - // adapter instance, no NO_ADAPTER window). - disposeRoutes?.() - disposeRoutes = undefined + // registration, so a change to either must re-register. The swap is + // atomic (same adapter instance, validated before anything moves): a + // conflicting route leaves the previous routes serving requests, and + // `registeredFacts` only advances once the registry actually holds the + // new set — so returning to a working configuration always re-applies. const routes = [...profiles().keys()] - if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter) + if (registration === undefined) { + // Dormant bare mount: nothing is registered until a section supplies + // profiles, and an empty section keeps it that way. + if (routes.length === 0) { + registeredFacts = facts + return + } + registration = ctx.llm.registerAdapter(routes, adapter) + } else { + registration.replace(routes) + } registeredFacts = facts } ensureRegistrationFacts() diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 4b97e7b2a7..a0826b3571 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -27,7 +27,7 @@ async function harness(baseURL: string, overrides: Record = {}) function adapterOf(providers: Record): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles(providers), - resolveApiKey: profile => Promise.resolve(profile.apiKey), + resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), }) } @@ -385,13 +385,19 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') }) - it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => { + it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => { + // The exact confusion this guards: the named reference is empty while an + // unrelated provider key sits in the environment. Deferring to pi-ai's own + // discovery here would authenticate as another tenant. vi.stubEnv('PI_CUSTOM_REF_KEY', '') vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) + expect(server.requests).toHaveLength(0) }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 4bf4d6425a..598d2aa2a9 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -14,6 +14,14 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-pi-ai') +/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */ +class StubAdapter extends LlmAdapter { + + override async * stream(): AsyncIterable { + throw new Error('stub adapter must never stream') + } +} + const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -137,4 +145,45 @@ describe('request-level dynamic profiles', () => { await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) + + it('keeps serving its routes when a settings-born route collides with another adapter', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) + // Another adapter owns `anthropic`; the registry must refuse to hand it over. + ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) + + await ctx.settings.update(NS, { + providers: { + openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, + anthropic: { apiKey: 'other' }, + }, + }) + + // The conflicting swap was refused whole: the previous route set still + // owns openai (an eager dispose would have dropped it), and anthropic + // still belongs to its original adapter. + expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(result.finish.kind).toBe('error') + expect(server.paths).toEqual(['/v1/responses']) + + // Reverting to the working configuration re-applies, even though its + // facts equal the ones the registry already holds. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/responses', '/v1/responses']) + }) + + it('ignores a settings document that merely reorders its provider keys', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } }) + const before = ctx.llm.listProviders().map(provider => provider.id) + + // Same routes, different YAML key order: nothing about the registration + // changed, so no swap should happen at all. + await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before) + }) }) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 1fb4443af0..73ac6ed900 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,6 +184,26 @@ export abstract class LlmAdapter { abstract stream(options: GenerateOptions): AsyncIterable } +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +export interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} + /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -201,39 +221,70 @@ export class LlmService extends Service { * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer that unregisters all of them. + * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. */ - registerAdapter(providers: string[], adapter: LlmAdapter): () => void { + registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { + // The routes this registration currently holds; `replace` rewrites it, and + // the disposer releases whatever it holds at disposal time. + const owned = new Set() const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') - const unique = new Set() - const registrations: AdapterRegistration[] = [] - for (const provider of providers) { - if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') - if (unique.has(provider) || this.adapters.has(provider)) { - throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') - } - const info = adapter.providerInfo(provider) - if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { - throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') - } - unique.add(provider) - const retryPolicy = adapter.providerRetryPolicy(provider) - ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) - registrations.push({ - adapter, - provider: { id: info.id, name: info.name }, - retryPolicy, - }) - } - for (const registration of registrations) this.adapters.set(registration.provider.id, registration) + this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) yield () => { - for (const provider of providers) this.adapters.delete(provider) + for (const provider of owned) this.adapters.delete(provider) + owned.clear() } }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + const handle = (() => void dispose()) as AdapterRegistrationHandle + handle.replace = (next: string[]): void => { + this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) + } + return handle + } + + /** + * Validate one candidate route set for `adapter`, treating routes this + * registration already holds as available. Nothing is mutated: a rejected + * candidate leaves the registry exactly as it was. + */ + private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet): AdapterRegistration[] { + const unique = new Set() + const registrations: AdapterRegistration[] = [] + for (const provider of providers) { + if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') + if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) { + throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') + } + const info = adapter.providerInfo(provider) + if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { + throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') + } + unique.add(provider) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) + } + return registrations + } + + /** + * Swap this registration's routes for the prepared ones in one synchronous + * section, so no observer can see the registry between the release and the + * re-registration. + */ + private commitRoutes(owned: Set, registrations: readonly AdapterRegistration[]): void { + for (const provider of owned) this.adapters.delete(provider) + owned.clear() + for (const registration of registrations) { + this.adapters.set(registration.provider.id, registration) + owned.add(registration.provider.id) + } } /** From d91f0227e6bc98ebcbea1554dc0bbf74ba85e7f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:52:51 +0800 Subject: [PATCH 34/97] fix(settings): keep installSettingsSection quiet when its consumer unloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's cleanup ran the same fallback for two different events. A settings provider detaching leaves the consumer running, so falling back to the composition entry and re-judging derived facts is right. The consumer's own unload ran it too — re-registering routes and touching resources the teardown was releasing. The disposer now checks the consumer fiber's own state and returns when it is unloading or disposed. --- packages/settings/settings/src/index.ts | 21 +++++++++++++ .../settings/settings/tests/settings.spec.ts | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 233a6734cb..95d43e756f 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -538,6 +538,20 @@ export abstract class Settings extends Service { } } +/** + * Value mirror of the `FiberState` members {@link isUnloading} compares + * against: a const enum has no runtime object to import, and the value is + * needed at runtime (same rationale as the CLI boot driver's mirror). + */ +const FIBER_DISPOSED = 4 +const FIBER_UNLOADING = 5 + +/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */ +function isUnloading(ctx: Context): boolean { + const state: number = ctx.fiber.state + return state === FIBER_UNLOADING || state === FIBER_DISPOSED +} + /** Hooks a consumer hands to {@link installSettingsSection}. */ export interface SettingsSectionHooks { /** @@ -578,6 +592,13 @@ export function installSettingsSection( const scope = sctx.settings.register(ns, schema, { base: entry }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { + // This disposer runs for two different reasons. A settings provider + // detaching leaves the consumer running, so it must fall back to its + // composition entry and re-judge what it derived. The consumer's own + // unload runs it too — and there `onChange` would re-register routes + // and touch resources the teardown is releasing, so the fallback is + // pointless and the notification actively harmful. + if (isUnloading(ctx)) return hooks.setSource(() => entry) hooks.onChange() }) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 76d40b77d6..5f9ac7dae7 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -694,4 +694,34 @@ describe('installSettingsSection', () => { }) expect(current()).toEqual({ theme: 'entry' }) }) + + it('stays silent when the consumer itself unloads', async () => { + const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + const changes: string[] = [] + const consumer = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes.push(current().theme) + }, + }) + }, + }) + await consumer + await vi.waitFor(() => { + expect(changes).toEqual(['user']) + }) + + // The consumer's own teardown must not re-derive anything: an onChange + // here would re-register routes and touch resources being released. + await consumer.dispose() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(changes).toEqual(['user']) + }) }) From 7606a9981332249bc3a0a43d280df1fd0c56df30 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:02:13 +0800 Subject: [PATCH 35/97] feat(sandbox): deny confined executions read access to the credential document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential store is 0600 under a 0700 directory, which stops other OS users but not the model: tool processes run as the same user, so under the shipped danger-full-access default they read it like any other file. SandboxExecutionPolicy grows readDenyPaths, and sandbox-policy defaults it to $DSH_HOME/.env — the exact file rather than the harness home, so the model keeps its documented access to its own session log. Seatbelt appends a trailing deny (last matching rule wins) and bwrap maps /dev/null over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own / read grant, so confine() reports partial enforcement there instead of claiming a boundary the process does not have. A real-kernel Seatbelt e2e proves the shape: the same read succeeds unconfined and fails under the denial, while a sibling file in the same directory stays readable. Both READMEs state the residual boundary plainly — no confining mode means no boundary — and record the OS keychain provider as the real answer. --- .../credentials/credentials-local/README.md | 15 +++++++-- packages/sandbox/sandbox-local/src/index.ts | 7 +++- .../sandbox/sandbox-local/src/profiles.ts | 23 ++++++++++++- .../sandbox/sandbox-local/tests/local.spec.ts | 21 ++++++++++++ .../sandbox-local/tests/seatbelt.e2e.ts | 33 ++++++++++++++++++- packages/sandbox/sandbox-policy/README.md | 6 ++++ packages/sandbox/sandbox-policy/package.json | 2 ++ packages/sandbox/sandbox-policy/src/index.ts | 23 ++++++++++++- .../sandbox-policy/tests/policy.spec.ts | 29 +++++++++++++++- packages/sandbox/sandbox-policy/tsconfig.json | 3 ++ packages/sandbox/sandbox/src/index.ts | 12 +++++++ 11 files changed, 167 insertions(+), 7 deletions(-) diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 277c7db028..2288d6d713 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -22,7 +22,7 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, ## The document -dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`. +dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. @@ -30,6 +30,15 @@ Values are rendered in the narrowest style dotenv reads back verbatim — bare, External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +## Security boundary + +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns. Two things narrow that: + +- A **confining sandbox mode** denies the credential document specifically: [`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) defaults `readDenyPaths` to `$DSH_HOME/.env`, and the Seatbelt and bwrap backends enforce it (Landlock cannot subtract from its own `/` read grant and reports `partial`). The denial names the file, not the home, so the model keeps its documented access to its own session log. +- The harness never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). + +Neither makes an unconfined agent safe. A deployment that must keep provider keys away from its own agent should run a confining mode; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. + ## Model Experience Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. @@ -40,7 +49,9 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly. +- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. +- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. +- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred. - **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. - **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 98dc86d23e..827f20d696 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -228,7 +228,12 @@ export class LocalSandboxProvider extends SandboxProvider { const selected = this.selectRunner(policy.mode) return { argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], - enforcement: selected.enforcement, + // Landlock grants are a pure allow-list, so it cannot subtract a read + // denial from its own `/` read grant: promising `full` there would + // misreport a boundary the process does not have. + enforcement: selected.runner === 'landlock' && (policy.readDenyPaths?.length ?? 0) > 0 + ? 'partial' + : selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], } diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index cee0f00852..27ca150ef4 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -5,9 +5,14 @@ */ import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' -import { writableRoots } from '@deepseek-ai/dsh-sandbox' +import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +/** This policy's read denials, canonical and deduplicated like the writable roots. */ +function denyPaths(policy: SandboxPolicy): string[] { + return [...new Set((policy.readDenyPaths ?? []).map(path => canonicalPath(path)))] +} + /** * Build the bwrap profile arguments for one file-effect policy. * @param policy - file-effect policy to express as bwrap mounts. @@ -19,6 +24,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { args.push('--tmpfs', '/tmp') args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) } + // Read denials come last so a workspace bind can never re-expose one. + // `/dev/null` over the path reads as empty; the `-try` form tolerates a + // path that does not exist yet (no credential stored so far). + for (const path of denyPaths(policy)) args.push('--ro-bind-try', '/dev/null', path) return args } @@ -28,6 +37,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { * @returns launcher grant arguments before the trailing separator and command argv. */ export function landlockProfileArgs(policy: SandboxPolicy): string[] { + // Landlock grants are a pure allow-list: a read grant on `/` cannot be + // subtracted from, so a requested read denial is unenforceable here. The + // provider reports `partial` enforcement for exactly this case rather than + // pretending the boundary exists. const readWrite = ['/dev/null'] if (policy.mode === 'workspace-write') { readWrite.push('/tmp', policy.workspaceRoot) @@ -54,5 +67,13 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { if (roots.length > 0) { forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) } + // SBPL applies the last matching rule, so the read denial is appended after + // every allow above and governs both reads and writes of those paths. Both + // filters are emitted so a denial may name a file or a directory. + const denied = denyPaths(policy) + if (denied.length > 0) { + const filters = denied.map(path => `(literal ${sbplString(path)}) (subpath ${sbplString(path)})`).join(' ') + forms.push(`(deny file-read* file-write* ${filters})`) + } return ['-p', forms.join(' ')] } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f7cc952498..ab99c5cc99 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -62,6 +62,27 @@ describe('profile dialects', () => { ]) }) + it('bwrap read denial: /dev/null over each denied path, after any workspace bind', () => { + expect(bwrapProfileArgs({ ...WW, readDenyPaths: ['/ws/secret.env'] })).toEqual([ + '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', + '--tmpfs', '/tmp', '--bind', '/ws', '/ws', + // The workspace bind above would otherwise re-expose the file. + '--ro-bind-try', '/dev/null', '/ws/secret.env', + ]) + }) + + it('landlock ignores read denials: a `/` read grant cannot subtract from itself', () => { + expect(landlockProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })) + .toEqual(landlockProfileArgs(RO)) + }) + + it('seatbelt read denial: a trailing deny naming the path as both a file and a directory', () => { + expect(seatbeltProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })).toEqual([ + '-p', + `${SEATBELT_RO_PROFILE} (deny file-read* file-write* (literal "/ws/secret.env") (subpath "/ws/secret.env"))`, + ]) + }) + it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => { // /dev/null specifically, NOT /dev: a whole-/dev grant would let confined // commands write real host paths beneath it (/dev/shm) under read-only. diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 6d645b1a3b..a01e3a25a2 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -70,6 +70,37 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu expect(result.stdout).toBe('dev-ok\n') }) + it('denies reading a credential document the mode would otherwise allow', async () => { + // The harness's own secret store: readable to the user, and the model's + // bash runs as that user — only the confinement can take it away. + const workdir = await tempDir(tmpdir()) + const secret = join(workdir, '.env') + await writeFile(secret, 'DEEPSEEK_API_KEY=sk-must-not-leak\n', { mode: 0o600 }) + const sandbox = await provider() + + const allowed = runConfined(sandbox, `cat ${secret}`, { mode: 'read-only', workspaceRoot: workdir }) + expect(allowed.result.stdout).toContain('sk-must-not-leak') + + const denied = runConfined(sandbox, `cat ${secret}`, { + mode: 'read-only', + workspaceRoot: workdir, + readDenyPaths: [secret], + }) + expect(denied.result.stdout).not.toContain('sk-must-not-leak') + expect(denied.result.status).not.toBe(0) + expect(denied.confined.enforcement).toBe('full') + // Everything else under the same directory stays readable: the denial is + // the credential document, not the harness home. + const sibling = join(workdir, 'notes.txt') + await writeFile(sibling, 'ordinary\n') + const neighbour = runConfined(sandbox, `cat ${sibling}`, { + mode: 'read-only', + workspaceRoot: workdir, + readDenyPaths: [secret], + }) + expect(neighbour.result.stdout).toBe('ordinary\n') + }) + it('read-only grants no temp area: a write under the user temp dir is denied too', async () => { // The per-user darwin temp dir is a workspace-write grant, not a // read-only one — under read-only the only write-shaped path is /dev/null. diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index dca54330bc..297dd7d521 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -13,6 +13,12 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). - `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead. +## Read denials + +`readDenyPaths` names absolute paths a **confined** execution must not read, whatever its mode otherwise permits. Omitted (or empty) denies the harness credential document `$DSH_HOME/.env`; a non-empty list replaces that default. Denials name exact paths rather than roots on purpose: denying the whole harness home would also take away the model's documented access to its own session log. + +Enforcement is backend-shaped. Seatbelt appends a trailing `deny file-read* file-write*` (last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list, so a read grant on `/` cannot be subtracted from and `confine()` reports `partial` enforcement rather than pretending the boundary exists. `danger-full-access` confines nothing at all, so no denial applies there — the credential document is then protected only by its file mode, which does not stop a same-UID tool process. + ## Surface - `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index d5f9270ed1..bb48bb0b35 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -37,6 +38,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 1f5ba0bb00..74c05f76a1 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -14,10 +14,11 @@ * @module @deepseek-ai/dsh-sandbox-policy */ -import { resolve as resolvePath } from 'node:path' +import { join, resolve as resolvePath } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Session } from '@deepseek-ai/dsh-session' import { effectiveSandboxMode } from './session-mode.ts' @@ -49,6 +50,16 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string + /** + * Absolute paths confined executions must not read, whatever their mode + * otherwise permits. Omitted (or empty) denies the harness home's + * credential document (`$DSH_HOME/.env`) — exactly that file, so the model + * keeps the documented access to its own session log under the same home; + * a non-empty list replaces it. Backends that cannot express a read denial + * report `partial` enforcement instead of pretending, and + * `danger-full-access` confines nothing, so no denial applies there at all. + */ + readDenyPaths?: string[] } /** Inputs that select the sandbox policy for one capability call. */ @@ -72,12 +83,15 @@ export class SandboxPolicyService extends Service { // No schema default: process.cwd() is resolved in the constructor so the // stored root is always absolute regardless of how it was supplied. workspaceRoot: z.string(), + readDenyPaths: z.array(z.string()), }) /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string + /** Absolute paths every confined execution is denied read access to. */ + readonly readDenyPaths: readonly string[] constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') @@ -86,6 +100,12 @@ export class SandboxPolicyService extends Service { // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + // The credential document is the default denial; a configured list + // replaces it. Schemastery fills an omitted array with `[]`, so empty and + // omitted are the same request: protect the default document. + const denyPaths = config.readDenyPaths ?? [] + this.readDenyPaths = (denyPaths.length > 0 ? denyPaths : [join(resolveDshHome(), '.env')]) + .map(resolveWorkspaceRoot) } /** @@ -102,6 +122,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + readDenyPaths: this.readDenyPaths, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 63ca0cd3d5..34e2f1b5cb 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -10,9 +10,14 @@ import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { +async function mounted(config: { + mode?: 'read-only' | 'workspace-write' | 'danger-full-access' + workspaceRoot?: string + readDenyPaths?: string[] +} = {}) { const ctx = new Context() await ctx.plugin(SandboxPolicyService, config) return ctx @@ -41,11 +46,28 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) }) + it('denies reading the harness credential document by default', async () => { + const ctx = await mounted() + // The exact file, not the whole home: the model keeps the documented + // access to its own session log under the same directory. + expect(ctx.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + + it('replaces the default with a configured denial list', async () => { + const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] }) + expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')]) + // Schemastery fills an omitted array with `[]`, so empty reads as omitted. + const empty = await mounted({ readDenyPaths: [] }) + expect(empty.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + it('resolves the deployment policy for an agentless call', async () => { const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -58,16 +80,19 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -87,6 +112,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) } finally { rmSync(root, { recursive: true, force: true }) @@ -100,6 +126,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index cb6fc623d0..65c906d6c3 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../sandbox" }, + { + "path": "../../util/paths" + }, { "path": "../../core/session" }, diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 781227f411..11e690704e 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,6 +40,18 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } /** From 9626c15c6bfb651a7759939024a34c6cad5181e6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:11:21 +0800 Subject: [PATCH 36/97] test(sandbox): carry the resolved read denials through consumer policy assertions The policy home's resolve() now stamps readDenyPaths, so every consumer that pins the resolved shape (bash-sandbox hand-off, tool-fs stamps) carries it, and three uncovered branches gained real tests: landlock reporting partial enforcement for a denial it cannot express, the policy's default under programmatic construction, and both ambient credential paths in llm-deepseek without a mounted seam. --- ...29-request-level-llm-config-credentials.md | 2 +- ...tial-boundaries-and-atomic-registration.md | 37 ++++++ .../bash/bash-sandbox/tests/sandbox.spec.ts | 10 +- .../credentials-local/README.zh.md | 15 ++- .../credentials-local/src/index.ts | 1 + packages/fs/tool-fs/tests/tools.spec.ts | 10 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 21 ++++ packages/llm/llm-pi-ai/README.md | 6 +- packages/llm/llm-pi-ai/README.zh.md | 6 +- packages/llm/llm-pi-ai/src/index.ts | 2 +- .../tests/loader-composition.spec.ts | 116 ++++++++++++++++++ packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- .../sandbox/sandbox-local/tests/local.spec.ts | 9 ++ packages/sandbox/sandbox-policy/README.zh.md | 6 + .../sandbox-policy/tests/policy.spec.ts | 7 ++ pnpm-lock.yaml | 3 + 19 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md create mode 100644 packages/llm/llm-pi-ai/tests/loader-composition.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index 67baec4b70..f12a2496a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -26,4 +26,4 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Consequences -Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). +Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)). diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md new file mode 100644 index 0000000000..837aa3e7b8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -0,0 +1,37 @@ +# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration + +Status: implemented + +English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md) + +> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`. + +## Problem + +Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. + +Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. + +## Decision + +**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. + +**The confining sandbox is the only real read boundary, and it names the file.** `SandboxExecutionPolicy` grows `readDenyPaths`, defaulted by `sandbox-policy` to `$DSH_HOME/.env`. Seatbelt appends a trailing `deny file-read* file-write*` (SBPL's last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own `/` read grant, so `confine()` reports `partial` enforcement instead of claiming a boundary the process lacks. Denials name exact paths, not roots: denying the whole harness home would also take away the model's documented access to its own session log. Both READMEs state the residue plainly — under the shipped `danger-full-access` default nothing is confined and the file is protected only by the OS user — and record an OS-keychain provider as the real answer. + +**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. + +**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. + +**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. + +## Alternatives considered + +- **Denying the whole harness home** — one root would have covered the credential document and any future secret file, but it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. Exact paths keep the denial to what is actually secret. +- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. The sandbox denial is the boundary; hiding the pointer is not. +- **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. +- **Treating `readDenyPaths: []` as an opt-out** — schemastery fills an omitted array with `[]`, so empty and omitted are indistinguishable at the constructor. Empty therefore means "protect the default document"; a deployment that stores credentials elsewhere names its own paths, and a denial on a path nothing reads costs nothing. +- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. + +## Consequences + +`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. A confined execution loses read access to `$DSH_HOME/.env` — deployments that deliberately let an agent read its own credential file must configure `readDenyPaths` themselves. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 90a67999c8..df4916b6b6 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -11,6 +11,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -74,8 +75,11 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } +/** The policy home's default read denial: the harness credential document. */ +const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] + function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { - return { mode, workspaceRoot } + return { mode, workspaceRoot, readDenyPaths: DEFAULT_DENY } } describe('the provider hand-off', () => { @@ -86,7 +90,7 @@ describe('the provider hand-off', () => { expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) expect(calls).toEqual([{ argv: ['bash', '-c', 'echo \'a b\' "c\'d"'], - policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) }, + policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }, }]) }) @@ -103,7 +107,7 @@ describe('the provider hand-off', () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) - expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) }) + expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }) }) it('an explicit workspaceRoot on the policy wins', async () => { diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index af1b840142..1b2b002e60 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -22,7 +22,7 @@ ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`。 +dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 @@ -30,6 +30,15 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +## 安全边界 + +文档位于 `0700` 目录下、权限 `0600`,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: + +- **约束型沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 +- harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 + +这两者都不能让未受约束的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行约束型模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 + ## Model Experience 经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 @@ -40,7 +49,9 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不 ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。 +- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 +- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有约束型沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 - **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 576b8241f0..44678b5837 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -160,6 +160,7 @@ function upsertLine(text: string | undefined, ref: CredentialRef, rendered: stri } const [, key, valuePart] = match if (key !== ref) { + /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ pendingQuote = opensMultiline(valuePart ?? '') out.push(line) continue diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index de844dcaf4..a3b658e6bf 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' @@ -112,6 +113,9 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +/** The policy home's default read denial: the harness credential document. */ +const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] + describe('session cwd resolution', () => { const execution = (cwd?: string) => cwd === undefined ? {} @@ -763,13 +767,13 @@ describe('sandbox escalation surface (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -802,7 +806,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 88f4fd7c01..ab44b61e30 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -50,7 +50,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 386c695766..4ecaf361fd 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -50,7 +50,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b5a4bc9ff4..aec9229e25 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -823,6 +823,27 @@ describe('plugin registration and config', () => { .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) + it('reads the ambient variable when no credentials seam is mounted', async () => { + // The plain cordis.yml composition: no credential provider, the key in + // the launching environment. + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { baseURL: server.url }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + }) + + it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + }) + it('prefers explicit config over env for key and base URL', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index fb8145d58a..0099c9acd3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile r ## Config -Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file; omitting both delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. ```yaml - id: llm @@ -41,7 +41,7 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0d0e2152d2..7cb4f5fcbc 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 ```yaml - id: llm @@ -41,7 +41,7 @@ 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()` 与 `providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 7ff3825bf2..4c610cae21 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -54,7 +54,7 @@ const NS = settingsNamespace('llm-pi-ai') function registrationFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) - .sort((left, right) => left.provider < right.provider ? -1 : left.provider > right.provider ? 1 : 0) + .sort((left, right) => left.provider.localeCompare(right.provider)) } /** Register one generic pi-ai adapter for all configured provider routes. */ diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..460e78b7c2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -0,0 +1,116 @@ +/** + * Real-composition guard for the dormant pi-ai posture: LlmService, + * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a + * test-only cordis.yml through the actual Loader + Include path, an external + * edit of settings.yaml registers the route live, and the next request + * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot + * catch Loader export-shape failures, which is why the twin adapter has the + * same guard. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + await closeMockServers() + vi.unstubAllEnvs() +}) + +/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */ +async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) + const settingsPath = join(root, 'settings.yaml') + await writeFile(settingsPath, '# personal settings\n') + await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: credentials', + " name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: ${JSON.stringify(join(root, '.env'))}`, + ' debounceMs: 10', + '- id: llm-pi-ai', + " name: '@deepseek-ai/dsh-llm-pi-ai'", + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath } +} + +describe('llm-pi-ai real dormant composition', () => { + it('boots with zero routes and registers one the moment settings supply a profile', async () => { + vi.stubEnv('PI_COMPOSITION_KEY', '') + const server = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath } = await loadComposition() + + // The shipped posture: the adapter exists, no route does. + expect(ctx.llm.listProviders()).toEqual([]) + + // Exactly what the web Models page leaves on disk. + await writeFile(settingsPath, [ + 'llm-pi-ai:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, + '', + ].join('\n')) + await vi.waitFor(() => { + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + }, { timeout: 5000 }) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') + }) +}) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..5b0c1b2dca 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Public API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4dc4a0ca06..5f5c8142ec 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。 +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index ab99c5cc99..ceadaba184 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -329,6 +329,15 @@ describe('the default landlock probe (launcher CLI contract)', () => { expect(sandbox.confine(['true'], RO).enforcement).toBe('partial') }) + it('reports partial enforcement when a read denial is requested it cannot express', async () => { + const launcher = fakeLauncher() + const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) + // Fully enforced for the write policy, yet the read denial is + // unexpressible in an allow-list that already grants `/` for reads. + expect(sandbox.confine(['true'], RO).enforcement).toBe('full') + expect(sandbox.confine(['true'], { ...RO, readDenyPaths: ['/ws/secret.env'] }).enforcement).toBe('partial') + }) + it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index a201d48c81..1de92eb814 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -13,6 +13,12 @@ - `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。 - `workspaceRoot`:无 agent(智能体)的调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`;无论显式配置还是采用默认值,都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。 +## 读取拒绝 + +`readDenyPaths` 列出**受约束**执行绝不可读取的绝对路径,无论其模式在其他方面允许什么。省略(或为空)时拒绝 harness 凭据文档 `$DSH_HOME/.env`;非空列表则替换该默认值。拒绝项有意点名确切路径而非根目录:拒绝整个 harness home 会连带拿走模型对自己会话日志的既定访问。 + +强制执行的形态由后端决定。Seatbelt 追加一条尾部 `deny file-read* file-write*`(最后匹配的规则胜出),bwrap 在任何工作区绑定之后把 `/dev/null` 映射到每个路径上;Landlock 的授权是纯粹的允许列表,`/` 上的读授权无法被扣除,因此 `confine()` 把强制执行报为 `partial`,而不是假装该边界存在。`danger-full-access` 根本不做任何约束,那里也就没有任何拒绝适用——凭据文档届时只受自身文件权限模式保护,而这挡不住同 UID 的工具进程。 + ## 接口 - `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 34e2f1b5cb..7740abc058 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -54,6 +54,13 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) }) + it('defaults the denial list under programmatic construction too', () => { + // Constructing the service directly bypasses Schemastery, so the field + // arrives undefined rather than as the empty array the schema fills. + const service = new SandboxPolicyService(new Context(), {}) + expect(service.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + it('replaces the default with a configured denial list', async () => { const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] }) expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37f7e6cf29..367b2f4299 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3618,6 +3618,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox From e7894f4152cbe4f3b60d81f1cb52c1a4c24717ad Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:37:28 +0800 Subject: [PATCH 37/97] docs(credentials): record the third-review contracts across READMEs, catalogs, and a new Agent Note Both provider READMEs state what actually holds: credentials-local now documents the physical-line editor, the read-modify-write under the writer lock, and a Security boundary section saying plainly that the file mode stops other OS users and not the model. sandbox-policy documents readDenyPaths and its per-backend enforcement. The llm READMEs carry the registration handle, pi-ai's credential-miss semantics, and DeepSeek's same-generation snapshot; app-boot and the CLI README stop describing $DSH_HOME/.env as an environment layer. A new Agent Note records the round (and the prior seam note cross-links it); the sandbox and core catalog pages gain readDenyPaths and AdapterRegistrationHandle with their manifest entries. The headless missing-credential snapshot re-records for the reworded guidance, pi-ai gains the Loader-composition guard its twin already had, and the deliberate provider symmetry is marked for the clone detector. --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...request-level-llm-config-credentials.zh.md | 2 +- ...undaries-and-atomic-registration.i18n.yaml | 6 +++ ...l-boundaries-and-atomic-registration.zh.md | 41 +++++++++++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.zh.md | 2 +- docs/config-catalog.md | 16 ++++++-- docs/cordis-catalog/events.md | 11 +++-- docs/cordis-catalog/services.md | 14 +++---- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 24 +++++++++++ docs/core-data-structures/core.zh.md | 24 +++++++++++ docs/core-data-structures/sandbox.i18n.yaml | 6 +-- docs/core-data-structures/sandbox.md | 14 ++++++- docs/core-data-structures/sandbox.zh.md | 14 ++++++- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 3 +- .../headless-agent/tests/headless.snapshot.ts | 7 +++- .../stream-json.expected.jsonl | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 ++++-- .../credentials-local/README.i18n.yaml | 4 +- .../credentials-local/README.zh.md | 8 ++-- .../credentials-local/src/index.ts | 10 +++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm/README.i18n.yaml | 4 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.zh.md | 4 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 +++ 31 files changed, 212 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c8cde9db07..c7861321a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: 67baec4b70d0c754f22573d87fb4492de5ca16a4 -2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c +2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 +2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 36182b77f4..99fd90013a 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 后果 -上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。 +上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml new file mode 100644 index 0000000000..e7c7b51af6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +2026-07-30-credential-boundaries-and-atomic-registration.md: 837aa3e7b8ed30c66aad880ab2d76eee376e1854 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: a00c3d93d2dc0451ed29613c4a804b0e518264ed diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md new file mode 100644 index 0000000000..a00c3d93d2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册 + +Status: implemented + +[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 + +> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。 + +## 问题 + +评审发现,凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'` 且 `writable: false`,`set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。 + +存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。 + +在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 + +与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 + +## 决策 + +**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 + +**受限沙箱才是唯一真正的读取边界,而且它点名到具体文件。**`SandboxExecutionPolicy` 新增 `readDenyPaths`,由 `sandbox-policy` 默认设为 `$DSH_HOME/.env`。Seatbelt 在末尾追加一条 `deny file-read* file-write*`(SBPL 中最后一条匹配规则胜出),bwrap 则在所有工作区 bind 之后把 `/dev/null` 映射到每条路径上;Landlock 的授权是纯粹的允许列表,无法从它自己对 `/` 的读取授权中减去任何东西,因此 `confine()` 报告 `partial` 强制执行,而不是声称一条该进程其实并不具备的边界。拒绝点名的是确切路径,而不是根目录:把整个 harness home 都拒掉,会连带夺走模型对自身会话日志的成文访问权。两个 README 都直白写明残留风险——在已交付的 `danger-full-access` 默认值下没有任何东西受限,这个文件只靠 OS 用户身份保护——并记下 OS 钥匙串(keychain)提供方才是真正的答案。 + +**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 + +**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 + +**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 + +## 曾考虑的替代方案 + +- **拒掉整个 harness home**——一个根目录本可覆盖凭据文档以及将来任何机密文件,但它同时也覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。用确切路径可以把拒绝范围限定在真正属于机密的东西上。 +- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。沙箱拒绝才是边界,藏起指针不是。 +- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 +- **把 `readDenyPaths: []` 当作 opt-out**——schemastery 会把省略的数组填成 `[]`,因此在构造函数处空数组与省略无从分辨。于是空数组的含义就是「保护默认文档」;把凭据存在别处的部署自行点名它自己的路径,而在没人读取的路径上设一条拒绝并不产生任何代价。 +- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 + +## 后果 + +`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。受限执行会失去对 `$DSH_HOME/.env` 的读取权限——刻意让 agent 读取自身凭据文件的部署,必须自行配置 `readDenyPaths`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bb5f370009..d6193c6645 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 93c36d18abd06bbd7a80c918f520b92489180395 -README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd +README.md: 1decf018f53e55e6dde73d8b65963ab96e20122b +README.zh.md: eecf60ca6a1a543b6c8f71297eab036ca6b42815 diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..eecf60ca6a 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,7 +12,7 @@ TUI 界面: - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; -- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 513df9b0d2..4e8f02536b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -416,7 +416,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -656,7 +656,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1027,12 +1027,22 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string + /** + * Absolute paths confined executions must not read, whatever their mode + * otherwise permits. Omitted (or empty) denies the harness home's + * credential document (`$DSH_HOME/.env`) — exactly that file, so the model + * keeps the documented access to its own session log under the same home; + * a non-empty list replaces it. Backends that cannot express a read denial + * report `partial` enforcement instead of pretending, and + * `danger-full-access` confines nothing, so no denial applies there at all. + */ + readDenyPaths?: string[] } ``` Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:45`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 36dc85e4ea..f56552afff 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -443,13 +443,18 @@ Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src ### `credentials/updated` — emit -Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. +Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. ```ts cordis-catalog /** * Committed change to a provider-managed credential source: a `set`, an * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. + * process-environment changes are not observable and never emit. Listener + * failures are contained and logged — a sync throw and an async rejection + * alike — without changing the committed operation's outcome, except + * `INVARIANT`-coded failures, which rethrow after every listener ran; + * that rethrow reaches the emitter only from synchronous listeners, so + * invariant checks on this event must not be async functions. * @param ref - the reference whose stored value changed. * @mode emit */ @@ -458,7 +463,7 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o Types: [CredentialRef](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) ## `domain/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d157fffe31..016dff7b15 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -532,7 +532,7 @@ abstract unset(ref: CredentialRef): Promise Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) @@ -790,9 +790,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer that unregisters all of them. + * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. */ -registerAdapter(providers: string[], adapter: LlmAdapter): () => void +registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle /** * Describe provider routes with a registered adapter. @@ -864,9 +864,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:211`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1059,7 +1059,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:143`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` @@ -1087,7 +1087,7 @@ overrideOf(session: Session): SandboxMode | undefined Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:79`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 948002edff..c8cb8302d1 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 docs/core-data-structures/core.md -core.md: 5e1049a131cfdbf2368350dbc199aebceebf71ba -core.zh.md: fbb95c1dfa40cc05d9e1f3a4c6ef32c11cab0ec7 +core.md: 5c79f454f50a059d72a592df45d504ee78835e0b +core.zh.md: 258517c625822bdbd64138baf3df186b075bb5c6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5e1049a131..5c79f454f5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -183,6 +183,30 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. +Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs. + +```ts type-equiv +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} +``` + ```ts type-equiv /** Display metadata for one registered provider route. */ interface LlmProviderInfo { diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index fbb95c1dfa..258517c625 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -189,6 +189,30 @@ interface MessageSourceMap { 提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 +注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。 + +```ts type-equiv +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} +``` + ```ts type-equiv /** Display metadata for one registered provider route. */ interface LlmProviderInfo { diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index f8189f4e15..c369d8066f 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 +# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md +sandbox.md: 566ac0edc0ba0600e2a1b5ecf18cc34e05e910ec +sandbox.zh.md: 24d8fbfc6c952246278192b5bed7cdc09223e7db diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..566ac0edc0 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. `readDenyPaths` names paths a confined execution must not read whatever its mode permits — the harness credential document by default — and backends that cannot express such a denial report `partial` enforcement rather than claiming a boundary the process lacks. ```ts type-equiv /** @@ -53,6 +53,18 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 9a52f12675..24d8fbfc6c 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。`readDenyPaths` 点名受限执行无论其模式允许什么都不得读取的路径——默认是 harness 凭据文档——无法表达此类拒绝的后端会把强制执行报为 `partial`,而不是声称一条该进程其实并不具备的边界。 ```ts type-equiv /** @@ -53,6 +53,18 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 14e8a00960..a481e67e54 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | [`credentials`](../packages/credentials/credentials) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 81dd199dbb..25d5ee1399 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -481,6 +481,7 @@ flowchart TD pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_paths pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants @@ -1098,7 +1099,7 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index fba7bf7338..a09e0281cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -191,10 +191,13 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) + // The guidance leads with the credential store — the path that keeps the + // secret out of configuration files — and offers a literal key last. expect(result.stderr).toBe( 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' - + ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,' - + ' or export DEEPSEEK_API_KEY\n', + + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' + + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' + + ' "apiKey" in the llm-deepseek settings section\n', ) const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index d7d72f6a86..c48a42f62e 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -4,5 +4,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}} -{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9f3d78c9bf..553e321d7a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -405,8 +405,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', methods: [ { - signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', - jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all of them.\n */', + signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle', + jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */', }, { signature: 'listProviders(): LlmProviderInfo[]', @@ -1297,7 +1297,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'credentials/updated', mode: 'emit', signature: '\'credentials/updated\'(ref: CredentialRef): void', - jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', + jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', }, { @@ -1521,6 +1521,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'AdapterRegistrationHandle', + declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', @@ -2207,7 +2211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n readDenyPaths?: readonly string[];\n}', }, { name: 'SandboxMode', diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 23cf5bb09b..55d8f24b34 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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 packages/credentials/credentials-local/README.md -README.md: 277c7db02836819e34a5c2db8aaf542c8eeec162 -README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867 +README.md: 2288d6d7133a7f356823e3e4f28746cfd28b2597 +README.zh.md: 959322c9ec670ed76b89f1f3a19246191b3ec02c diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 1b2b002e60..959322c9ec 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,12 +32,12 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档位于 `0700` 目录下、权限 `0600`,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: -- **约束型沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 +- **受限沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 - harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 -这两者都不能让未受约束的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行约束型模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +这两者都不能让未受限的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行受限模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 ## Model Experience @@ -51,7 +51,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 - **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有约束型沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 - **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 44678b5837..c62d4411fa 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -302,6 +302,11 @@ export class CredentialsLocal extends Credentials { await this.write(ref, undefined) } + /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same + reviewed contract as settings-local, deliberately mirrored (prefer symmetry + for parallel values); the two providers own different documents and + failure policies, so extracting the shape would couple their teardown + semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue(operation: () => Promise): Promise { const task = this.operations.then(operation) @@ -319,6 +324,7 @@ export class CredentialsLocal extends Credentials { this.ctx.logger.error(error) }) } + /* jscpd:ignore-end */ /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ private async write(ref: CredentialRef, value: string | undefined): Promise { @@ -390,6 +396,9 @@ export class CredentialsLocal extends Credentials { this.values = new Map(Object.entries(parse(text))) } + /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and + reconcile policy: warn-and-keep on a reload, throw on a write, invariant + failures propagate. */ /** * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable document keeps the @@ -430,6 +439,7 @@ export class CredentialsLocal extends Credentials { this.values = next for (const ref of changed) this.notifyUpdated(ref) } + /* jscpd:ignore-end */ /** Seam-addressable entries whose effective (non-empty) value changed. */ private changedRefs(prev: Map, next: Map): CredentialRef[] { diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 41ecd175d9..e02f994fef 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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 packages/llm/llm-deepseek/README.md -README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303 -README.zh.md: 386c695766a68c9054472bd5c9b9deb6746c52e6 +README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce +README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6584157850..25e825eead 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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 packages/llm/llm-pi-ai/README.md -README.md: fb8145d58a7c74c70498468044282c740460a947 -README.zh.md: 0d0e2152d27447705cdf19f5b36069314ff05b4d +README.md: 0099c9acd39cd2d471936505726d68423f351c76 +README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 49ff6d7c48..d7740dcebf 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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 packages/llm/llm/README.md -README.md: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 4dc4a0ca06378116d05fdb4b9b048738930511fd +README.md: 5b0c1b2dcafeefaad25f1714e4a1783430370118 +README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index b21c8d885a..78ec04a6a0 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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 packages/sandbox/sandbox-policy/README.md -README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd -README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c +README.md: 297dd7d5210bb30963a162c6a55a598c6d522aaf +README.zh.md: 1de92eb81409a7fabb25de94eb5372f0f16afb6f diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..17ce617b70 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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 packages/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: 47c5de35e6151b82f8d99c06618c42dfabe59f5e +README.zh.md: 9878567464b46865f9320582359f7baa0c97f30c diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..9878567464 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -26,8 +26,8 @@ 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e2fed96e45..b9dbb501ec 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -33,6 +33,7 @@ export const LINK_MAP: Readonly> = { MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', + AdapterRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6687d4127d..8e6f4c62ce 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,6 +31,11 @@ "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AdapterRegistrationHandle", + "source": "packages/llm/llm/src/index.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", From 0a25c0d2371eb3ecc84545ae5241821e2f2961f1 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 16:41:55 +0800 Subject: [PATCH 38/97] feat(web): add queue collapse control --- ...-29-addressable-queue-operations.i18n.yaml | 4 +- ...2026-07-29-addressable-queue-operations.md | 4 +- ...6-07-29-addressable-queue-operations.zh.md | 4 +- apps/web/tests/queue-actions.e2e.ts | 4 + .../queue-actions/editing.expected.md | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/queue/QueueDock.module.css | 39 ++++ .../src/client/queue/QueueDock.tsx | 190 ++++++++++-------- .../ui-conversation/tests/queue-dock.spec.tsx | 33 ++- 11 files changed, 190 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 85befa1383..3524737277 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 78a7d346163bb7e5e76c989c6e93576b4a6cee64 -2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af +2026-07-29-addressable-queue-operations.md: 28570be7a2a520fd8293a73526302122740823ca +2026-07-29-addressable-queue-operations.zh.md: eb62d45c6572cd993fedf982d6c86e2ff19787a6 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 78a7d34616..28570be7a2 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" Queued"` header that expands or collapses the complete list. The header exposes `aria-expanded`. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. ## Alternatives considered @@ -34,7 +34,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios expand the queue and drive its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 050b9755ad..eb62d45c65 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" Queued"` 表头。表头暴露 `aria-expanded`。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 ## 考虑过的替代方案 @@ -34,7 +34,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 ## 后果 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 1df3ffc247..025747c37d 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -80,6 +80,10 @@ describe('web e2e: queue row actions', () => { await input.fill(text) await input.press('Enter') } + const queueHeader = page.getByRole('button', { name: '2 Queued' }) + await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) + .toBe('false') + await queueHeader.click() await expect.poll( () => page.getByRole('button', { name: '删除排队消息' }).count(), { timeout: 10_000 }, diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 311c961450..0bb20cde47 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -12,6 +12,7 @@ - button "编辑": - img - paragraph: partial +- button "2 Queued" [expanded] - list: - listitem: - text: Queue item to remove diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 654722b589..5b9adf8b01 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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 packages/client/ui-conversation/README.md -README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b +README.md: 6e48c127b18eb65008edd2debf9835e5beffdcca +README.zh.md: 632d2f9959ac496e6f4512ec30ca0f97c53a5abf diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3973c14f2b..6e48c127b1 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,6 +18,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +`QueueDock` takes the same input-dock list at `order: 0`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" Queued"` header whose button expands or collapses the complete list. The header exposes `aria-expanded`; each visible row remains a single-line preview with its exact-occurrence edit and delete actions. + Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a6bb15c4cd..632d2f9959 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,6 +18,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +`QueueDock` 以 `order: 0` 占用同一个 input-dock 列表。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" Queued"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded`;每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 + 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4c05c2cbca..ce4455509b 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -30,6 +30,45 @@ pointer-events: none; } +.header { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 36px; + padding: 4px 16px 4px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + text-align: left; + cursor: pointer; +} + +.header:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.count { + flex: 1 1 auto; + min-width: 0; + font-family: Inter, var(--dsw-font-family); + font-size: 14px; + font-weight: 500; + line-height: 24px; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + width: 14px; + height: 14px; + color: var(--dsw-alias-label-tertiary); +} + .list { margin: 0; padding: 0; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..b4f7da1cc5 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -8,7 +8,8 @@ import { useEffect, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, + IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, + IconCloseOutline16, IconEditOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import css from './QueueDock.module.css' @@ -22,11 +23,15 @@ export interface QueueDockInjected { /** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected -/** Queue strip: one preview line per queued message; renders null when the queue is empty. */ +/** + * Queue strip: one item renders directly; multiple items default to a + * collapsible count header; an empty queue renders nothing. + */ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { const queue = useSession(s => s.queue) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) + const [collapsed, setCollapsed] = useState(true) useEffect(() => { if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) @@ -63,92 +68,107 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { return (
-
    - {queue.map(row => ( -
  • - {editing?.id === row.id - ? ( - { setEditing({ id: row.id, text: event.currentTarget.value }) }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - setEditing(null) - return - } - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { - event.preventDefault() - void saveEdit() - } - }} - /> - ) - : {row.preview}} -
    + {queue.length > 1 && ( + + )} + {(queue.length === 1 || !collapsed) && ( +
      + {queue.map(row => ( +
    • {editing?.id === row.id ? ( - <> - - - + { setEditing({ id: row.id, text: event.currentTarget.value }) }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setEditing(null) + return + } + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void saveEdit() + } + }} + /> ) - : ( - <> - - - - )} -
    -
  • - ))} -
+ : {row.preview}} +
+ {editing?.id === row.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+ + ))} + + )}
) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 8d0614d2e9..67c6ce7c45 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** * QueueDock rendering and operations: authoritative rows, inline editing, - * removal, failure notices, and live retirement. + * collapse state, removal, failure notices, and live retirement. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' @@ -78,16 +78,40 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) + it('renders one row directly and defaults multiple rows to a collapsible count header', () => { + const single = snapshotWith([row('i-1', 'one')]) + const source = liveSession(single) + const view = render() + expect(view.queryByRole('button', { name: '1 Queued' })).toBeNull() + expect(view.getByText('one')).toBeTruthy() + + act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) }) + const header = view.getByRole('button', { name: '2 Queued' }) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('one')).toBeNull() + expect(view.queryByText('two')).toBeNull() + + fireEvent.click(header) + expect(header.getAttribute('aria-expanded')).toBe('true') + expect(view.getByText('one')).toBeTruthy() + expect(view.getByText('two')).toBeTruthy() + + fireEvent.click(header) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('one')).toBeNull() + }) + it('renders active actions and disables editing for mixed-content rows', () => { const snap = snapshotWith([ row('i-1', '第一条排队消息'), row('i-2', null, 'image [image]'), ]) const source = liveSession(snap) - const { container } = render() + const { container, getByRole } = render() + fireEvent.click(getByRole('button', { name: '2 Queued' })) expect([...container.querySelectorAll('li')].map(item => item.textContent)) .toEqual(['第一条排队消息', 'image [image]']) - expect(container.querySelectorAll('button')).toHaveLength(4) + expect(container.querySelectorAll('button')).toHaveLength(5) expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2) expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2) expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0) @@ -162,10 +186,11 @@ describe('QueueDock', () => { const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')]) const source = liveSession(snap) const updateQueue = vi.fn(() => Promise.resolve()) - const { getAllByLabelText } = render( + const { getAllByLabelText, getByRole } = render( , ) + fireEvent.click(getByRole('button', { name: '2 Queued' })) fireEvent.click(getAllByLabelText('删除排队消息')[0]!) await waitFor(() => { expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' }) From 997932ffd6646dfd8f4bb861430c8399722d4600 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 30 Jul 2026 17:00:58 +0800 Subject: [PATCH 39/97] feat(ui): refine trajectory timeline interaction --- .../src/client/TrajectoryTable.tsx | 20 +- .../src/client/TrajectoryTimeline.module.css | 29 ++- .../src/client/TrajectoryTimeline.tsx | 212 ++++++++++++++---- .../src/client/TrajectoryView.tsx | 19 +- .../client/ui-trajectory/tests/views.spec.tsx | 130 +++++++++++ 5 files changed, 360 insertions(+), 50 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..82769b3d6f 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, @@ -225,6 +225,8 @@ export interface TrajectoryTableProps { onSelectedIndexChange?: (index: number | null) => void /** Report a direct user selection from a ledger row. */ onRecordSelect?: (index: number) => void + /** One externally requested record selection; a new object repeats the request. */ + recordSelection?: { readonly index: number } | null /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ @@ -1397,6 +1399,7 @@ export function TrajectoryTable({ searchMatchIndexes = null, onSelectedIndexChange, onRecordSelect, + recordSelection = null, onClearSelection, collapsedTurns, onToggleTurn, @@ -1410,11 +1413,12 @@ export function TrajectoryTable({ const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) + const appliedRecordSelection = useRef(null) const tabHistory = useRef>(new Set(['overview'])) useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const allRecords = flattenRecords(turns) + const allRecords = useMemo(() => flattenRecords(turns), [turns]) const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const records = searchMatchIndexes === null ? collapseAssistantRecords( @@ -1531,7 +1535,7 @@ export function TrajectoryTable({ onClearSelection?.() } - const selectRecord = (index: number) => { + const selectRecord = useCallback((index: number) => { const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) @@ -1541,7 +1545,15 @@ export function TrajectoryTable({ const available = new Set(tabs.map(tab => tab.id)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) setActiveTab(recent ?? tabs[0]?.id ?? 'overview') - } + }, [allRecords, onRecordSelect]) + useEffect(() => { + if ( + recordSelection === null + || appliedRecordSelection.current === recordSelection + ) return + appliedRecordSelection.current = recordSelection + selectRecord(recordSelection.index) + }, [recordSelection, selectRecord]) const selectRequest = ( request: SelectedRequest, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 734b2f3326..7d4fae9cad 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -70,16 +70,29 @@ .lanes { position: absolute; z-index: 2; - inset: 7px 0; + top: 7px; + bottom: 7px; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); } .turnBoundaries { position: absolute; z-index: 3; - inset: 0; + top: 0; + bottom: 0; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); pointer-events: none; } +@media (prefers-reduced-motion: no-preference) { + .lanes[data-animate-viewport='true'], + .turnBoundaries[data-animate-viewport='true'] { + transition: left 180ms ease-out; + } +} + .turnBoundary { position: absolute; top: 0; @@ -142,6 +155,18 @@ opacity: 0.2; } +.span[data-hovered='true']:not([data-current='true']) { + z-index: 1; + opacity: 0.78; + box-shadow: + 0 0 0 1px var(--dsw-alias-bg-layer-2), + 0 0 0 2px color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 80%, + transparent + ); +} + .span[data-current='true'] { z-index: 1; opacity: 1; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 8ad1afefc1..58270f36e7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css' const MINIMUM_DRAG_PX = 3 const MINIMUM_ZOOM_OPERATIONS = 4 +const EDGE_PAN_ZONE_FRACTION = 0.08 +const EDGE_PAN_STEP_FRACTION = 0.025 +const MAXIMUM_EDGE_PAN_PX = 32 interface FractionRange { start: number end: number } +interface HoverPoint { + fraction: number + recordIndex: number | null +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps { /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet | null onRangeChange: (range: TrajectoryTimeRange | null) => void + /** Select a directly clicked timeline block. */ + onRecordSelect?: (index: number) => void + /** Bring the nearest record into view after clicking timeline whitespace. */ onRecordFocus?: (index: number) => void } @@ -41,11 +52,16 @@ function clampFraction(value: number): number { return Math.min(1, Math.max(0, value)) } -function centeredRange(center: number, width: number): FractionRange { - const clampedWidth = Math.min(1, Math.max(0, width)) +function centeredRange( + center: number, + width: number, + minimum: number, + maximum: number, +): FractionRange { + const clampedWidth = Math.min(maximum - minimum, Math.max(0, width)) const start = Math.min( - Math.max(center - clampedWidth / 2, 0), - 1 - clampedWidth, + Math.max(center - clampedWidth / 2, minimum), + maximum - clampedWidth, ) return { start, end: start + clampedWidth } } @@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ selectedIndex = null, searchMatchIndexes = null, onRangeChange, + onRecordSelect, onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) @@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ )), [turns], ) - const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) - const [draft, setDraft] = useState(null) - const [hover, setHover] = useState(null) + const dragRef = useRef<{ + pointerId: number + anchorTime: number + anchorClientX: number + recordIndex: number | null + } | null>(null) + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) + const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { if ( model !== null @@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }, [model, onRangeChange, range]) useEffect(() => { if (model === null) return + setAnimateViewport(false) setViewport(current => current !== null && (current.end < model.start || current.start > model.end) ? null : current) }, [model]) + useEffect(() => { + if (model === null || selectedIndex === null) return + const selectedSpan = model.spans.find(span => span.index === selectedIndex) + if (selectedSpan === undefined) return + setAnimateViewport(true) + setViewport((current) => { + if (current === null) return current + if ( + selectedSpan.end > current.start + && selectedSpan.start < current.end + ) return current + const duration = Math.max(1, current.end - current.start) + const desiredStart = selectedSpan.end <= current.start + ? selectedSpan.start + : selectedSpan.end - duration + const nextStart = Math.min( + Math.max(desiredStart, model.start), + Math.max(model.start, model.end - duration), + ) + if (nextStart === current.start) return current + return { start: nextStart, end: nextStart + duration } + }) + }, [model, selectedIndex]) const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) const viewportDuration = Math.min( fullDuration, @@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const projectedDomainStyle = model === null + ? undefined + : { + '--trajectory-domain-left': + `${-(domainStart - model.start) / domainDuration * 100}%`, + '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`, + } as CSSProperties const committed = model === null || range === null ? null : rangeFraction(range, domainStart, domainDuration) - const visibleRange = draft ?? committed - const activeRange = draft === null - ? range - : { - start: domainStart + draft.start * domainDuration, - end: domainStart + draft.end * domainDuration, - } + const draftFraction = model === null || draft === null + ? null + : rangeFraction(draft, domainStart, domainDuration) + const visibleRange = draftFraction ?? committed + const activeRange = draft ?? range if (model === null) { return ( @@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) } - const minimumSelectionFraction = Math.min( - 1, - fullDuration / domainDuration / model.spans.length, + const minimumSelectionDuration = Math.min( + domainDuration, + fullDuration / model.spans.length, ) const fractionAt = (event: PointerEvent): number => { @@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) } - const commit = (fraction: FractionRange) => { - onRangeChange({ - start: domainStart + fraction.start * domainDuration, - end: domainStart + fraction.end * domainDuration, - }) + const recordIndexAt = (event: PointerEvent): number | null => { + const target = event.target instanceof HTMLElement ? event.target : null + const value = target?.closest('[data-timeline-record-index]') + ?.dataset.timelineRecordIndex + if (value === undefined) return null + const index = Number(value) + return Number.isFinite(index) ? index : null + } + + const commit = (nextRange: TrajectoryTimeRange) => { + onRangeChange(nextRange) } const onPointerDown = (event: PointerEvent) => { if (event.button !== 0) return - const rect = event.currentTarget.getBoundingClientRect() const anchor = fractionAt(event) - setHover(anchor) - dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } + const anchorTime = domainStart + anchor * domainDuration + const recordIndex = recordIndexAt(event) + setHover({ fraction: anchor, recordIndex }) + dragRef.current = { + pointerId: event.pointerId, + anchorTime, + anchorClientX: event.clientX, + recordIndex, + } if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId) } - setDraft({ start: anchor, end: anchor }) + setDraft({ start: anchorTime, end: anchorTime }) } const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current + const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) - setHover(fraction) + setHover({ fraction, recordIndex: recordIndexAt(event) }) if (drag === null || drag.pointerId !== event.pointerId) return - setDraft(orderedRange(drag.anchor, fraction)) + let nextDomainStart = domainStart + if (viewport !== null) { + const localX = event.clientX - rect.left + const edgeWidth = Math.min( + MAXIMUM_EDGE_PAN_PX, + Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION), + ) + const direction = localX < edgeWidth + ? -1 + : localX > rect.width - edgeWidth ? 1 : 0 + if (direction !== 0) { + const edgeDistance = direction < 0 + ? edgeWidth - localX + : localX - (rect.width - edgeWidth) + const strength = clampFraction(edgeDistance / edgeWidth) + const desiredStart = domainStart + + direction * domainDuration * EDGE_PAN_STEP_FRACTION + * Math.max(0.2, strength) + nextDomainStart = Math.min( + Math.max(desiredStart, model.start), + model.end - domainDuration, + ) + if (nextDomainStart !== domainStart) { + setAnimateViewport(false) + setViewport({ + start: nextDomainStart, + end: nextDomainStart + domainDuration, + }) + } + } + } + const pointTime = nextDomainStart + fraction * domainDuration + setDraft(orderedRange(drag.anchorTime, pointTime)) } const onPointerEnd = (event: PointerEvent) => { const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return - const point = fractionAt(event) - const selected = orderedRange(drag.anchor, point) - setHover(point) + const pointFraction = fractionAt(event) + const pointTime = domainStart + pointFraction * domainDuration + const selected = orderedRange(drag.anchorTime, pointTime) + setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) }) dragRef.current = null setDraft(null) - const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX - const committedRange = selected.end - selected.start < minimumSelectionFraction + const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX + const clickedSpan = click && drag.recordIndex !== null + ? model.spans.find(span => span.index === drag.recordIndex) + : undefined + if (clickedSpan !== undefined) { + onRangeChange(null) + onRecordSelect?.(clickedSpan.index) + return + } + const committedRange = selected.end - selected.start < minimumSelectionDuration ? centeredRange( click ? selected.start : (selected.start + selected.end) / 2, - minimumSelectionFraction, + minimumSelectionDuration, + model.start, + model.end, ) : selected commit(committedRange) if (click) { - const timelinePoint = domainStart + selected.start * domainDuration + const timelinePoint = selected.start const nearest = model.spans.reduce((candidate, span) => { const candidateDistance = timelinePoint < candidate.start ? candidate.start - timelinePoint @@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onWheel = (event: WheelEvent) => { event.preventDefault() + setAnimateViewport(false) const rect = event.currentTarget.getBoundingClientRect() const anchorFraction = clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) @@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onWheel={onWheel} onContextMenu={(event) => { event.preventDefault() + setAnimateViewport(false) onRangeChange(null) setViewport(null) }} > - {hover !== null && draft === null && ( + {hover !== null && hover.recordIndex === null && draft === null && (