feat(fs): add a minimal read_image tool over the attachment and fs seams

The model reads a PNG/JPEG/WebP/GIF file, the bytes commit through the
durable attachment lifecycle, and the tool result carries the real
ImageBlock so the image enters context from the next request onward.
FileSystem gains a bounded readBytes primitive (local + E2B providers);
registration is conditional on the attachment store, and a strict
execution gate refuses routes that do not declare image input, so a
text route's durable history stays free of image blocks. llm-replay
models may declare inputModalities, letting keyless ACP snapshots pin
both the sha256-referenced success and the verbatim refusal.

Supersedes the withdrawn route-scoped design of PR #598; the decision
record is .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md.
This commit is contained in:
creatixchu
2026-08-10 15:09:07 +08:00
parent 3764ce62a5
commit 1861a3fc7c
65 changed files with 1973 additions and 67 deletions

View File

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

View File

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