Merge pull request #2148 from deepseek-harness/feat/read-image-context

feat(fs): add a minimal read_image tool over the attachment and fs seams
This commit is contained in:
CreatixChu
2026-08-11 12:31:39 +08:00
committed by GitHub
72 changed files with 2169 additions and 100 deletions

View File

@@ -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-08-10-minimal-read-image-tool.md
2026-08-10-minimal-read-image-tool.md: a43e53d70e98bac7a50aa6bbabbb1e177237df01
2026-08-10-minimal-read-image-tool.zh.md: a94e4b296425ad50876b0b45a689442c896a85a1

View File

@@ -0,0 +1,33 @@
# Agent Note: A minimal read_image tool over existing seams
Status: implemented
English | [中文](2026-08-10-minimal-read-image-tool.zh.md)
## Problem
The multimodal attachment work gave user uploads a complete durable path — bytes committed to the content-addressed attachment store before the owning `user/message`, an `ImageBlock` carrying only the `sha256:` reference, and the pi-ai route re-reading verified bytes per request — but the model itself had no way to look at an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or a rendered chart either failed or shelled out to lossy workarounds. A first standalone attempt (PR #598) solved this together with loop-level route scoping: an `agent/request-ready` extension point publishing exact-model modalities before assembly, per-route schema/guidance visibility, and a reversible `image-placeholder-v1` history projection so text routes could continue over placeholder text. That design worked but coupled a tool to new agent-loop machinery, three new session-log concepts, and per-step registration churn — far more surface than the capability needs.
## Decision
Ship the smallest tool that loads an image into the next request's context, entirely over existing seams; the withdrawn PR #598 design is the explicit counter-example this note records.
- **`read_image` lives in `dsh-tool-fs`** beside `read`/`write`/`edit`. Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes``ctx.attachments.saveImage``fs/observed`, and the tool result is the metadata envelope plus a real `ImageBlock``ToolResultBlock.content` already admits image blocks, the pi-ai adapter already renders them, and the Web host's model-switch guard already scans tool results, so nothing downstream changes.
- **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`).
- **Registration is composition-conditional, execution is route-gated.** The tool registers only under `ctx.inject(['attachments'], …)` — no store, no tool. At execution, before any I/O, the strict gate resolves the calling route (latest `request/header` config, falling back to agent options) through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A refusal is a plain `isError` result, so a text route's durable history never acquires an image block and the session cannot brick its own route.
- **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request.
- **llm-replay models may declare `inputModalities`**, which is what lets the two keyless ACP snapshots pin both sides of the gate — the sha256-referenced success on an image-capable replay route and the verbatim refusal on a text-only one.
## Alternatives considered
- **PR #598's route-scoped design** (request-ready seam, per-route schema/guidance visibility, reversible history projection) — withdrawn in favor of this note's shape. What it bought: text routes could keep running after images entered history, and the tool disappeared from prompts where it cannot succeed. What it cost: agent-loop changes, three new durable concepts (`agent/request-ready`, `messageProjection`, availability notices), and registration that churned per step. The capability itself — see an image on the next request — never needed any of it. If per-route projection becomes a real requirement, that PR's history is the reference implementation.
- **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end.
- **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest.
- **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary.
## Consequences
- A text-only route refuses instead of degrading: no placeholder projection means no delegated-viewing story here — that is deliberately the next PR (subagent image readback rebuilt on the current subagent seams).
- The route gate races a concurrent model switch; the Web host's image-aware switch guard covers its surface, and other front doors own their equivalent. Recorded as a tool-fs Known Limitation.
- Repeated image results accumulate request-token cost until compaction; content addressing deduplicates bytes only.
- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages.

View File

@@ -0,0 +1,33 @@
# Agent Note: 基于既有 seam 的最小 read_image 工具
Status: implemented
[English](2026-08-10-minimal-read-image-tool.md) | 中文
## 问题
多模态附件工作为用户上传建立了完整的持久路径:字节在所属 `user/message` 之前提交到内容寻址的附件存储,`ImageBlock` 只携带 `sha256:` 引用pi-ai 路由在每次请求时重新读取并校验字节。但模型自己没有查看磁盘图像的手段。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败要么退到有损的变通做法。第一次独立尝试PR #598)把这个问题与循环级路由作用域一起解决:新增在组装前发布确切模型模态的 `agent/request-ready` 扩展点、按路由控制 schema指导可见性以及可逆的 `image-placeholder-v1` 历史投影让文本路由能在占位符上继续。该设计可行,但让一个工具耦合了新的 agent-loop 机制、三个新的会话日志概念和每步的注册变动,远超这项能力本身的需要。
## 决定
只交付能把图像载入下一次请求上下文的最小工具,完全建立在既有 seam 之上;撤回的 PR #598 设计是本记录明确保留的反例。
- **`read_image` 放在 `dsh-tool-fs`**,与 `read`/`write`/`edit` 并列。扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型;附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes``ctx.attachments.saveImage``fs/observed` 流动,工具结果是元数据信封加真正的 `ImageBlock`——`ToolResultBlock.content` 本就允许图像块pi-ai 适配器本就会渲染它们Web 宿主的模型切换防护本就会扫描工具结果,下游无需任何改动。
- **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上任何后端都无法无界缓冲文件stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。
- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册——没有存储就没有工具。执行时在任何 I/O 之前,严格门禁通过 `ctx.llm.resolveModelInfo` 解析调用路由(最新 `request/header` 配置,缺失时回退到 agent 选项),要求 `inputModalities` 包含 `image`;能力未知即拒绝。拒绝是普通的 `isError` 结果,因此文本路由的持久历史绝不会出现图像块,会话不会毁掉自己的路由。
- **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。
- **llm-replay 模型可以声明 `inputModalities`**,这正是两个 keyless ACP 快照能钉住门禁两侧的原因:图像路由上以 sha256 引用的成功结果,和纯文本路由上逐字的拒绝。
## 考虑过的替代方案
- **PR #598 的路由作用域设计**request-ready 扩展点、按路由的 schema指导可见性、可逆历史投影——被本记录的形态取代后撤回。它换来的是图像进入历史后文本路由仍能运行工具在注定失败的提示词里消失。它付出的是改动 agent-loop、三个新的持久概念`agent/request-ready``messageProjection`、可用性通知)和每步变动的注册。而这项能力本身——下一次请求看到图像——从不需要这些。如果按路由投影将来成为真实需求,该 PR 的历史就是参考实现。
- **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。
- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp权威。扩展名只是声明不匹配时按改名修复提示失败关闭而不是被静默接受这也让模型对文件名与内容的对应保持诚实。
- **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。
## 后果
- 纯文本路由得到拒绝而不是降级:没有占位符投影意味着这里没有委托查看的方案——那有意留给下一个 PR基于当前 subagent seam 重建的 subagent image readback
- 路由门禁与并发模型切换存在竞态Web 宿主的图像感知切换防护覆盖其表面,其他前端拥有各自的等价防护。已记入 tool-fs 的已知限制。
- 重复的图像结果在压缩之前持续累积请求 token 成本;内容寻址只去重字节。
- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。

View File

@@ -159,7 +159,7 @@ describe('the shipped Web composition', () => {
// depend on ripgrep being present on the machine.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
'subagent', 'subagent_fork', 'task_kill',
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
'workflow', 'write',

View File

@@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [
'list_agents',
'ralph',
'read',
'read_image',
'send_message',
'skill',
'subagent',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 2332b8bc8a7504314cd1721ef875ec3742b88e1f
config-catalog.zh.md: 4c788f9b5afdf65798e298c3e7c1d97bc14ffb64
config-catalog.md: 4110b89d871605e4286828f229fc45b62df156d8
config-catalog.zh.md: 3cf2035865f36f18b2395bf8f161157bc5ee9f32

View File

@@ -533,7 +533,7 @@ export interface Config {
}
```
Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts)
Source: [`packages/fs/fs-local/src/index.ts:41`](../packages/fs/fs-local/src/index.ts)
## `@deepseek-ai/dsh-fs-sandbox`
@@ -986,6 +986,8 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
/** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */
inputModalities?: readonly ModelModality[]
/**
* Optional per-request output cap the replay route materializes when callers
* omit one, so replay reconstructs the request header a live catalog produced.
@@ -1001,9 +1003,9 @@ export interface ReplayModelConfig {
}
```
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts)
Source: [`packages/support/llm-replay/src/index.ts:776`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
@@ -2116,7 +2118,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:25`](../packages/fs/tool-fs/src/index.ts)
## `@deepseek-ai/dsh-tool-fs-search`

View File

@@ -988,6 +988,8 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
/** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */
inputModalities?: readonly ModelModality[]
/**
* Optional per-request output cap the replay route materializes when callers
* omit one, so replay reconstructs the request header a live catalog produced.
@@ -1003,9 +1005,9 @@ export interface ReplayModelConfig {
}
```
依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
来源:[`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts)
来源:[`packages/support/llm-replay/src/index.ts:776`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: 65b291dfc049b30ece7a53f23ad32186759d6fcb
module-graph.zh.md: 8bf1993964931209829733bf0a6a07097c1301b9
module-graph.md: 57577f59f3c773c51680b1e1c6bf53b0bbba4dfa
module-graph.zh.md: 3f97c65e091f3dc0f1ccba23b6984610e445fe73

View File

@@ -792,6 +792,7 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_tool_fs --> pkg_attachment
pkg_tool_fs --> pkg_fs
pkg_tool_fs --> pkg_invariants
pkg_tool_fs --> pkg_llm
@@ -1389,7 +1390,7 @@ flowchart TD
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |

View File

@@ -794,6 +794,7 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_tool_fs --> pkg_attachment
pkg_tool_fs --> pkg_fs
pkg_tool_fs --> pkg_invariants
pkg_tool_fs --> pkg_llm
@@ -1391,7 +1392,7 @@ flowchart TD
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md
filesystem.md: 00e28130db9f60e6ad5f8c582ca5531482cdeb32
filesystem.zh.md: 5cf6be12f41b36c8149a941a4d251c4497ed4738
filesystem.md: 01fe2d07f5497374019855ca46ced7e173d21445
filesystem.zh.md: e68246d7a44b8812e132e02979a2c0cda6adb792

View File

@@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'>
type FsVersion = Branded<'FsVersion'>
```
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. A protocol consumer that needs a byte ceiling applies it while consuming `streamText`, so the filesystem seam needs no consumer-specific bounded-read primitive.
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets consumers reject directories and special files before reading, and `size` lets text consumers choose `readText` vs `streamText` without probing by failure. A text consumer applies its own retention ceiling while consuming `streamText`. Raw-byte consumers use `readBytes(target, signal, maxBytes)`; its required complete-content cap makes a known or discovered overflow fail with `FS_TOO_LARGE` instead of truncating or buffering without a bound.
```ts type-equiv
/**
@@ -256,6 +256,7 @@ type FsErrorCode =
| 'FS_NOT_DIRECTORY'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_TOO_LARGE'
| 'FS_PERMISSION_DENIED'
| 'FS_SANDBOX_DENIED'
| 'FS_IO_ERROR'
@@ -274,7 +275,7 @@ type FsErrorCode =
## The service and the plugin
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -373,6 +374,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* Read the whole regular file as raw bytes with no decoding or binary
* rejection. The bound lives at this seam so a backend can never buffer an
* unbounded file: a target known or discovered to exceed `maxBytes` fails
* with `FS_TOO_LARGE` instead of returning a truncated result.
* @param target - the resolved target to read.
* @param signal - aborts the read.
* @param maxBytes - inclusive byte cap on the complete content.
* @returns the full raw content, at most `maxBytes` long.
*/
abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
/**
* List direct children of a directory in stable name order. Returns resolved
* child targets plus cheap metadata only; never reads file contents.

View File

@@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'>
type FsVersion = Branded<'FsVersion'>
```
`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。需要字节上限的协议消费方在消费 `streamText` 时执行该上限,因此文件系统 seam 无需消费方专用的有界读取原语
`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让消费方在读取前拒绝目录特殊文件;`size` 让文本消费方无需通过失败探测即可选择 `readText` 还是 `streamText`。文本消费方在消费 `streamText` 时执行自己的保留量上限。原始字节消费方调用 `readBytes(target, signal, maxBytes)`;其必填的完整内容上限会使已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,不会截断结果或无界缓冲
```ts type-equiv
/**
@@ -256,6 +256,7 @@ type FsErrorCode =
| 'FS_NOT_DIRECTORY'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_TOO_LARGE'
| 'FS_PERMISSION_DENIED'
| 'FS_SANDBOX_DENIED'
| 'FS_IO_ERROR'
@@ -274,7 +275,7 @@ type FsErrorCode =
## 服务与插件
`FileSystem``ctx.fs`abstract拥有提供方原语`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
`FileSystem``ctx.fs`abstract拥有提供方原语`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -373,6 +374,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* Read the whole regular file as raw bytes with no decoding or binary
* rejection. The bound lives at this seam so a backend can never buffer an
* unbounded file: a target known or discovered to exceed `maxBytes` fails
* with `FS_TOO_LARGE` instead of returning a truncated result.
* @param target - the resolved target to read.
* @param signal - aborts the read.
* @param maxBytes - inclusive byte cap on the complete content.
* @returns the full raw content, at most `maxBytes` long.
*/
abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
/**
* List direct children of a directory in stable name order. Returns resolved
* child targets plus cheap metadata only; never reads file contents.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
tool-catalog.md: f61b6daeb7209d0fa81606bda7718fd42c7f22dc
tool-catalog.zh.md: cbecdba084fa64ec78913706221a1558d206cde8
tool-catalog.md: 19a4035fdc1e24d439307d11cb585f689b35043e
tool-catalog.zh.md: e34fbb85152e012592b4e045e2f505ded6175e94

View File

@@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
| `@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 view presence/absence, edit absence, or successful mutation`, `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 read presence/absence or successful mutation`, `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`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `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. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. 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`, `goal/change 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. |
@@ -485,6 +485,27 @@ Read a UTF-8 text file and return line-numbered content.
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
### `read_image`
Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.
```json
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the image file, resolved by the filesystem backend."
}
},
"required": [
"file_path"
]
}
```
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
### `write`
Create or fully replace a UTF-8 text file.
@@ -511,7 +532,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. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.
## `@deepseek-ai/dsh-tool-fs-search`

View File

@@ -25,7 +25,7 @@
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect``cordis_mount``cordis_unmount` | `ctx.tools` | `tool/call``tool/result``process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools``ctx.pty``an owning Agent at execution time` | `tool/call``PTY shell state``tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools``ctx.fs` | `tool/call``fs/observed after view presence/absence, edit absence, or successful mutation``tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
| `@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 read presence/absence or successful mutation``tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 |
| `@deepseek-ai/dsh-tool-fs` | `edit``read``read_image``write` | `ctx.tools``ctx.fs``ctx.systemPrompt``ctx.attachments (read_image registration)``ctx.llm + an image-capable route (read_image execution)` | `tool/call``fs/write-intent or fs/edit-intent for mutations``fs/observed after read presence/absence or successful file operation``durable attachment (read_image)``tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments``read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 |
| `@deepseek-ai/dsh-tool-fs-search` | `glob``grep` | `ctx.tools``ctx.subprocess``ctx.systemPrompt` | `tool/call``tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
| `@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` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 |
| `@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``goal/change for mutations``tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 |
@@ -489,6 +489,27 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
### `read_image`
读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。要求当前模型接受图像输入。
```json
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the image file, resolved by the filesystem backend."
}
},
"required": [
"file_path"
]
}
```
来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
### `write`
创建或完全替换 UTF-8 文本文件。
@@ -515,7 +536,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同
先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments``read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝
## `@deepseek-ai/dsh-tool-fs-search`

View File

@@ -0,0 +1,39 @@
# Keyless replay for the read-image refusal scenario: identical to the
# image.cordis.snapshot.yml overlay except the replay catalog leaves flash
# text-only, so the strict read_image gate refuses and no image ever enters
# the durable log.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} 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.
- insert:
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-flash
inputModalities: [text]
- id: deepseek-v4-pro
inputModalities: [text]

View File

@@ -0,0 +1,27 @@
# Text-route image overlay: the attachment store registers read_image, but the
# strict execution gate refuses on a route that does not declare image input,
# so a text-only deployment keeps its durable history text-clean. The app
# config is restated to re-pin `deepseek-v4-flash` (base ships pro; the
# authored fixture and the pinned header class are flash), because a config
# patch replaces the whole app config.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} 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.
- insert:
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'

View File

@@ -0,0 +1,41 @@
# Keyless replay for the read-image success scenario. Include patches cannot
# target entries behind a nested include, so this restates the replay overlay
# directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and
# re-pins the recorded flash model. The replay catalog declares image input on
# flash, so the strict read_image gate accepts the route and the tool result
# carries the durable image block; the live DeepSeek route cannot record this.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} 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.
- insert:
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-flash
inputModalities: [text, image]
- id: deepseek-v4-pro
inputModalities: [text]

View File

@@ -0,0 +1,27 @@
# Image-scenario overlay: adds the durable attachment store the read_image tool
# commits through. The store resolves its root from $DSH_HOME, which the
# snapshot harness scopes per run, so the overlay itself carries no paths. The
# app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the
# authored fixture and the pinned header class are flash), because a config
# patch replaces the whole app config.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} 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.
- insert:
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'

View File

@@ -38,6 +38,8 @@ const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cor
const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url))
const IMAGE_CONFIG = fileURLToPath(new URL('../image.cordis.yml', import.meta.url))
const IMAGE_TEXT_ROUTE_CONFIG = fileURLToPath(new URL('../image-text-route.cordis.yml', import.meta.url))
const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.yml', import.meta.url))
@@ -180,6 +182,30 @@ const SCENARIOS: Scenario[] = [
configPath: SESSION_QUERY_CONFIG,
posixOnly: true,
},
// Authored keyless replays through the assembled app: the replay catalog
// declares flash image-capable (success) or text-only (refusal), and the
// real read_image tool executes against the workspace fixture and the real
// attachment store. Both boot the same composed header (the tool registers
// with the attachment store, independent of route), so they share one class.
{
name: 'read-image',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'image',
// The overlay adds no prompt section (read_image carries no guidance), so
// the composed system prompt is byte-identical to the default class; only
// the tool-schema sidecar is class-specific.
systemPromptSource: 'text-turn',
configPath: IMAGE_CONFIG,
},
{
name: 'read-image-text-route',
hasModelTurn: true,
recorded: false,
headerClass: 'image',
configPath: IMAGE_TEXT_ROUTE_CONFIG,
},
{
name: 'pty-tools',
hasModelTurn: true,

View File

@@ -0,0 +1,14 @@
{
"steps": [
{
"op": "initialize"
},
{
"op": "newSession"
},
{
"op": "prompt",
"text": "Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."
}
]
}

View File

@@ -0,0 +1,26 @@
{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}}
{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1786344284632,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1786344284632,"data":{"title":"Use read_image on red.png in","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1786344284632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1786344284633,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":10,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":12,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1786344284638,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1786344284638,"data":{"turn":1,"step":1,"callId":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}
{"type":"tool/result","seq":15,"time":1786344284643,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-refused"},"content":[{"type":"tool-result","toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1786344284643,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":17,"time":1786344284648,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":19,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNAVAILABLE"}}}}
{"type":"assistant/chunk","seq":20,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":21,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":22,"time":1786344284652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
{"type":"step/end","seq":23,"time":1786344284653,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":24,"time":1786344284653,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -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":"UNAVAILABLE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -0,0 +1,14 @@
{
"steps": [
{
"op": "initialize"
},
{
"op": "newSession"
},
{
"op": "prompt",
"text": "Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."
}
]
}

View File

@@ -0,0 +1,26 @@
{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}}
{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}
{"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":17,"time":1786344283075,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
{"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -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":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1,543 @@
{
"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> 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": "interrupt_agent",
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
}
},
"required": [
"agent_id"
]
}
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"enum": [
"children",
"descendants"
]
}
}
}
},
{
"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": "read_image",
"description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the image file, resolved by the filesystem backend."
}
},
"required": [
"file_path"
]
}
},
{
"name": "send_message",
"description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.",
"parameters": {
"type": "object",
"properties": {
"subagent_id": {
"type": "string",
"description": "The subagent id returned when the background subagent was started."
},
"message": {
"type": "string",
"description": "The message to deliver to the subagent."
}
},
"required": [
"subagent_id",
"message"
]
}
},
{
"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.",
"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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."
}
},
"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.",
"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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."
}
},
"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."
},
"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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -14,6 +14,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-attachment-local": "workspace:*",
"@deepseek-ai/dsh-bash": "workspace:*",
"@deepseek-ai/dsh-bash-env": "workspace:*",
"@deepseek-ai/dsh-bash-local": "workspace:*",
@@ -27,8 +28,8 @@
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*",
"@deepseek-ai/dsh-credentials-local": "workspace:*",
"@deepseek-ai/dsh-e2b": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-fs-e2b": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-fs-policy": "workspace:*",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:*",
@@ -76,8 +77,8 @@
"@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*",
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-subprocess-e2b": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tasks-local": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",

View File

@@ -117,6 +117,10 @@ class RecordingFileSystem extends FileSystem {
return this.entries.get(target.targetKey)?.content ?? ''
}
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
throw new Error('not needed in workspace-context tests')
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()

View File

@@ -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', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', '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', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', '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) {

View File

@@ -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/e2b/fs-e2b/README.md
README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9
README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575
README.md: 9171989f968f144593107eb918fe75cd12de7768
README.zh.md: 9f50bbe4c37bbbfb641690a690be45dbb5158258

View File

@@ -9,6 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
- **Bounded raw-byte reads** — `readBytes` short-circuits on the stat size before any content transfer, then streams the remote object and cancels the stream at the first chunk past `maxBytes` (`FS_TOO_LARGE`), so neither an at-rest oversized file nor a post-stat grower is buffered whole in host memory. The empty-file quirk of the pinned SDK (content-length 0 returns `''` in stream format) yields an empty result.
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure.

View File

@@ -9,6 +9,7 @@
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析GNU `realpath -mz` 提供规范化目标身份且不要求最终文件存在ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam目录列表会复用已返回的元数据并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI以及由提供方负责的包含关系检查因此通用进程管理消费方无需解析 E2B 目标 ID也不会套用宿主路径规则。
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
- **有界原始字节读取**`readBytes` 在任何内容传输之前先按 stat 大小短路,然后流式读取远程对象,并在第一个超过 `maxBytes` 的分片处取消流(`FS_TOO_LARGE`),因此静态超限文件和 stat 后增长的文件都不会被完整缓冲进宿主内存。所钉版本 SDK 的空文件怪癖content-length 为 0 时 stream 格式返回 `''`)产生空结果。
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布即使目标位置出现目录也能使提交具备原子且不替换的语义系统会把提交前从暂存文件读取的元数据投影到目标路径以生成返回的版本因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。

View File

@@ -90,6 +90,24 @@ function commandOpts(signal: AbortSignal | undefined): { envs: Record<string, st
return { envs: e2bControlEnvs(), ...signalOpts(signal) }
}
async function openReadStream(
sandbox: Sandbox,
target: FsTarget,
signal: AbortSignal | undefined,
): Promise<ReadableStream<Uint8Array>> {
try {
// The pinned SDK's stream overload lies for empty files: content-length 0
// returns '' instead of a ReadableStream.
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
ReadableStream<Uint8Array> | string
return typeof read === 'string'
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
: read
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
}
function entryType(entry: EntryInfo): FsInfo['type'] {
switch (entry.type) {
case FileType.FILE:
@@ -227,21 +245,57 @@ export class E2BFileSystem extends FileSystem {
}
}
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const sandbox = await this.ctx.e2b.getSandbox()
const info = await this.requireRegular(target, signal)
if (info.size !== undefined && info.size > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
const stream = await openReadStream(sandbox, target, signal)
const reader = stream.getReader()
const chunks: Uint8Array[] = []
let bytes = 0
let completed = false
try {
while (true) {
assertNotAborted(signal, 'read')
const next = await reader.read()
if (next.done) break
// The stat preflight covers the at-rest case; this streamed bound stops
// a post-stat grower without transferring past the first overflowing chunk.
bytes += next.value.byteLength
if (bytes > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
chunks.push(next.value)
}
completed = true
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
} finally {
if (!completed) {
try {
await reader.cancel()
} catch (_streamCancellationFailure) {
// The read already failed; a cancellation failure on the abandoned
// remote stream adds nothing actionable for the caller.
}
}
reader.releaseLock()
}
const whole = new Uint8Array(bytes)
let offset = 0
for (const chunk of chunks) {
whole.set(chunk, offset)
offset += chunk.byteLength
}
return whole
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
let stream: ReadableStream<Uint8Array>
try {
// The pinned SDK's stream overload lies for empty files: content-length 0
// returns '' instead of a ReadableStream.
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
ReadableStream<Uint8Array> | string
stream = typeof read === 'string'
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
: read
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
const stream = await openReadStream(sandbox, target, signal)
const displayPath = target.displayPath
return {
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
@@ -412,10 +466,11 @@ export class E2BFileSystem extends FileSystem {
}
}
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<void> {
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
return info
}
private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {

View File

@@ -40,6 +40,7 @@ class FakeRemote {
readonly links: Array<{ from: string; to: string }> = []
readonly removals: string[] = []
readonly commands: string[] = []
readonly reads: Array<{ path: string; format: 'bytes' | 'stream' }> = []
streamChunks: Uint8Array[] | undefined
streamKeepOpen = false
readonly streamCancel = vi.fn()
@@ -157,6 +158,7 @@ class FakeRemote {
},
read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array> | string> => {
this.checkAbort(options)
this.reads.push({ path, format: options.format })
if (this.nextReadError !== undefined) {
const error = this.nextReadError
this.nextReadError = undefined
@@ -471,6 +473,42 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
})
it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => {
const remote = new FakeRemote()
remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47])
remote.dir('/workspace/directory')
const { fs } = await setup(remote)
const target = await fs.resolve('img.bin')
expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47])
expect(remote.reads).toEqual([{ path: '/workspace/img.bin', format: 'stream' }])
remote.reads.length = 0
await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE')
expect(remote.reads).toEqual([])
await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND')
await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE')
const live = new AbortController()
expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4)
remote.nextReadError = new DOMException('aborted', 'AbortError')
await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED')
})
it('readBytes bounds a post-stat grower mid-stream and reads an empty file through the SDK quirk', async () => {
const remote = new FakeRemote()
remote.file('/workspace/grow.bin', [1, 1, 1, 1])
remote.file('/workspace/empty.bin', '')
const { fs } = await setup(remote)
remote.streamChunks = [bytes([1, 1, 1]), bytes([1, 2, 2])]
remote.streamKeepOpen = true
await expectCode(fs.readBytes(await fs.resolve('grow.bin'), undefined, 4), 'FS_TOO_LARGE')
expect(remote.streamCancel).toHaveBeenCalledOnce()
remote.streamChunks = undefined
remote.streamKeepOpen = false
expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0)
})
it('honors aborts before and during remote reads', async () => {
const remote = new FakeRemote()
remote.file('/workspace/a', 'a')

View File

@@ -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/fs-local/README.md
README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8
README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda
README.md: d17dc0747833a0ecb85505260badc79ad739f27f
README.zh.md: e13ab2f04b84b59fbb3846c08139e679b2d43be1

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the twelve `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
- **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback.
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十`FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十`FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **执行世界坐标**`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo``version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token`type` 为 `file`/`directory`/`other``size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
- **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`Windows 上的新文件继承目标目录的 DACL而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。

View File

@@ -98,6 +98,8 @@ export interface FsIoInternals {
removeStagingDir?: (stagingDir: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+publication. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
/** Test hook after raw-read stat preflight and before bounded content I/O. */
inspectReadBytesAfterStat?: (target: LocalTarget) => void | Promise<void>
}
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
@@ -380,6 +382,50 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
return decodeUtf8(raw, 'read', target.displayPath)
}
/**
* Read a whole regular file as raw bytes with no decoding or binary rejection.
* `maxBytes` bounds the complete content: the stat size short-circuits an
* oversized file before any content I/O, and the stream reads at most one byte
* beyond the cap so a file growing after stat cannot cause unbounded buffering.
* @param target - the resolved file to read.
* @param signal - aborts the read (`FS_ABORTED`).
* @param maxBytes - inclusive byte cap on the complete content (`FS_TOO_LARGE`).
* @param internals - test seam for a deterministic post-stat growth race.
* @returns the full raw content, at most `maxBytes` long.
*/
export async function readWholeBytes(
target: LocalTarget,
signal: AbortSignal | undefined,
maxBytes: number,
internals: FsIoInternals = {},
): Promise<Uint8Array> {
const info = await statRegularFile(target, 'read', signal)
if (info.size > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
await internals.inspectReadBytesAfterStat?.(target)
const stream = createReadStream(target.targetKey, {
end: maxBytes,
...signal ? { signal } : {},
})
const chunks: Buffer[] = []
let bytes = 0
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytes += chunk.length
if (bytes > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
}
chunks.push(chunk)
}
} catch (error: unknown) {
/* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
throw error
}
return Buffer.concat(chunks, bytes)
}
/**
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,

View File

@@ -28,6 +28,7 @@ import {
probeNoFollow,
readForEdit,
readTextForDiff,
readWholeBytes,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
@@ -147,6 +148,10 @@ export class LocalFileSystem extends FileSystem {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals)
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
return entries.map(entry => ({

View File

@@ -265,6 +265,43 @@ describe('readText / streamText', () => {
})
})
describe('readBytes', () => {
it('reads raw bytes without decoding or NUL rejection', async () => {
const raw = Buffer.from([0x68, 0x00, 0x69, 0xff])
await writeFile(join(dir, 'a.bin'), raw)
expect(Buffer.from(await fs.readBytes(await fs.resolve('a.bin'), undefined, raw.length))).toEqual(raw)
})
it('accepts a file exactly at maxBytes and rejects one past it', async () => {
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
const target = await fs.resolve('a.bin')
expect((await fs.readBytes(target, undefined, 4)).length).toBe(4)
await expect(fs.readBytes(target, undefined, 3)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
})
it('bounds content I/O when a file grows after stat preflight', async () => {
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
const target = await fs.resolve('a.bin')
fs.internals.inspectReadBytesAfterStat = () => writeFile(join(dir, 'a.bin'), Buffer.alloc(1024 * 1024, 2))
await expect(fs.readBytes(target, undefined, 4)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
})
it('rejects a missing file and a directory', async () => {
await expect(fs.readBytes(await fs.resolve('nope'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(fs.readBytes(await fs.resolve('.'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('reads under a live signal and rejects an already-aborted one with FS_ABORTED', async () => {
await writeFile(join(dir, 'a.bin'), 'data')
const live = new AbortController()
expect((await fs.readBytes(await fs.resolve('a.bin'), live.signal, 1024)).length).toBe(4)
const controller = new AbortController()
controller.abort()
await expect(fs.readBytes(await fs.resolve('a.bin'), controller.signal, 1024)).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('listDir', () => {
it('lists files and directories in stable name order with resolved child targets', async () => {
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })

View File

@@ -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/fs/README.md
README.md: 62d3febde82e013ace054a9e6242147c1756b0d1
README.zh.md: 137c1e8da1014bf7dda7c4bf2e667aca6d2f51b5
README.md: d53fe69456622e533e5ba5a96bd9dab10c188eaa
README.zh.md: 64b7d79687a0b6a0d81a5037ea6044d4a406f32b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -17,7 +17,7 @@ This package owns the Service Definition and provider contract layer of the four
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements eleven primitives.
A backend subclasses `FileSystem` and implements twelve primitives.
| Member | Semantics |
|---|---|
@@ -29,6 +29,7 @@ A backend subclasses `FileSystem` and implements eleven primitives.
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
| `readBytes(target, signal, maxBytes)` | Read a complete regular file as raw bytes with no decoding or binary rejection. `maxBytes` is required and bounds the complete content at this seam: a known or discovered overflow fails with `FS_TOO_LARGE` instead of truncating or buffering without a bound. |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
@@ -47,7 +48,7 @@ This package declares three events (see the generated region of [filesystem.md](
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_TOO_LARGE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## Model Experience
@@ -59,7 +60,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**`FileSystem`**`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查列出元数据、原子写入和应用字面量编辑但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
**`FileSystem`**`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
本包是四层文件系统栈中的提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
@@ -17,7 +17,7 @@
## 服务 API`ctx.fs`
后端继承 `FileSystem` 并实现十个原语。
后端继承 `FileSystem` 并实现十个原语。
| 成员 | 语义 |
|---|---|
@@ -29,6 +29,7 @@
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 |
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
| `readBytes(target, signal, maxBytes)` | 把完整普通文件按原始字节读出,不做解码或二进制拒绝。`maxBytes` 为必填,在该 seam 上限制完整内容:已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,而不是截断或无界缓冲。 |
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent``createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
@@ -47,7 +48,7 @@
## 词汇
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }``{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode``FS_NOT_FOUND``FS_NOT_DIRECTORY``FS_NOT_TEXT``FS_NOT_REGULAR_FILE``FS_PERMISSION_DENIED``FS_IO_ERROR``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }``{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode``FS_NOT_FOUND``FS_NOT_DIRECTORY``FS_NOT_TEXT``FS_NOT_REGULAR_FILE``FS_TOO_LARGE``FS_PERMISSION_DENIED``FS_IO_ERROR``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`
## 模型体验
@@ -59,7 +60,7 @@
## 已知限制与延期工作
- **约定只支持文本**后端`FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有十个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **变更操作约定只支持文本**文本读取和两个变更操作都`FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有十个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。

View File

@@ -186,6 +186,18 @@ export abstract class FileSystem extends Service {
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* Read the whole regular file as raw bytes with no decoding or binary
* rejection. The bound lives at this seam so a backend can never buffer an
* unbounded file: a target known or discovered to exceed `maxBytes` fails
* with `FS_TOO_LARGE` instead of returning a truncated result.
* @param target - the resolved target to read.
* @param signal - aborts the read.
* @param maxBytes - inclusive byte cap on the complete content.
* @returns the full raw content, at most `maxBytes` long.
*/
abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
/**
* List direct children of a directory in stable name order. Returns resolved
* child targets plus cheap metadata only; never reads file contents.

View File

@@ -177,6 +177,7 @@ export type FsErrorCode =
| 'FS_NOT_DIRECTORY'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_TOO_LARGE'
| 'FS_PERMISSION_DENIED'
| 'FS_SANDBOX_DENIED'
| 'FS_IO_ERROR'

View File

@@ -50,6 +50,13 @@ class FakeFileSystem extends FileSystem {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const bytes = new TextEncoder().encode(await this.readText(target))
if (bytes.length > maxBytes) {
throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE')
}
return bytes
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
return [
@@ -112,6 +119,16 @@ describe('FileSystem provider seam', () => {
expect(streamed).toBe(await fs.readText(target))
})
it('readBytes returns raw content and enforces the byte cap with FS_TOO_LARGE', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.bin', 'hi')
const target = await fs.resolve('a.bin')
expect(await fs.readBytes(target, undefined, 2)).toEqual(new TextEncoder().encode('hi'))
await expect(fs.readBytes(target, undefined, 1)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
})
it('listDir returns child entry targets without reading file content', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)

View File

@@ -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: 27b53aca50f470fe9ead4da27d87328264440ff7
README.zh.md: 5bcf9c471d933702c46d50c56c9539cb9eede3ca
README.md: 7e334f886747cd8dc566a572c699cd80c7cf62fe
README.zh.md: b5eb5ae38aba049d77375d31d1509342a23f13fc

View File

@@ -2,17 +2,20 @@
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 contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. 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. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
The **model-facing filesystem tools**`read`, `read_image`, `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 contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. 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. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
```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(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results
await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments
```
`@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.
`read_image` registers only while a durable `ctx.attachments` service is mounted — without one the deployment cannot commit image bytes, so the tool never appears. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options); an unknown or text-only route gets a refusal result before any filesystem I/O, so a text route's durable history stays free of image blocks.
## Config
All keys are optional; the defaults are the shipped read caps.
@@ -29,18 +32,20 @@ All keys are optional; the defaults are the shipped read caps.
| Tool | Arguments | Behavior |
|---|---|---|
| `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). |
| `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. It succeeds only when the exact routed model declares image input. |
| `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, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`, `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, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
## 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:
- **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.)
- **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat` (recording an `absent` observation for a missing target, like `read`), a bounded `ctx.fs.readBytes` capped at the smaller of `imageLimits.maxImageBytes` and `imageLimits.maxMessageImageBytes` (the result is one message carrying one image), `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (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,11 +55,11 @@ When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_per
## `fs/observed` is fire-and-forget
`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.
`fs/observed` fires AFTER the read/read_image/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).
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`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
## Model Experience
@@ -94,7 +99,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 [`read`, `read_image`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. `read_image` appears only while a durable attachment store is mounted; the schema itself is route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent.
#### Token effect
@@ -118,6 +123,20 @@ Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; t
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Image read result
#### What the model sees
A successful `read_image` returns `<path><displayPath></path>`, `<type>image</type>`, and a `<content>` envelope naming the media type, dimensions, and byte size, followed by the image itself as a native image block. The session log stores only the durable `sha256:` attachment reference; the routed provider re-reads and digest-verifies the bytes on each request.
#### Token effect
The image is billed on every later request until compaction. Each call is independently bounded by the attachment store's `maxImageBytes`/`maxImagePixels`; repeated successful calls accumulate history, and content addressing deduplicates only the stored bytes, not the per-request token cost.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Write and edit results
#### What the model sees
@@ -136,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>`. 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 <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
Failures are normalized as `Error: <message>`. 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 <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, `offset <offset> is out of range for "<path>" (<total> lines)`, `cannot read "<path>": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "<path>" as an image: model "<model>" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "<path>": the <ext> extension declares <type>, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
#### Token effect
@@ -149,5 +168,8 @@ 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 ripgrep-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`.
- **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`.
- **The route gate races a concurrent model switch** — `read_image` checks the latest routed model at execution; a switch committed between that check and the next request can leave an image block on a route that rejects image content. The Web host already refuses switching an image-bearing session to a text-only model; other front doors own their equivalent guard.
- **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed.
- **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages.
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)).

View File

@@ -2,17 +2,20 @@
[English](README.md) | 中文
**面向模型的文件系统工具**`read``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。
**面向模型的文件系统工具**`read``read_image``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@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(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results
await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments
```
`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。
`read_image` 只在持久 `ctx.attachments` 服务已挂载时注册:没有它,部署无法持久提交图像字节,工具就不会出现。执行时还要求确切路由的模型声明 `image` 输入(通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项);未知或纯文本路由在任何文件系统 I/O 之前就得到拒绝结果,因此文本路由的持久历史不会出现图像块。
## 配置
所有键均为可选;默认值是随产品交付的读取上限。
@@ -29,18 +32,20 @@ await ctx.plugin(ToolFs) // this package — re
| 工具 | 参数 | 行为 |
|---|---|---|
| `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`2000上限也为该值。 |
| `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。只有确切路由的模型声明图像输入时才会成功。 |
| `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 }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }``read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }``write` → `{ path, operation: 'create' | 'update', before: string | null, after }``edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `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))。随后执行:
- **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。1 次 stat。
- **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`(目标缺失时与 `read` 一样记录 `absent` 观察)、以 `imageLimits.maxImageBytes` 与 `imageLimits.maxMessageImageBytes` 中较小者为上限的有界 `ctx.fs.readBytes`(结果是携带一张图像的一条消息)、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。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,11 +55,11 @@ await ctx.plugin(ToolFs) // this package — re
## `fs/observed` 发后即忘
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的约定是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。
`fs/observed` 在 read/read_image/write/edit 已经成功之后,通过普通 `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)。
包根目录只导出 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`(不依赖 Cordis单独进行单元测试`src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。
## 模型体验
@@ -94,7 +99,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 移除任一定义。
模型会看到已生成的 [`read`、`read_image`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。`read_image` 只在持久附件存储已挂载时出现schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。
#### Token 影响
@@ -118,6 +123,20 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### 图像读取结果
#### 模型看到的内容
成功的 `read_image` 返回 `<path><displayPath></path>`、`<type>image</type>` 和写明媒体类型、尺寸与字节数的 `<content>` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。
#### Token 影响
图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。
#### KV Cache 影响
仅追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。
### 写入与编辑结果
#### 模型看到的内容
@@ -136,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file``offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry``FS_NOT_OBSERVED` 追加 `— read the file, then retry`结构化错误码保持不变。该次重新读取确认缺失后edit 会报告 `FS_NOT_FOUND`而不会重复陈旧恢复指令write 则使用带防护的创建。
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file``offset <offset> is out of range for "<path>" (<total> lines)`、`cannot read "<path>": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "<path>" as an image: model "<model>" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "<path>": the <ext> extension declares <type>, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry``FS_NOT_OBSERVED` 追加 `— read the file, then retry`结构化错误码保持不变。该次重新读取确认缺失后edit 会报告 `FS_NOT_FOUND`而不会重复陈旧恢复指令write 则使用带防护的创建。
#### Token 影响
@@ -149,5 +168,8 @@ 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/) 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
- **`read` 只处理 UTF-8 文本文件**二进制安全读取和 PDF/图像/多模态内容均延期处理目录目标为 `FS_NOT_REGULAR_FILE`。
- **`read` 只处理 UTF-8 文本文件**图像使用独立的、按扩展名路由的 `read_image` 工具PDF、音频和视频仍延期处理目录目标为 `FS_NOT_REGULAR_FILE`。
- **路由门禁与并发模型切换存在竞态**`read_image` 在执行时检查最新路由的模型在该检查与下一次请求之间提交的切换可能让图像块落在拒绝图像内容的路由上。Web 宿主已拒绝把含图像的会话切到纯文本模型;其他前端拥有各自的等价防护。
- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。
- **工具结果卡片没有内嵌图像预览**UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。
- **没有超时接口**`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -51,6 +52,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",

View File

@@ -1,5 +1,5 @@
/**
* Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
* Model-facing read, read_image, 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
@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-user-approval'
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
import { applyReadImageTool } from './read-image.ts'
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
import { FsSandboxSurface } from './sandbox.ts'
@@ -49,7 +50,7 @@ function assertPositiveInteger(name: string, value: number): void {
}
}
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
/** Register the full `read`/`write`/`edit` filesystem tool suite, plus `read_image` while `attachments` is mounted. */
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
@@ -63,6 +64,12 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.readMaxBytes,
streamMinSize: resolved.readStreamMinSize,
})
// read_image is composition-conditional: without a mounted attachment store
// the deployment cannot durably commit image bytes, so the tool never
// registers; the execute body keeps a defensive re-check for direct callers.
ctx.inject(['attachments'], (imageCtx) => {
applyReadImageTool(imageCtx)
})
// One escalation surface shared by both mutating tools: advertisement gating,
// per-call policy resolution, and denial-marker mapping, all keyed off whether
// the mounted ctx.fs confines (ctx.fs.sandboxMode).

View File

@@ -0,0 +1,231 @@
/**
* The model-facing `read_image` tool: reads a PNG/JPEG/WebP/GIF file, durably
* commits its bytes through the attachment service (the same lifecycle as a
* user-uploaded image), and returns an image block so the image enters model
* context from the next request onward.
*
* The route gate is deliberately stricter than the host upload preflight: a
* tool result enters durable session history, so emitting an image on a route
* that cannot carry it would break that route's continuation. Unknown
* capability therefore refuses instead of relying on the adapter guard.
* @module @deepseek-ai/dsh-tool-fs/src/read-image
*/
import { basename, extname } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-fs'
import { resolveRegularReadTarget } from './read-target.ts'
/** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */
const IMAGE_EXTENSIONS: Readonly<Record<string, ImageMediaType>> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
}
/** The canonical outcome declared by the `read_image` output schema. */
export interface ImageReadValue {
path: string
image: {
attachmentId: string
mediaType: ImageMediaType
bytes: number
width: number
height: number
name?: string
}
}
/**
* Map a model-supplied path to its declared image media type by extension.
* @param filePath - the raw `file_path` argument (not yet resolved).
* @returns the declared media type, or undefined when the path does not claim an image.
*/
export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefined {
return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()]
}
/**
* Enforce the strict image-capability gate for the calling route. Resolves the
* session's latest routed provider/model (request header config, then agent
* options) and requires the exact resolved route to declare `image` input explicitly.
* @param ctx - the plugin context used to resolve the optional `llm` service.
* @param exec - the tool-execution context supplying the calling agent.
* @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages.
*/
export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, requestedPath: string): Promise<void> {
const routed = exec.agent?.session.requestHeader()?.config
const provider = routed?.provider ?? exec.agent?.options.provider
const model = routed?.model ?? exec.agent?.options.model
const llm = ctx.get('llm')
if (provider === undefined || model === undefined || llm === undefined) {
throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`)
}
const active = await llm.resolveModelInfo(provider, model, exec.signal)
if (active.inputModalities === undefined || !active.inputModalities.includes('image')) {
throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`)
}
}
/**
* Re-brand a canonical image outcome into the durable attachment reference an
* `ImageBlock` carries.
* @param image - the canonical image metadata from the output schema.
* @returns the branded attachment reference.
*/
export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachmentRef {
return {
attachmentId: AttachmentId(image.attachmentId),
mediaType: image.mediaType,
bytes: image.bytes,
width: image.width,
height: image.height,
...image.name === undefined ? {} : { name: image.name },
}
}
/**
* Format an image read as the model-facing envelope beside its image block.
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
* @param image - the canonical image metadata to summarize.
* @returns the model-facing envelope; the image itself rides the adjacent image block.
*/
export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string {
return `<path>${displayPath}</path>
<type>image</type>
<content>
${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes
</content>`
}
/**
* Project one canonical image read into its model-facing envelope and image.
* @param value - the canonical image-read outcome.
* @returns the two content blocks used by native and nested dispatches.
*/
function imageReadContent(value: ImageReadValue): ContentBlock[] {
return [
{ type: 'text', text: formatImageReadOutput(value.path, value.image) },
{ type: 'image', attachment: imageRefFromValue(value.image) },
]
}
/**
* Register the `read_image` tool into the given context. The composing plugin
* owns the attachments gate: `src/index.ts` calls this inside
* `ctx.inject(['attachments'], …)` so the tool exists only while a durable
* store is mounted. Execution still re-checks `ctx.get('attachments')` for
* direct callers and gates on the calling route's declared image input.
* @param ctx - the registration scope; execution uses its `fs` service plus
* the optional `attachments`/`llm` services.
*/
export function applyReadImageTool(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'read_image',
description: 'Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.',
parameters: {
file_path: { type: 'string', required: true, description: 'Path to the image file, resolved by the filesystem backend.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
image: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
attachmentId: { type: 'string', required: true },
mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true },
bytes: { type: 'integer', required: true },
width: { type: 'integer', required: true },
height: { type: 'integer', required: true },
name: { type: 'string' },
},
},
},
},
render: (_args, value) => imageReadContent(value),
},
// Content-addressed attachment writes are idempotent, so concurrent reads
// of the same file cannot conflict.
isConcurrencySafe: () => true,
async execute(args, exec) {
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
// Every gate runs before any filesystem I/O so a refusal never leaks
// partial reads or attachment writes.
const mediaType = imageMediaTypeForPath(args.file_path)
if (mediaType === undefined) {
throw new Error(`cannot read "${args.file_path}": read_image only accepts PNG/JPEG/WebP/GIF paths`)
}
const attachments = ctx.get('attachments')
if (attachments === undefined) {
throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`)
}
if (!attachments.imageLimits.mediaTypes.includes(mediaType)) {
throw new Error(`cannot read "${args.file_path}": ${mediaType} images are not accepted by this deployment`)
}
await assertImageCapableRoute(ctx, exec, args.file_path)
const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path)
// The tool result is one message carrying one image, so the per-message
// aggregate bound applies beside the per-image bound.
const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes)
const data = await ctx.fs.readBytes(target, exec.signal, byteCap)
// Persist before returning: the image block must reference a durably
// committed object by the time the tool/result event is appended.
let ref: ImageAttachmentRef
try {
ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) })
} catch (error: unknown) {
if (!(error instanceof AttachmentError) || error.code !== 'IMAGE_TYPE_MISMATCH') throw error
const extension = extname(target.displayPath).toLowerCase()
throw new Error(
`cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`,
{ cause: error },
)
}
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
const value: ImageReadValue = {
path: target.displayPath,
image: {
attachmentId: ref.attachmentId,
mediaType: ref.mediaType,
bytes: ref.bytes,
width: ref.width,
height: ref.height,
...ref.name === undefined ? {} : { name: ref.name },
},
}
if (exec.parent !== undefined) {
exec.deferContext(createUserMessage({
content: imageReadContent(value),
source: { kind: 'plugin', plugin: 'tool-fs' },
}))
}
return value
},
// Pure display: a generic card in the read family with a follow-along
// location on the image file.
presentCall(args): GenericCallView {
return {
card: 'generic',
title: `Read image ${args.file_path}`,
kind: 'read',
locations: [{ path: args.file_path }],
}
},
}))
}

View File

@@ -0,0 +1,34 @@
/**
* Shared path resolution and regular-file validation for model-facing read tools.
* @module @deepseek-ai/dsh-tool-fs/src/read-target
*/
import type { Context } from '@deepseek-ai/cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { sessionResolveOptions } from './session-cwd.ts'
/**
* Resolve a model-supplied path, observe absence, and require a regular file.
* @param ctx - the plugin context providing filesystem resolution and observation events.
* @param exec - the current tool execution, including session cwd and cancellation.
* @param requestedPath - the raw path supplied to the tool.
* @returns the resolved target and its single stat result.
*/
export async function resolveRegularReadTarget(
ctx: Context,
exec: ToolExecution,
requestedPath: string,
): Promise<{ target: FsTarget; info: FsInfo }> {
const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath))
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (info.type !== 'file') {
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
return { target, info }
}

View File

@@ -7,11 +7,10 @@
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ReadResultView, ToolResult } 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 { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import { resolveRegularReadTarget } from './read-target.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
export const READ_LIMIT = 2000
@@ -136,16 +135,9 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
isConcurrencySafe: () => true,
async execute(args, exec) {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: absence observation OR type check + size routing + present version.
// A concurrent write can only make a later guarded mutation fail stale and require reread.
const info = await ctx.fs.stat(target, exec.signal)
if (!info) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
const { target, info } = await resolveRegularReadTarget(ctx, exec, input.filePath)
// Stream when the file is large OR size is unknown, so a size-less backend
// never buffers an arbitrarily large file.

View File

@@ -0,0 +1,499 @@
/**
* The `read_image` tool over the REAL local filesystem and attachment store:
* extension routing, the strict image-modality gate (every refusal arm),
* durable commit + image-block rendering, attachment admission failures, and
* the regression that `read` keeps its text-only contract.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import {
applyReadImageTool,
formatImageReadOutput,
imageMediaTypeForPath,
imageRefFromValue,
} from '../src/read-image.ts'
/** 1x1 red PNG (valid signature, IHDR, IDAT). */
const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')
/** 3x3 red PNG used to trip a tiny configured pixel limit. */
const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64')
const testToolSignal = new AbortController().signal
/** Exact-route fake adapter; `stream` is unreachable in these tests. */
class CatalogAdapter extends LlmAdapter {
constructor(
private readonly models: LlmModelInfo[],
private readonly resolvedModels: LlmModelInfo[] = models,
) {
super()
}
override listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve(this.models)
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const resolved = this.resolvedModels.find(candidate => candidate.id === model)
return Promise.resolve({
provider,
id: model,
name: resolved?.name ?? model,
...resolved?.inputModalities === undefined ? {} : { inputModalities: [...resolved.inputModalities] },
})
}
override stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('read_image tests never stream')
}
}
/** In-process Code Mode seam fake that invokes the real registry bindings. */
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
behavior: (request: CodeRunRequest) => Promise<CodeRunResult> = () => Promise.resolve({ logs: [] })
run(request: CodeRunRequest): Promise<CodeRunResult> {
return this.behavior(request)
}
}
let dir: string
let home: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-read-image-'))
home = await mkdtemp(join(tmpdir(), 'dsh-read-image-home-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
})
interface SetupOptions {
models?: LlmModelInfo[]
resolvedModels?: LlmModelInfo[]
attachments?: boolean
llm?: boolean
storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number }
toolMode?: ToolConfig['mode']
}
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' })
if (options.toolMode === 'code' || options.toolMode === 'both') {
await ctx.plugin(FakeRuntime)
}
await ctx.plugin(LocalFileSystem, { cwd: dir })
await ctx.plugin(FsPolicy)
if (options.attachments !== false) {
await ctx.plugin(LocalAttachmentStore, { dshHome: home, ...options.storeConfig })
}
if (options.llm !== false) {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['visual'], new CatalogAdapter(options.models ?? [
{ provider: 'visual', id: 'vision-model', name: 'Vision', inputModalities: ['text', 'image'] },
{ provider: 'visual', id: 'text-model', name: 'Text', inputModalities: ['text'] },
{ provider: 'visual', id: 'legacy-model', name: 'Legacy' },
], options.resolvedModels))
}
await ctx.plugin(ToolFs)
return ctx
}
/** A fake calling agent pinned to one routed provider/model. */
function agentOn(model: string | undefined, provider = 'visual'): object {
return {
options: {},
session: {
header: { cwd: dir },
requestHeader: () => (model === undefined ? undefined : { config: { provider, model } }),
append: () => undefined,
},
}
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: object) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`img-call-${++callCounter}`),
name,
arguments: args,
...agent ? { agent: agent as never } : {},
})
}
function readImage(ctx: Context, args: unknown, agent?: object) {
return call(ctx, 'read_image', args, agent)
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('imageMediaTypeForPath', () => {
it('maps the four extensions case-insensitively and rejects everything else', () => {
expect(imageMediaTypeForPath('a.png')).toBe('image/png')
expect(imageMediaTypeForPath('a.JPG')).toBe('image/jpeg')
expect(imageMediaTypeForPath('b.jpeg')).toBe('image/jpeg')
expect(imageMediaTypeForPath('c.webp')).toBe('image/webp')
expect(imageMediaTypeForPath('d.Gif')).toBe('image/gif')
expect(imageMediaTypeForPath('note.txt')).toBeUndefined()
expect(imageMediaTypeForPath('png')).toBeUndefined()
})
})
describe('imageRefFromValue', () => {
it('re-brands with and without the optional display name', () => {
const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }
expect(imageRefFromValue(base)).toEqual(base)
expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' })
})
})
describe('read_image happy path', () => {
it('commits the bytes durably and renders the envelope beside an image block', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup()
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(false)
expect(result.content).toHaveLength(2)
const image = result.content[1] as { type: string; attachment: ImageAttachmentRef }
expect(image.type).toBe('image')
expect(image.attachment.mediaType).toBe('image/png')
expect(image.attachment.width).toBe(1)
expect(image.attachment.height).toBe(1)
expect(image.attachment.bytes).toBe(PNG_1X1.length)
expect(image.attachment.name).toBe('red.png')
expect(image.attachment.attachmentId).toMatch(/^sha256:[0-9a-f]{64}$/)
expect(text(result)).toBe(formatImageReadOutput(join(dir, 'red.png'), {
attachmentId: image.attachment.attachmentId,
mediaType: 'image/png',
bytes: PNG_1X1.length,
width: 1,
height: 1,
}))
// The committed object must read back verbatim through the store.
const attachments = ctx.get('attachments')
if (attachments === undefined) throw new Error('expected the attachment service')
const stored = await attachments.readImage(image.attachment)
expect(Buffer.from(stored.data)).toEqual(PNG_1X1)
})
it('emits fs/observed for the read image', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup()
const observed: string[] = []
ctx.on('fs/observed', target => void observed.push(target.displayPath))
await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(observed).toEqual([join(dir, 'red.png')])
})
it('falls back to agent options when no request header exists yet', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup()
const agent = {
options: { provider: 'visual', model: 'vision-model' },
session: { header: { cwd: dir }, requestHeader: () => undefined },
}
const result = await readImage(ctx, { file_path: 'red.png' }, agent)
expect(result.isError).toBe(false)
})
it('forwards a nested Code Mode image through the outer run_code context', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ toolMode: 'code' })
const runtime = ctx.codeRuntime as FakeRuntime
runtime.behavior = async (request) => {
const value = await request.bindings[0]!.functions.read_image!({ file_path: 'red.png' })
return { logs: [], value }
}
const result = await call(ctx, RUN_CODE_NAME, {
code: 'return await tools.read_image({ file_path: "red.png" })',
description: 'Read the image through Code Mode',
}, agentOn('vision-model'))
expect(result.isError).toBe(false)
expect(result.content.every(block => block.type === 'text')).toBe(true)
expect(result.additionalContexts).toHaveLength(1)
const forwarded = result.additionalContexts?.[0]?.content
expect(forwarded).toHaveLength(2)
expect(forwarded?.[0]?.type).toBe('text')
expect(forwarded?.[0]?.type === 'text' ? forwarded[0].text : '').toContain('<type>image</type>')
expect(forwarded?.[1]).toMatchObject({
type: 'image',
attachment: { mediaType: 'image/png', width: 1, height: 1 },
})
})
})
describe('strict image-modality gate', () => {
it('accepts an exact visual route even when the advisory model catalog omits it', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({
models: [],
resolvedModels: [
{ provider: 'visual', id: 'hidden-vision', name: 'Hidden Vision', inputModalities: ['text', 'image'] },
],
})
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('hidden-vision'))
expect(result.isError).toBe(false)
})
it.each([
['a text-only model', 'text-model'],
['a model without declared modalities', 'legacy-model'],
['a model absent from the catalog', 'unknown-model'],
])('refuses on %s', async (_label, model) => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup()
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn(model))
expect(result.isError).toBe(true)
expect(text(result)).toContain('does not declare image input')
})
it('refuses when the route cannot be resolved (no agent, or no header and no options)', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup()
const noAgent = await readImage(ctx, { file_path: 'red.png' })
expect(noAgent.isError).toBe(true)
expect(text(noAgent)).toContain('route could not be resolved')
const noRoute = await readImage(ctx, { file_path: 'red.png' }, agentOn(undefined))
expect(noRoute.isError).toBe(true)
expect(text(noRoute)).toContain('route could not be resolved')
})
it('refuses when no llm service is mounted', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ llm: false })
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('route could not be resolved')
})
})
describe('argument and service preconditions', () => {
it('rejects an empty path and a non-image extension', async () => {
const ctx = await setup()
const empty = await readImage(ctx, { file_path: ' ' }, agentOn('vision-model'))
expect(empty.isError).toBe(true)
expect(text(empty)).toContain('non-empty')
const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model'))
expect(nonImage.isError).toBe(true)
expect(text(nonImage)).toContain('only accepts PNG/JPEG/WebP/GIF paths')
})
it('refuses when no attachment service is mounted', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ attachments: false })
expect(ctx.tools.get('read_image')).toBeUndefined()
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('read_image')
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('unknown tool "read_image"')
})
it('defensively refuses execution without an attachment service', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ attachments: false })
applyReadImageTool(ctx)
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('no attachment service is mounted')
})
it('refuses a media type the deployment does not accept', async () => {
/** Store whose deployment accepts JPEG only. */
class JpegOnlyStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = Object.freeze({
maxImageBytes: 1024,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1024,
maxImagePixels: 100,
mediaTypes: Object.freeze(['image/jpeg'] as const),
})
validateImage(_input: SaveImageAttachment): Promise<void> {
throw new Error('unreachable: admission refuses before validation')
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
throw new Error('unreachable: admission refuses before save')
}
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
throw new Error('unreachable in this test')
}
}
const ctx = await setup({ attachments: false })
await ctx.plugin(JpegOnlyStore)
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('image/png images are not accepted by this deployment')
})
})
describe('image admission failures', () => {
it('explains how to repair a declared/actual media-type mismatch', async () => {
await writeFile(join(dir, 'wrong.jpg'), PNG_1X1)
const ctx = await setup()
const result = await readImage(ctx, { file_path: 'wrong.jpg' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('the .jpg extension declares image/jpeg')
expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats')
})
it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } })
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('exceeds')
})
it('honors the tighter per-message aggregate byte bound', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ storeConfig: { maxMessageImageBytes: PNG_1X1.length - 1 } })
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
expect(text(result)).toContain('exceeds')
})
it('surfaces the pixel limit from the attachment admission', async () => {
await writeFile(join(dir, 'big.png'), PNG_3X3)
const ctx = await setup({ storeConfig: { maxImagePixels: 4 } })
const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model'))
expect(result.isError).toBe(true)
})
it('reports a missing image file and a directory target through the fs vocabulary', async () => {
await mkdir(join(dir, 'folder.png'))
const ctx = await setup()
const observed: { path: string; kind: string }[] = []
ctx.on('fs/observed', (target, observation) => void observed.push({ path: target.displayPath, kind: observation.kind }))
const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model'))
expect(missing.isError).toBe(true)
expect(text(missing)).toContain('not found')
expect(observed).toEqual([{ path: join(dir, 'absent.png'), kind: 'absent' }])
const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model'))
expect(directory.isError).toBe(true)
expect(text(directory)).toContain('not a regular file')
})
it('omits the display name when the store returns a reference without one', async () => {
/** Store echoing a fixed nameless reference; deployments may strip names entirely. */
class NamelessStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = Object.freeze({
maxImageBytes: 1024,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1024,
maxImagePixels: 100,
mediaTypes: Object.freeze(['image/png'] as const),
})
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.resolve()
}
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }
}
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
throw new Error('unreachable in this test')
}
}
await writeFile(join(dir, 'red.png'), PNG_1X1)
const ctx = await setup({ attachments: false })
await ctx.plugin(NamelessStore)
const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
expect(result.isError).toBe(false)
const image = result.content[1] as { attachment: ImageAttachmentRef }
expect(image.attachment.name).toBeUndefined()
})
})
describe('registration surface', () => {
it('withdraws read_image when the tool-fs fiber or the attachment store is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'native' })
await ctx.plugin(LocalFileSystem, { cwd: dir })
await ctx.plugin(FsPolicy)
const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home })
const toolFsFiber = await ctx.plugin(ToolFs)
const names = () => ctx.tools.schemas().map(schema => schema.name).sort()
expect(names()).toEqual(['edit', 'read', 'read_image', 'write'])
// Disposing only the attachment store tears down the scoped inject fiber:
// read_image withdraws while the unconditional tools stay registered.
await attachmentsFiber.dispose()
expect(names()).toEqual(['edit', 'read', 'write'])
// Remounting the store restores the conditional registration.
const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home })
expect(names()).toEqual(['edit', 'read', 'read_image', 'write'])
// Disposing the whole plugin withdraws every tool, read_image included.
await toolFsFiber.dispose()
expect(names()).toEqual([])
await remounted.dispose()
})
it('declares read_image parallel-safe and presents a read-family card', async () => {
const ctx = await setup()
expect(ctx.tools.executionMode({
signal: testToolSignal, callId: CallId('img-parallel'), name: 'read_image', arguments: { file_path: 'a.png' },
})).toEqual({ kind: 'parallel' })
expect(ctx.tools.get('read_image')?.presentCall?.({ file_path: 'shot.png' })).toEqual({
card: 'generic',
title: 'Read image shot.png',
kind: 'read',
locations: [{ path: 'shot.png' }],
})
})
})
describe('read keeps its text-only contract', () => {
it('still refuses a PNG as a binary file and line-numbers text', async () => {
await writeFile(join(dir, 'red.png'), PNG_1X1)
await writeFile(join(dir, 'note.txt'), 'hello\nworld')
const ctx = await setup()
const png = await call(ctx, 'read', { file_path: 'red.png' }, agentOn('vision-model'))
expect(png.isError).toBe(true)
expect(text(png)).toContain('binary file')
const txt = await call(ctx, 'read', { file_path: 'note.txt' }, agentOn('text-model'))
expect(txt.isError).toBe(false)
expect(text(txt)).toContain('1: hello')
expect(text(txt)).toContain('<type>file</type>')
})
})

View File

@@ -71,6 +71,13 @@ class FakeFs extends FileSystem {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const bytes = new TextEncoder().encode(this.files.get(target.targetKey) ?? '')
if (bytes.length > maxBytes) {
throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE')
}
return bytes
}
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
return []
}

View File

@@ -41,6 +41,9 @@
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../attachment/attachment"
}
]
}

View File

@@ -432,6 +432,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>',
jsDoc: '/**\n * Stream the whole regular text file as decoded text chunks (same text\n * semantics as {@link readText}, for large files). The backend owns\n * cross-chunk UTF-8 decoding and binary rejection so the policy layer never\n * touches raw bytes.\n * @param target - the resolved target to read.\n * @param signal - aborts the stream, including between chunks.\n * @returns the chunk iterable, decoded and validated like {@link readText}.\n */',
},
{
signature: 'abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>',
jsDoc: '/**\n * Read the whole regular file as raw bytes with no decoding or binary\n * rejection. The bound lives at this seam so a backend can never buffer an\n * unbounded file: a target known or discovered to exceed `maxBytes` fails\n * with `FS_TOO_LARGE` instead of returning a truncated result.\n * @param target - the resolved target to read.\n * @param signal - aborts the read.\n * @param maxBytes - inclusive byte cap on the complete content.\n * @returns the full raw content, at most `maxBytes` long.\n */',
},
{
signature: 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>',
jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */',

View File

@@ -96,6 +96,10 @@ class TestFileSystem extends FileSystem {
throw new Error('not needed in skill tests')
}
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
throw new Error('not needed in skill tests')
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
this.listDirCalls += 1
if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')

View File

@@ -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/support/llm-replay/README.md
README.md: ae7cb5414a28bcf33f9878f6f02220861df9a0d5
README.zh.md: 2acc0ff8e8c011126452ba3458aaeb0800d9d8f3
README.md: 6119407c06cf734166300f5f70a3fe65d026d08d
README.zh.md: 75d74b654a9a53e7ada5cc854e275eadcbe01284

View File

@@ -29,7 +29,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and an `inputModalities` array containing only `text` and `image`; invalid modalities fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. |
| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. |
```yaml

View File

@@ -29,7 +29,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
| `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env。 |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 |
| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow` 和仅包含 `text``image``inputModalities` 数组;模态配置无效时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
| `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSEServer-Sent Events多路复用器看到真正的增量传递。它只是仿真开关测试不得依赖它保证正确性。值必须是非负整数pace 等待期间中止会迅速取消流。 |
```yaml

View File

@@ -19,6 +19,7 @@ import type {
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
ModelModality,
ResolvedRetryPolicy,
RetryPolicyConfig,
StreamChunk,
@@ -51,6 +52,8 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
/** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */
inputModalities?: readonly ModelModality[]
/**
* Optional per-request output cap the replay route materializes when callers
* omit one, so replay reconstructs the request header a live catalog produced.
@@ -581,6 +584,7 @@ class ReplayAdapter extends LlmAdapter {
id: model.id,
name: model.name ?? model.id,
...model.description === undefined ? {} : { description: model.description },
...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
})))
}
@@ -594,6 +598,9 @@ class ReplayAdapter extends LlmAdapter {
id: model,
name: configuredModel?.name ?? model,
...configuredModel?.description === undefined ? {} : { description: configuredModel.description },
...configuredModel?.inputModalities === undefined
? {}
: { inputModalities: [...configuredModel.inputModalities] },
...configuredModel?.contextWindow === undefined
? {}
: { context: { contextWindow: configuredModel.contextWindow } },
@@ -783,11 +790,28 @@ export interface Config {
paceMs?: number
}
function validateConfiguredModalities(providers: ReplayProviderConfig[] | undefined): void {
for (const provider of providers ?? []) {
for (const model of provider.models ?? []) {
const modalities: unknown = model.inputModalities
if (modalities === undefined) continue
if (!Array.isArray(modalities)
|| !modalities.every((modality: unknown) => modality === 'text' || modality === 'image')) {
throw new Error(
`llm-replay: provider "${provider.id}" model "${model.id}" inputModalities `
+ 'must be an array containing only "text" and "image"',
)
}
}
}
}
export function apply(ctx: Context, config: Config = {}): void {
const file = config.file ?? process.env.DSH_SNAPSHOT_FILE
if (file === undefined || file.length === 0) {
throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
}
validateConfiguredModalities(config.providers)
const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES
const childFiles = config.childFiles

View File

@@ -7,6 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { CompactionId } from '@deepseek-ai/dsh-compact'
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
import {
type Config,
type ReplayEntry,
type SessionScript,
apply,
@@ -595,6 +596,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
{
id: 'flash',
contextWindow: 128_000,
inputModalities: ['text', 'image'],
defaultMaxTokens: 64_000,
reasoningEfforts: ['off', 'max'],
defaultReasoningEffort: 'max',
@@ -611,18 +613,20 @@ describe('installLlmReplay (through the real LlmService)', () => {
{ id: 'empty', name: 'empty' },
])
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'flash', name: 'flash' },
{ provider: 'deepseek', id: 'flash', name: 'flash', inputModalities: ['text', 'image'] },
{ provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' },
])
await expect(ctx.llm.listModels('empty')).resolves.toEqual([])
await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({
context: { contextWindow: 128_000 },
inputModalities: ['text', 'image'],
defaultMaxTokens: 64_000,
reasoning: {
efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }],
defaultEffort: 'max',
},
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('inputModalities')
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context')
// Efforts without a configured default preserve the provider's own default.
await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({
@@ -1111,11 +1115,31 @@ describe('apply (the plugin entry)', () => {
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 })
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }])
apply(ctx, {
file,
providers: [
{ id: 'm', models: [{ id: 'm', inputModalities: ['image'] }, { id: 'text' }] },
{ id: 'empty' },
],
paceMs: 1,
})
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }, { id: 'empty', name: 'empty' }])
await expect(ctx.llm.resolveModelInfo('m', 'm')).resolves.toMatchObject({ inputModalities: ['image'] })
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})
it.each([
['a string', 'image'],
['an unknown modality', ['audio']],
])('rejects inputModalities configured as %s during load', (_case, inputModalities) => {
const ctx = new Context()
const providers = [{ id: 'm', models: [{ id: 'm', inputModalities }] }] as unknown as
NonNullable<Config['providers']>
expect(() => { apply(ctx, { file, providers }) }).toThrow(
'llm-replay: provider "m" model "m" inputModalities must be an array containing only "text" and "image"',
)
})
it('falls back to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE when config is empty', async () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')

6
pnpm-lock.yaml generated
View File

@@ -422,6 +422,9 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:*
version: link:../packages/boot/app-boot
'@deepseek-ai/dsh-attachment-local':
specifier: workspace:*
version: link:../packages/attachment/attachment-local
'@deepseek-ai/dsh-bash':
specifier: workspace:*
version: link:../packages/bash/bash
@@ -3886,6 +3889,9 @@ importers:
'@deepseek-ai/dsh-agent-loop-testkit':
specifier: workspace:^
version: link:../../support/agent-loop-testkit
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../fs

View File

@@ -462,6 +462,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
'Promise',
'Record',
'Readonly',
'Uint8Array',
])
/** Project types deliberately documented outside the subsystems catalog. */

View File

@@ -24,6 +24,8 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import WebService from '@deepseek-ai/dsh-web'
@@ -60,6 +62,29 @@ import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
class CatalogAttachmentStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = Object.freeze({
maxImageBytes: 1,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1,
maxImagePixels: 1,
mediaTypes: Object.freeze(['image/png'] as const),
})
override validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest'))
}
override saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest'))
}
override readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
return Promise.reject(new Error('gen-tool-catalog: attachment reads are unreachable during schema harvest'))
}
}
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog.md'
@@ -265,16 +290,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-fs',
dir: 'tool-fs',
source: 'packages/fs/tool-fs/src/index.ts',
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'],
async mount(ctx) {
// The tool needs `fs`; the bare provider is sufficient because policy
// changes behavior, not schema shape.
// changes behavior, not schema shape. The catalog seam marker opts into
// the attachments-conditional read_image schema without attachment I/O.
await ctx.plugin(LocalFileSystem)
await ctx.plugin(CatalogAttachmentStore)
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. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs-search',