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:
@@ -238,6 +238,43 @@ describe('readText / streamText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readBytes', () => {
|
||||
it('reads raw bytes without decoding or NUL rejection', async () => {
|
||||
const raw = Buffer.from([0x68, 0x00, 0x69, 0xff])
|
||||
await writeFile(join(dir, 'a.bin'), raw)
|
||||
expect(Buffer.from(await fs.readBytes(await fs.resolve('a.bin'), undefined, raw.length))).toEqual(raw)
|
||||
})
|
||||
|
||||
it('accepts a file exactly at maxBytes and rejects one past it', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
|
||||
const target = await fs.resolve('a.bin')
|
||||
expect((await fs.readBytes(target, undefined, 4)).length).toBe(4)
|
||||
await expect(fs.readBytes(target, undefined, 3)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('bounds content I/O when a file grows after stat preflight', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1))
|
||||
const target = await fs.resolve('a.bin')
|
||||
fs.internals.inspectReadBytesAfterStat = () => writeFile(join(dir, 'a.bin'), Buffer.alloc(1024 * 1024, 2))
|
||||
|
||||
await expect(fs.readBytes(target, undefined, 4)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(fs.readBytes(await fs.resolve('nope'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(fs.readBytes(await fs.resolve('.'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('reads under a live signal and rejects an already-aborted one with FS_ABORTED', async () => {
|
||||
await writeFile(join(dir, 'a.bin'), 'data')
|
||||
const live = new AbortController()
|
||||
expect((await fs.readBytes(await fs.resolve('a.bin'), live.signal, 1024)).length).toBe(4)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(fs.readBytes(await fs.resolve('a.bin'), controller.signal, 1024)).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDir', () => {
|
||||
it('lists files and directories in stable name order with resolved child targets', async () => {
|
||||
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
|
||||
|
||||
Reference in New Issue
Block a user