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