From d4614f92d658c30b36835549776f8acc409eb7a3 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 11:59:52 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 6777e4fab97315c398f676eb37b9416c933c435d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:14:03 +0800 Subject: [PATCH 08/12] fix(fs-search): make glob sampling an explicit choice --- .../2026-07-27-glob-sampling.i18n.yaml | 4 +- .../bug-fix/2026-07-27-glob-sampling.md | 16 ++++---- .../bug-fix/2026-07-27-glob-sampling.zh.md | 16 ++++---- apps/cli/config/base.cordis.yml | 2 + docs/config-catalog.md | 4 +- docs/tool-catalog.md | 4 +- .../tests/fs-search.cordis.snapshot.yml | 1 + examples/acp-agent/tests/fs-search.cordis.yml | 1 + packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 31 ++++++++------ packages/fs/tool-fs-search/README.zh.md | 31 ++++++++------ packages/fs/tool-fs-search/src/glob.ts | 41 +++++++++++++------ packages/fs/tool-fs-search/src/index.ts | 6 ++- .../tool-fs-search/tests/integration.spec.ts | 2 +- .../fs/tool-fs-search/tests/load-path.spec.ts | 2 +- .../fs/tool-fs-search/tests/tools.spec.ts | 38 +++++++++++++++-- scripts/gen-tool-catalog.ts | 4 +- 17 files changed, 140 insertions(+), 67 deletions(-) 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 7c11db5207..6a898fa0a4 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: b9dfc7fc89ff61824094ccd5297f766c665f4edb -2026-07-27-glob-sampling.zh.md: 4f022467e461247c746e906b32f5ecffcb885d56 +2026-07-27-glob-sampling.md: 67912cf290b127a96819d57f30387197af68323d +2026-07-27-glob-sampling.zh.md: e339a5f87b483849df60feb726b061fe85074300 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 b9dfc7fc89..67912cf290 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 @@ -12,15 +12,17 @@ Three individually valid behaviors composed into the false impression. A glob wi ## 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`. +A result that fits within `globMaxResults` remains complete and byte-for-byte modification-time ordered. The required `sampleOverCapGlobResults` config has no fallback: `false` retains the modification-time head for an over-cap result, while `true` samples round-robin across the complete result's top-level entries. In sampling mode, every entry receives one slot before any receives a second, exhausted groups drop out, relative order remains stable within each group, and 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`. +In sampling mode, the footer states that the page is a cross-entry sample rather than the modification-time head and reports how many top-level entries it reaches when that fact adds information. When more top-level entries exist than inline slots, it tells the model to narrow `path`. Head mode keeps the ordinary capped-result footer. When spill succeeds, both modes preserve the complete sorted list in the artifact. -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. +The prompt and schema state the configured over-cap ordering, that a pattern without `/` matches at any depth, and that glob returns files, never directory entries. The shipped CLI composition explicitly selects head mode; deployments that want representative capped pages select sampling mode. 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. +**Keep the modification-time head as the only behavior.** Rejected after measuring the failure shape. Some deployments need the stable ordering, but a deployment that values workspace orientation can explicitly select representative data instead of asking the model to distrust the only paths it received. + +**Give the sampling choice a default.** Rejected. No product-wide evidence establishes either ordering as the implicit contract, so every composition selects one and misconfiguration fails at load. **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. @@ -36,10 +38,10 @@ The prompt and schema also state that a pattern without `/` matches at any depth ## 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. +A sampling-mode over-cap 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. Head mode retains the concentration risk as an explicit deployment trade-off. -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. +The tool surface does not grow. Every composition must set `sampleOverCapGlobResults`; changing it alters glob's prompt, schema description, and over-cap Native rendering. The canonical output keeps `root` so sampling mode can recover its grouping basis, while fitting results remain 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 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. +Package tests pin the required config, both over-cap modes, their prompt and schema descriptions, 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 explicitly enables sampling, 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 4f022467e4..e339a5f87b 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 @@ -12,15 +12,17 @@ Status: implemented ## 决策 -未超过 `globMaxResults` 的结果仍保持完整,且按修改时间排序的内容逐字节不变。超过上限时,系统会在完整结果的顶层条目之间按轮转方式采样:每个条目都先获得一个位置,之后才有条目获得第二个位置;已经用尽的分组会退出轮转;各组内部的相对顺序保持稳定。分组始终以实际搜索根为基准,显式指定 `path` 时也如此。 +未超过 `globMaxResults` 的结果仍保持完整,且按修改时间排序的内容逐字节不变。必填的 `sampleOverCapGlobResults` 配置没有回退值:`false` 会为超过上限的结果保留按修改时间排序的前部,`true` 则会在完整结果的顶层条目之间按轮转方式采样。采样模式下,每个条目都先获得一个位置,之后才有条目获得第二个位置;已经用尽的分组会退出轮转;各组内部的相对顺序保持稳定;分组以实际搜索根为基准,显式指定 `path` 时也如此。 -footer 会说明当前页面是跨条目的样本,而不是按修改时间排序的前部;当顶层条目覆盖数能提供额外信息时,还会报告该数量;完整排序列表仍保存在 spill 产物中。若顶层条目数量超过内联位置数,footer 会要求模型缩小 `path`。 +采样模式下,footer 会说明当前页面是跨条目的样本,而不是按修改时间排序的前部;当触达的顶层条目数能提供额外信息时,还会报告该数量。若顶层条目数量超过内联位置数,footer 会要求模型缩小 `path`。保留前部的模式沿用达到上限时的普通 footer。spill 成功时,两种模式都会在该产物中保留完整排序列表。 -提示词与 schema 还会说明:不含 `/` 的模式会匹配任意深度,glob 只返回文件,绝不返回目录条目。在向模型暴露 bash 工具的部署中,目录定位仍由普通 shell 操作完成:查看一个目录使用 `ls`,跨目录树按指定文件路径模式查找则使用 glob。skill(技能)发现流程仍将 `ctx.fs.listDir` 作为内部提供方原语使用;本决策不会新增面向模型的 `list` 工具。 +提示词与 schema 会说明配置所指定的超限结果排序方式、不含 `/` 的模式会匹配任意深度,以及 glob 只返回文件而绝不返回目录条目。随产品交付的 CLI(命令行界面)组合显式选择保留前部的模式;希望达到上限的页面具有代表性的部署则选择采样模式。在向模型暴露 bash 工具的部署中,目录定位仍由普通 shell 操作完成:查看一个目录使用 `ls`,跨目录树按指定文件路径模式查找则使用 glob。skill(技能)发现流程仍将 `ctx.fs.listDir` 作为内部提供方原语使用;本决策不会新增面向模型的 `list` 工具。 ## 考虑过的替代方案 -**保留按修改时间排序的前部,只警告结果过于集中。** 测量实际故障形态后否决。警告只会要求模型怀疑自己拿到的唯一一批路径;具有代表性的数据能直接修正答案。 +**只保留按修改时间排序的前部。** 测量实际故障形态后否决。某些部署需要这种稳定排序;但重视工作区定位的部署可以显式选择具有代表性的数据,而不必要求模型怀疑自己拿到的唯一一批路径。 + +**为采样选项提供默认值。** 否决。没有全产品范围的证据支持把任一排序作为隐式契约,因此每个组合都必须选择一种,配置错误则在加载时失败。 **对所有结果采样。** 否决。完整结果没有因截断损失任何信息,因此按修改时间排序仍有助于回答关注新旧时间的问题。只有当截取前部已经无法描述整体时,才开始采样。 @@ -36,10 +38,10 @@ footer 会说明当前页面是跨条目的样本,而不是按修改时间排 ## 影响 -超过上限的 glob 页面无法再根据内联路径回答按时间判断新旧的问题;footer 会明确说明这一点,spill 产物仍保留完整的排序视图。采样只平衡搜索根下的第一路径段,因此某个顶层条目内部较深处、结果密集的子树仍可能占据主导。 +采样模式下,超过上限的 glob 页面无法再根据内联路径回答按时间判断新旧的问题;footer 会明确说明这一点,spill 产物仍保留完整的排序视图。采样只平衡搜索根下的第一路径段,因此某个顶层条目内部较深处、结果密集的子树仍可能占据主导。保留前部的模式则把集中风险作为显式部署取舍保留下来。 -工具接口不会扩大。此修复会更改 glob 的提示词、schema 描述、规范输出(`root` 记录采样基准)以及超过上限时的 Native 渲染,未超过上限的结果保持不变。 +工具接口不会扩大。每个组合都必须设置 `sampleOverCapGlobResults`;更改该值会改变 glob 的提示词、schema 描述以及超过上限时的 Native 渲染。规范输出保留 `root`,以便采样模式恢复其分组基准;未超过上限的结果保持不变。 ## 测试 -包测试锁定了结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会启动最小化的真实 Loader/app/local-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 +包测试锁定了必填配置、两种超过上限模式及其提示词和 schema 描述、结果集中与扁平两种情况、显式根目录、分组数超过 JavaScript 参数个数上限、分组耗尽、位置数少于分组数,以及工作目录以外的路径。`fs-glob-sampling` ACP(Agent Client Protocol)场景会显式启用采样,启动最小化的真实 Loader/app/local-bash 组合,并让真实搜索插件对接确定性的 `rg` 进程 fixture(测试前置数据);其结果覆盖 4 个顶层条目,而不是只返回某棵子树的前部。 diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 67c2b3b551..bc8c3434de 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -102,6 +102,8 @@ - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false - id: workspace-context name: '@deepseek-ai/dsh-workspace-context' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dde2d6f65b..98b798b9b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1664,8 +1664,10 @@ Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index Requires: `tools` · `systemPrompt` · `bash` ```ts config-catalog -/** Plugin config (all optional — `Config` supplies the defaults). */ +/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ export interface Config { + /** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */ + sampleOverCapGlobResults: boolean /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ globMaxResults?: number /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index bac13da635..7d6fa79dea 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@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-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). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. 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. | | `@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. | @@ -524,7 +524,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). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. 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` diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index e1755ec9d4..141691a087 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -31,4 +31,5 @@ - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' config: + sampleOverCapGlobResults: true globMaxResults: 4 diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 171ab88980..153128f914 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -29,4 +29,5 @@ - id: tool-fs-search name: '@deepseek-ai/dsh-tool-fs-search' config: + sampleOverCapGlobResults: true globMaxResults: 4 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 2d11c1356a..bedb8289c1 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: 51b3fa5385330cdaba0b36dd71a6efe4fd3d0db5 -README.zh.md: de9e50c90724cbc40f6de5880e7000ae0552b52d +README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4 +README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 51b3fa5385..b12ffda986 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -5,9 +5,9 @@ English | [中文](README.zh.md) The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check -// Default deployment: a bash executor whose PATH includes rg, then the discovery tools. +// A deployment chooses how over-cap glob pages are selected. await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local -await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep +await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` @@ -20,11 +20,12 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin ## Config -All keys are optional; the defaults are the shipped search caps. +`sampleOverCapGlobResults` is required and has no fallback; deployments choose the over-cap ordering contract explicitly. The remaining keys are optional search caps with the defaults below. | Key | Default | Meaning | |---|---|---| -| `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. | +| `sampleOverCapGlobResults` | none (required) | `true` samples an over-cap `glob` page across top-level entries; `false` keeps the modification-time-ordered head. When formatted spill succeeds, both modes preserve the complete sorted list in that artifact. | +| `globMaxResults` | `100` | Max paths one `glob` call shows inline (matches Claude Code's `GlobTool` limit). A result within the cap remains complete and modification-time ordered. | | `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,14 +35,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 directory entries. 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; `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. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. | | `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 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`. +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 }`; when sampling is enabled, `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 the configured 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 @@ -55,12 +56,18 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. -##### Glob guidance +##### Glob guidance with `sampleOverCapGlobResults: true` ```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 entries, so it spans the tree instead of one subtree. ``` +##### Glob guidance with `sampleOverCapGlobResults: false` + +```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 keeps the modification-time-ordered head. +``` + ##### Grep guidance ```markdown @@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### Token effect -Fixed guidance cost per request while the tools are registered. +Fixed guidance cost per request while the tools are registered; the required sampling choice selects one glob variant. #### KV Cache effect -Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. +Prefix-stable while the plugin scope, sampling choice, and guidance text are unchanged. Activation, disposal, or changing the choice may invalidate reuse from this prompt section. ### Tool schemas #### What the model sees -The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible. +The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds. #### Token effect @@ -93,7 +100,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 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. +`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. With `sampleOverCapGlobResults: true`, an over-cap `glob` page takes paths round-robin across entries immediately beneath the actual search root, 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`. With `false`, the page is the modification-time-ordered head and keeps the plain capped-result footer. A result that fits inline is untouched, and a flat sampled result also keeps the plain footer because its sample equals the modification-time head. The spill artifact always holds the complete list in modification-time order. #### Token effect @@ -122,4 +129,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 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. +- **Sampling, when enabled, 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 de9e50c907..7dd6cdf9a2 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -5,9 +5,9 @@ **面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check -// Default deployment: a bash executor whose PATH includes rg, then the discovery tools. +// A deployment chooses how over-cap glob pages are selected. await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local -await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep +await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` @@ -20,11 +20,12 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 配置 -所有键均为可选;默认值是随产品交付的搜索上限。 +`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余键是可选的搜索上限,默认值如下。 | 键 | 默认值 | 含义 | |---|---|---| -| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过时结果整体按修改时间展示;超过时内联页面改为跨顶层条目取样,完整的排序列表写入格式化 spill 产物。 | +| `sampleOverCapGlobResults` | 无(必填) | `true` 会在顶层条目之间对超过上限的 `glob` 页面采样;`false` 保留按修改时间排序的前部。格式化 spill 成功时,两种模式都会在该产物中保留完整排序列表。 | +| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过上限的结果保持完整,并按修改时间排序。 | | `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,14 +35,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 保留 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 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 | | `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` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `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`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 @@ -55,12 +56,18 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- 加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 -##### Glob 指导 +##### 启用 `sampleOverCapGlobResults: true` 时的 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 entries, so it spans the tree instead of one subtree. ``` +##### 启用 `sampleOverCapGlobResults: false` 时的 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 keeps the modification-time-ordered head. +``` + ##### Grep 指导 ```markdown @@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### Token 影响 -工具注册期间,每个请求支付固定指导成本。 +工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体。 #### KV Cache 影响 -只要插件作用域和指导文本不变,前缀就保持稳定。启用或 dispose(资源释放)可能从该提示词段开始使复用失效。 +只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能从该提示词段开始使复用失效。 ### 工具 schema #### 模型看到的内容 -当前接口可见时,公开已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search);前提是加载时 `rg` 探测成功。 +glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见。 #### Token 影响 @@ -93,7 +100,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 会要求模型缩小 `path`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer,因为此时取样结果就是按修改时间排序的头部。spill 产物始终保存按修改时间排序的完整列表。 +`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;若无法触达全部条目,footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平的采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。 #### Token 影响 @@ -122,4 +129,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 12af97a24b..c9806b7bd8 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -38,6 +38,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz /** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */ export interface GlobToolCaps { + /** Whether over-cap pages are sampled across top-level entries instead of taking the modification-time head. */ + sampleOverCapGlobResults: boolean /** Max paths retained inline; later paths go to the formatted spill file. */ maxResults: number /** Cap on the complete raw `rg` stdout the tool will parse. */ @@ -183,24 +185,32 @@ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, * @returns the model-facing text. */ 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.' 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 ? ' Narrow path to inspect a specific subtree.' : '') - return `${body}\n\n(Showing ${sample.items.length} of ${seen} paths${basis} ${recovery})` + return formatGlobPage(sample.items, seen, spillRef, basis) +} + +/** Format one bounded page and the recovery path for its complete sorted result. */ +function formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined, basis: string): string { + const body = 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 ${items.length} of ${seen} paths${basis} ${recovery})` } /** 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 { +function renderGlobPaths(paths: string[], caps: GlobToolCaps, 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, root), paths.length, spillRef) + if (paths.length <= caps.maxResults) return paths.join('\n') + if (!caps.sampleOverCapGlobResults) { + return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, '.') + } + return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef) } /** @@ -222,19 +232,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener * @param caps - the deployment's resolved glob caps (plugin config after defaulting). */ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { + const overCapGuidance = caps.sampleOverCapGlobResults + ? 'while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.' + : 'while a larger one keeps the modification-time-ordered head.' ctx.systemPrompt.section({ 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 entries, ' - + 'so it spans the tree instead of one subtree.', + + `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`, }) + const overCapDescription = caps.sampleOverCapGlobResults + ? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries` + : `a larger result returns the first ${caps.maxResults} paths in modification-time order` 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 entries, ` + + `Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, ` + 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.', parameters: { pattern: { @@ -255,7 +270,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { paths: { type: 'array', required: true, items: { type: 'string' } }, }, }, - render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults, value.root) }], + render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }], }, async execute(args, exec) { const input = parseGlobArgs(args) @@ -284,7 +299,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n')) return { kind: 'accept', - content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, value.root, spillRef) }], + content: [{ type: 'text', text: renderGlobPaths(paths, caps, value.root, spillRef) }], ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, } }) diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index b6ae6ebbad..eea213a938 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -58,8 +58,10 @@ export const name = 'tool-fs-search' /** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */ export const inject = ['tools', 'systemPrompt', 'bash'] -/** Plugin config (all optional — `Config` supplies the defaults). */ +/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ export interface Config { + /** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */ + sampleOverCapGlobResults: boolean /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ globMaxResults?: number /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ @@ -73,6 +75,7 @@ export interface Config { } export const Config: z = z.object({ + sampleOverCapGlobResults: z.boolean().required(), globMaxResults: z.number().default(GLOB_MAX_RESULTS), grepMaxMatches: z.number().default(GREP_MAX_MATCHES), grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), @@ -137,6 +140,7 @@ export async function apply(ctx: Context, config: Config): Promise { return } applyGlobTool(ctx, { + sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, rawOutputMaxBytes: resolved.rawOutputMaxBytes, timeoutMs: resolved.timeoutMs, diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 6be9629460..8cb96e7e66 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -64,7 +64,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) - await ctx.plugin(ToolFsSearch) + await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }) afterEach(async () => { diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 0f72529567..71022720fc 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -82,7 +82,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters[0] // A collapsed export shape (dropped inject) would throw "without inject" here. - const fiber = await ctx.plugin(unwrapped) + const fiber = await ctx.plugin(unwrapped, { sampleOverCapGlobResults: true }) expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep'])) await fiber.dispose() }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index ccb3af5b80..da273ec2a2 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -111,12 +111,14 @@ class FakeSpill extends SpillStore { } interface SetupOptions { - config?: ToolFsSearch.Config + config?: Partial spill?: boolean probeError?: Error probeResult?: BashRunResult } +const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config + async function setup(options: SetupOptions = {}) { const ctx = new Context() const warnings: string[] = [] @@ -128,7 +130,7 @@ async function setup(options: SetupOptions = {}) { if (options.probeResult) bash.probeResult = options.probeResult if (options.probeError) bash.probeError = options.probeError if (options.spill === true) await ctx.plugin(FakeSpill) - const fiber = await ctx.plugin(ToolFsSearch, options.config) + const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config }) const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined return { ctx, bash, spill, fiber, warnings } } @@ -216,7 +218,7 @@ describe('registration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFsSearch) // no bash executor + await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -241,9 +243,27 @@ describe('registration', () => { expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000) expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000) }) + + it('describes the modification-time head when over-cap sampling is disabled', async () => { + const { ctx } = await setup({ config: { sampleOverCapGlobResults: false } }) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('a larger one keeps the modification-time-ordered head') + expect(prompt).not.toContain('sampled across top-level entries') + const glob = ctx.tools.schemas().find(schema => schema.name === 'glob') + expect(glob?.description).toContain('a larger result returns the first 100 paths in modification-time order') + expect(glob?.description).not.toContain('sampled across top-level entries') + }) }) describe('config validation', () => { + it('requires an explicit over-cap glob sampling choice', () => { + expect(() => new ToolFsSearch.Config()).toThrow(/sampleOverCapGlobResults/) + expect(new ToolFsSearch.Config({ sampleOverCapGlobResults: false })).toMatchObject({ + sampleOverCapGlobResults: false, + globMaxResults: 100, + }) + }) + it.each([ ['globMaxResults', { globMaxResults: 0 }], ['grepMaxMatches', { grepMaxMatches: -1 }], @@ -255,7 +275,7 @@ describe('config validation', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeBash) - await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) + await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) }) @@ -625,6 +645,16 @@ describe('glob results', () => { + 'The complete result could not be saved; narrow pattern or path to see more.)') }) + it('keeps the modification-time head when over-cap sampling is disabled', async () => { + const { ctx, bash } = await setup({ + config: { globMaxResults: 3, sampleOverCapGlobResults: false }, + }) + bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n')) + expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) + .toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n' + + '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)') + }) + 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([ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index b092154b21..a77bdb6d95 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -273,10 +273,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ // never depends on the host PATH. `ctx.spillStore` is optional (read via // ctx.get) and does not affect the schemas, so no spill backend is mounted. await ctx.plugin(CatalogSearchBashExecutor) - await ctx.plugin(ToolFsSearch) + await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }, 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). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. 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 30cda487eafd17ee5ee1d77b294f3e3fd7642e59 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 21:34:15 +0800 Subject: [PATCH 09/12] fix(cli): map @deepseek-ai/dsh-tui/prompt to source in tsconfig paths The tui.cordis.yml entry for @deepseek-ai/dsh-tui/prompt had no tsconfig paths mapping: the @deepseek-ai/dsh-* wildcard substitutes tui/prompt whole into nonexistent candidates, so the tsx source launch fell back to package exports and required built lib/prompt.js. pnpm dsh failed at startup on every clean tree (fresh worktrees) with 'plugin(s) failed to load'. --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index 527dc44758..801ed2dc66 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"], + "@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], From 37116d0b0adae58e7e6a5b279e8004e9eb7d81a5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 21:34:23 +0800 Subject: [PATCH 10/12] test(scripts): gate source-plane resolution of configured plugins verify-cordis-config now requires every configured specifier of a local workspace package to resolve through the tsconfig.base.json paths facade to a .ts/.tsx source file. A failed resolution or a .d.ts hit (the exports fallback into built lib/types) fails the gate, so a missing paths mapping is a red gate instead of a clean-tree-only startup crash masked by built trees in CI. Removing the dsh-tui/prompt mapping reproduces the failure. Agent Note records the decision and alternatives. --- ...fig-source-plane-resolution-gate.i18n.yaml | 6 +++ ...dis-config-source-plane-resolution-gate.md | 27 ++++++++++ ...-config-source-plane-resolution-gate.zh.md | 27 ++++++++++ scripts/verify-cordis-config.ts | 52 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 .agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md create mode 100644 .agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.i18n.yaml new file mode 100644 index 0000000000..9b7308098b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.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/process/2026-07-30-cordis-config-source-plane-resolution-gate.md +2026-07-30-cordis-config-source-plane-resolution-gate.md: f9070d39559948ef27f96df5afccd7c4e076f131 +2026-07-30-cordis-config-source-plane-resolution-gate.zh.md: fac6c4047d334d7dd0685aa270234fee3d15dba8 diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md new file mode 100644 index 0000000000..f9070d3955 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.md @@ -0,0 +1,27 @@ +# Agent Note: verify-cordis-config gates source-plane resolution of configured plugins + +Status: implemented + +English | [中文](2026-07-30-cordis-config-source-plane-resolution-gate.zh.md) + +## Problem + +`apps/cli/config/tui.cordis.yml` gained the `@deepseek-ai/dsh-tui/prompt` entry without a matching tsconfig `paths` mapping. The generic `@deepseek-ai/dsh-*` wildcard substitutes `tui/prompt` whole into its `/*/src` candidates, none of which exist, so the [tsx source launch](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) fell back to package `exports` and resolved `lib/prompt.js` — an artifact-plane file. Every environment with a built `lib/` (developer trees after `pnpm build`) booted fine, and the e2e workflow runs the keyless TUI PTY smoke in `lib` mode (`DSH_EXAMPLE_MODE=lib`, built bin under plain Node) so CI never exercises the source vector at all — while every clean checkout failed `pnpm dsh` at startup with `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`. No gate checked the source plane, so the breakage shipped silently and surfaced only in fresh worktrees. + +## Decision + +`scripts/verify-cordis-config.ts` (`validateSourcePlaneResolution`) requires every configured specifier of a local workspace package — harness packages and vendored Cordis alike — to resolve through the `tsconfig.base.json` `paths` facade to a `.ts`/`.tsx` source file, using `ts.resolveModuleName` from the repository root. A failed resolution or a `.d.ts` hit (the `exports` fallback into built `lib/types`) fails `verify-cordis-config`, naming the config files and the specifier. The missing `@deepseek-ai/dsh-tui/prompt` mapping is added next to the other explicit subpath entries; removing it reproduces the gate failure. + +## Alternatives considered + +**Rely on the keyless TUI PTY smoke.** In default source mode it boots the real tree through the source vector and does catch the failure — but only on a clean tree. CI's e2e workflow runs it exclusively in `lib` mode (the built bin resolving real package `exports`), so no CI line runs the source vector, and developer trees with a stale `lib/` stay masked locally. Adding a source-mode CI smoke proves one composition per run; the static gate covers every shipped and example config. + +**Broaden the `dsh-source-launch-smoke` compat test to full boot.** The node-compat smoke asserts only the TTY refusal, which happens before plugin loading. A full keyless boot per matrix line duplicates the PTY smoke at higher cost and, like it, proves one composition rather than every shipped and example config. + +**A `@deepseek-ai/dsh-*/prompt`-style wildcard mapping.** Fixes this one subpath but not the class; the next single-file subpath export (`/surface`, `/message`, …) regresses identically. The static gate covers all current and future configured specifiers. + +## Consequences + +- A configured workspace specifier that resolves only through built `lib/` is now a red `verify-cordis-config` (in `hygiene` and CI) instead of a clean-tree-only startup crash. +- New single-file subpath exports referenced from a cordis.yml need an explicit `tsconfig.base.json` `paths` entry at introduction time; the gate message says so. +- The gate resolves with `tsconfig.base.json` options only; a specifier needing client-only compiler options to resolve would fail it, which matches the facade's role as the single resolution surface for tsx and vitest. diff --git a/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md new file mode 100644 index 0000000000..fac6c4047d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-cordis-config-source-plane-resolution-gate.zh.md @@ -0,0 +1,27 @@ +# Agent Note: verify-cordis-config 对配置中插件的源码面解析实施门禁 + +Status: implemented + +[English](2026-07-30-cordis-config-source-plane-resolution-gate.md) | 中文 + +## 问题 + +`apps/cli/config/tui.cordis.yml` 新增了 `@deepseek-ai/dsh-tui/prompt` 配置项,却没有对应的 tsconfig `paths` 映射。通用的 `@deepseek-ai/dsh-*` 通配符会把 `tui/prompt` 整体代入其 `/*/src` 候选路径,而这些路径全都不存在,因此 [tsx 源码启动](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)会回退到包(package)的 `exports`,解析出产物面文件 `lib/prompt.js`。任何带有已构建 `lib/` 的环境(开发者目录树运行 `pnpm build` 后)都能正常启动,而 e2e 工作流以 `lib` 模式(`DSH_EXAMPLE_MODE=lib`,构建产物 bin 在普通 Node 下运行)执行无密钥 TUI PTY 冒烟测试,因此 CI 根本不会经过源码启动向量——与此同时,所有干净检出环境中的 `pnpm dsh` 都会在启动时失败,并报错 `plugin(s) failed to load: @deepseek-ai/dsh-tui/prompt`。当时没有门禁检查源码面,因此该故障未被发现便进入发布版本,仅在新的 worktree 中暴露。 + +## 决策 + +`scripts/verify-cordis-config.ts`(`validateSourcePlaneResolution`)要求配置中凡是引用本地 workspace 包的模块说明符(包括 harness 包与纳入 vendor 的 Cordis)都必须通过 `tsconfig.base.json` 的 `paths` 外观层(facade)解析到 `.ts`/`.tsx` 源文件;解析以仓库根目录为起点,调用 `ts.resolveModuleName` 完成。解析失败或命中 `.d.ts`(即经 `exports` 回退到构建出的 `lib/types`)都会使 `verify-cordis-config` 失败,并列出配置文件与模块说明符。缺失的 `@deepseek-ai/dsh-tui/prompt` 映射已添加在其他显式子路径条目旁;删除该映射即可复现门禁失败。 + +## 备选方案 + +**依赖无密钥 TUI PTY 冒烟测试。** 在默认源码模式下,该测试通过源码向量启动真实目录树,确实能捕获这个故障,但仅限干净目录树。CI 的 e2e 工作流只以 `lib` 模式运行它(构建产物 bin 通过真实的包 `exports` 解析),因此没有任何 CI 环节执行源码向量,而带有过期 `lib/` 的开发者目录树在本地也仍被掩盖。为 CI 增加一个源码模式冒烟测试,每次也只能证明一种组合;静态门禁则覆盖所有随产品发布的配置与示例配置。 + +**将 `dsh-source-launch-smoke` 兼容性测试扩展为完整启动。** node-compat 冒烟测试只断言 TTY 拒绝,而该拒绝发生在插件加载之前。每条矩阵版本线都执行一次完整的无密钥启动,会以更高成本重复 PTY 冒烟测试,而且同样只能验证一种组合,无法覆盖所有随产品发布的配置与示例配置。 + +**使用类似 `@deepseek-ai/dsh-*/prompt` 的通配符映射。** 这能修复当前子路径,却不能杜绝这一类问题;下一个单文件子路径导出(`/surface`、`/message` 等)仍会以同样方式复发。静态门禁覆盖当前及未来配置中引用的所有模块说明符。 + +## 结果 + +- 配置中的 workspace 模块说明符若只能通过构建后的 `lib/` 解析,现在会导致 `verify-cordis-config` 门禁失败(在 `hygiene` 和 CI 中执行),而不再成为只在干净目录树中出现的启动崩溃。 +- cordis.yml 中引用新的单文件子路径导出时,必须同步为 `tsconfig.base.json` 添加显式 `paths` 条目;门禁消息会明确提示这一要求。 +- 门禁只使用 `tsconfig.base.json` 的选项执行解析;如果某个模块说明符需要仅客户端可用的编译器选项才能解析,门禁就会失败。这符合该外观层作为 tsx 与 vitest 唯一解析入口的定位。 diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 1e0127a826..bda0d5ecfc 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -60,6 +60,7 @@ for (const file of files) { errors.push(...validateExampleResolution()) errors.push(...validateAppResolution()) +errors.push(...validateSourcePlaneResolution()) if (errors.length > 0) { console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') @@ -138,6 +139,57 @@ function validateAppResolution(): string[] { return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } +/** + * Every configured specifier of a local workspace package must resolve through + * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source + * launch (tsx) and vitest resolve in the source plane; without a `paths` match + * they fall back to package `exports`, which reach built `lib/` — present on a + * built dev tree, absent on a clean one — so a missing mapping boots locally + * yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts` + * or `.js` under built `lib/`) is that artifact-plane fallback, not source. + */ +function validateSourcePlaneResolution(): string[] { + const violations: string[] = [] + const localPackages = localPackageDirectories() + const config = ts.readConfigFile(resolve(root, 'tsconfig.base.json'), path => ts.sys.readFile(path)) + if (config.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + } + const { options, errors: optionErrors } = ts.convertCompilerOptionsFromJson( + (config.config as { compilerOptions?: unknown }).compilerOptions, + root, + 'tsconfig.base.json', + ) + if (optionErrors.length > 0) { + throw new Error(optionErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + // convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative + // `paths` targets resolve against the host's current directory; anchor it to + // the repository root to keep the gate cwd-independent. + const host: ts.ModuleResolutionHost = { + fileExists: path => ts.sys.fileExists(path), + readFile: path => ts.sys.readFile(path), + directoryExists: path => ts.sys.directoryExists(path), + getCurrentDirectory: () => root, + } + const sourceExtensions = new Set([ts.Extension.Ts, ts.Extension.Tsx]) + const containingFile = resolve(root, 'scripts/verify-cordis-config.ts') + const locationsBySpecifier = new Map>() + for (const reference of pluginReferences) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined || !localPackages.has(packageName)) continue + const locations = locationsBySpecifier.get(reference.name) ?? new Set() + locations.add(reference.file) + locationsBySpecifier.set(reference.name, locations) + } + for (const [specifier, locations] of locationsBySpecifier) { + const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule + if (resolved !== undefined && sourceExtensions.has(resolved.extension)) continue + violations.push(`${[...locations].join(', ')}: ${specifier} does not resolve to workspace source through tsconfig.base.json paths (add a mapping so the tsx source launch does not depend on built lib/)`) + } + return violations +} + function missingPluginDependencies( references: readonly PluginReference[], dependencies: Readonly>, From 72220dd821f690bb1d5d1ff4204711a04bf638e5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:17:42 +0800 Subject: [PATCH 11/12] fix(fs-search): drop exhausted glob sample groups Keep only groups with another path in the active round. This bounds skewed sampling by paths visited instead of rescanning every singleton for every late-group item. --- packages/fs/tool-fs-search/src/glob.ts | 28 ++++++++++++------- .../fs/tool-fs-search/tests/tools.spec.ts | 13 +++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index c9806b7bd8..97d0d904bb 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -150,27 +150,35 @@ function topLevelSegment(path: string): string { * @returns the page grouped by top-level entry, with the shown/total top-level spread. */ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample { + type ActiveGroup = { key: string; items: string[]; index: number; current: string } const groups = new Map() + let active: ActiveGroup[] = [] for (const path of paths) { const key = topLevelSegment(relativeToSearchRoot(path, root)) const group = groups.get(key) - if (group === undefined) groups.set(key, [path]) - else group.push(path) + if (group === undefined) { + const items = [path] + groups.set(key, items) + active.push({ key, items, index: 0, current: path }) + } else { + group.push(path) + } } - 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) { - for (const [key, group] of groups) { + while (active.length > 0 && count < maxItems) { + const nextActive: ActiveGroup[] = [] + for (const { key, items, index, current } of active) { 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) + if (bucket === undefined) taken.set(key, [current]) + else bucket.push(current) + const nextIndex = index + 1 + const nextPath = items[nextIndex] + if (nextPath !== undefined) nextActive.push({ key, items, index: nextIndex, current: nextPath }) } + active = nextActive } return { items: [...taken.values()].flat(), shown: taken.size, total: groups.size } } diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index da273ec2a2..5055db7c9c 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -538,6 +538,19 @@ describe('cross-directory sampling', () => { expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 }) }) + it('does not rescan exhausted entries while filling a skewed page', () => { + const singletonCount = 12_500 + const paths = [ + ...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`), + ...Array.from({ length: singletonCount }, (_, index) => `late/${index}`), + ] + expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({ + shown: singletonCount + 1, + total: singletonCount + 1, + items: { length: paths.length - 1 }, + }) + }, 500) + 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 }) From 48b0cb25fd1ad02bcfa63c6246d2b7924c228493 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:20:15 +0800 Subject: [PATCH 12/12] fix(fs-search): respect platform path separators Group paths using node:path.sep so POSIX backslashes remain filename characters while Windows continues to treat them as separators. --- .../acp-agent/tests/fixtures/fs-search-bin/rg | 2 ++ .../snapshots/fs-glob-sampling/session.jsonl | 2 +- packages/fs/tool-fs-search/src/glob.ts | 22 ++++++++++++++----- .../fs/tool-fs-search/tests/tools.spec.ts | 20 ++++++++++++++--- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/fs-search-bin/rg b/examples/acp-agent/tests/fixtures/fs-search-bin/rg index cd5703bbf7..181ad68837 100755 --- a/examples/acp-agent/tests/fixtures/fs-search-bin/rg +++ b/examples/acp-agent/tests/fixtures/fs-search-bin/rg @@ -3,6 +3,8 @@ printf '%s\n' \ 'archive/a.ts' \ 'archive/b.ts' \ 'archive/c.ts' \ + 'old\one' \ + 'old\two' \ 'src/index.ts' \ 'docs/guide.md' \ 'test/spec.ts' diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index 85ef5dfe1a..8579543459 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -11,7 +11,7 @@ {"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,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"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,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched 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.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"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"}}} diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 97d0d904bb..d934e76d21 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -9,6 +9,7 @@ */ import type { Context } from 'cordis' +import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -112,16 +113,25 @@ export interface GlobSample { /** 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 (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path + let rootEnd = root.length + while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1 + const trimmedRoot = root.slice(0, rootEnd) + if (trimmedRoot.length === 0) return stripLeadingSeparators(path) if (path === trimmedRoot) return '' - if (path.startsWith(`${trimmedRoot}/`) || path.startsWith(`${trimmedRoot}\\`)) { + if (path.startsWith(`${trimmedRoot}${sep}`)) { return path.slice(trimmedRoot.length + 1) } return path } +/** Strip only separators recognized by the execution platform. */ +function stripLeadingSeparators(path: string): string { + let start = 0 + while (path[start] === sep) start += 1 + return path.slice(start) +} + /** * 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 @@ -131,8 +141,8 @@ function relativeToSearchRoot(path: string, root: string): string { * empty group. */ function topLevelSegment(path: string): string { - const trimmed = path.replace(/^[\\/]+/, '') - const cut = trimmed.search(/[\\/]/) + const trimmed = stripLeadingSeparators(path) + const cut = trimmed.indexOf(sep) return cut === -1 ? trimmed : trimmed.slice(0, cut) } diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5055db7c9c..a0d3cdf38c 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' @@ -582,14 +582,28 @@ describe('cross-directory sampling', () => { .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 }) + const rooted = [ + ['root', 'a', 'one'].join(sep), + ['root', 'a', 'two'].join(sep), + ['root', 'b', 'three'].join(sep), + ] + expect(sampleAcrossTopLevel(rooted, 2, 'root')) + .toEqual({ items: [rooted[0], rooted[2]], 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.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => { + const paths = ['old\\one', 'old\\two', 'src/a'] + expect(sampleAcrossTopLevel(paths, 2)).toEqual({ + items: ['old\\one', 'old\\two'], + shown: 2, + total: 3, + }) + }) + 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 })