fix(review): bound E2B readBytes at the seam, prove conditional-registration disposal, and align read_image contracts

- E2BFileSystem.readBytes now short-circuits on the stat size before any
  content transfer and streams the remote object, cancelling at the first
  chunk past the cap, honoring the seam's bounded-buffering contract; the
  fs-e2b README pair documents the new primitive.
- The tool-fs HMR test now proves the attachments-scoped registration:
  disposing the store withdraws read_image while read/write/edit stay,
  remounting restores it, and disposing the plugin withdraws everything.
- read_image caps reads at the smaller of maxImageBytes and
  maxMessageImageBytes, records an absent observation for a missing
  target like its sibling read, widens the mismatch remedy to cover
  out-of-family formats, and renames the gate's parameter to
  requestedPath; module/apply/registration JSDoc now match the shipped
  composition. zh terminology aligned; the examples manifest keeps its
  literal arrow.
This commit is contained in:
creatixchu
2026-08-10 15:35:33 +08:00
parent 1861a3fc7c
commit 97a9ec5a0e
12 changed files with 141 additions and 39 deletions

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: e295ad63902cbae245229fa86b780aeb59de808d
README.zh.md: be87bb18a1c49654d07977b5c8e2577188517077
README.md: 7e334f886747cd8dc566a572c699cd80c7cf62fe
README.zh.md: b5eb5ae38aba049d77375d31d1509342a23f13fc

View File

@@ -45,7 +45,7 @@ Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], t
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`, a bounded `ctx.fs.readBytes` capped at `imageLimits.maxImageBytes`, `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.)
- **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.)
@@ -155,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`, `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 PNG/JPEG/WebP/GIF format`; 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

View File

@@ -45,7 +45,7 @@ await ctx.plugin(ToolFs) // this package — re
工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `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`、以 `imageLimits.maxImageBytes` 为上限的有界 `ctx.fs.readBytes`、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。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。
@@ -125,7 +125,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
### 图像读取结果
#### 模型看到什么
#### 模型看到的内容
成功的 `read_image` 返回 `<path><displayPath></path>`、`<type>image</type>` 和写明媒体类型、尺寸与字节数的 `<content>` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。
@@ -133,9 +133,9 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。
#### KV 缓存影响
#### KV Cache 影响
追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。
追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。
### 写入与编辑结果
@@ -155,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)`、`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 PNG/JPEG/WebP/GIF format`;提供方和策略模板在各自包的 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 影响

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
@@ -50,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

View File

@@ -60,19 +60,19 @@ export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefi
* 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 displayPath - the path rendered in refusal messages.
* @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages.
*/
export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, displayPath: string): Promise<void> {
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 "${displayPath}" as an image: the current model route could not be resolved`)
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 "${displayPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`)
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`)
}
}
@@ -120,12 +120,13 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] {
}
/**
* Register the `read_image` tool. Execution gates on the optional
* `attachments`/`llm` services and the calling route's declared image input;
* registration itself is unconditional so denial happens at the operation
* boundary rather than through schema omission.
* @param ctx - the plugin context; registrations are effects scoped to it, and
* execution uses its `fs` service plus the optional `attachments`/`llm` services.
* 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({
@@ -180,10 +181,16 @@ export function applyReadImageTool(ctx: Context): void {
const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path))
const info = await ctx.fs.stat(target, exec.signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
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 data = await ctx.fs.readBytes(target, exec.signal, attachments.imageLimits.maxImageBytes)
// 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
@@ -193,7 +200,7 @@ export function applyReadImageTool(ctx: Context): void {
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 PNG/JPEG/WebP/GIF format`,
`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 },
)
}

View File

@@ -93,7 +93,7 @@ interface SetupOptions {
resolvedModels?: LlmModelInfo[]
attachments?: boolean
llm?: boolean
storeConfig?: { maxImageBytes?: number; maxImagePixels?: number }
storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number }
toolMode?: ToolConfig['mode']
}
@@ -366,7 +366,7 @@ describe('image admission failures', () => {
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 PNG/JPEG/WebP/GIF format')
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 () => {
@@ -377,6 +377,14 @@ describe('image admission failures', () => {
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 } })
@@ -387,9 +395,12 @@ describe('image admission failures', () => {
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)
@@ -430,6 +441,32 @@ describe('image admission failures', () => {
})
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'])
void remounted
// Disposing the whole plugin withdraws every tool, read_image included.
await toolFsFiber.dispose()
expect(names()).toEqual([])
})
it('declares read_image parallel-safe and presents a read-family card', async () => {
const ctx = await setup()
expect(ctx.tools.executionMode({