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

@@ -3,7 +3,7 @@
"private": true,
"version": "0.0.1",
"type": "module",
"description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.",
"description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exportslib. Not a build target.",
"dependencies": {
"@cordisjs/plugin-hmr": "workspace:*",
"@cordisjs/plugin-include": "workspace:*",

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

@@ -229,20 +229,59 @@ export class E2BFileSystem extends FileSystem {
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
let bytes: Uint8Array
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')
}
let stream: ReadableStream<Uint8Array>
try {
// The E2B files API returns the whole object; the remote sandbox owns
// that buffering, so the seam bound is enforced on the complete result.
bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
// Same pinned-SDK quirk as streamText: 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)
}
assertNotAborted(signal, 'read')
if (bytes.byteLength > maxBytes) {
throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
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.
}
}
}
return bytes
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>> {
@@ -430,10 +469,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

@@ -488,6 +488,22 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
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/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({