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:
@@ -227,6 +227,24 @@ 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
|
||||
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) })
|
||||
} 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')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
await this.requireRegular(target, signal)
|
||||
|
||||
@@ -471,6 +471,23 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47])
|
||||
remote.dir('/workspace/directory')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('img.bin')
|
||||
expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47])
|
||||
await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE')
|
||||
await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
const live = new AbortController()
|
||||
expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4)
|
||||
remote.nextReadError = new DOMException('aborted', 'AbortError')
|
||||
await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('honors aborts before and during remote reads', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'a')
|
||||
|
||||
Reference in New Issue
Block a user