mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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 exports→lib. Not a build target.",
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:*",
|
||||
"@cordisjs/plugin-include": "workspace:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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