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

@@ -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')