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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 影响
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user