Skip to content

Commit e2eaccf

Browse files
maruakshayclaude
andcommitted
feat(tools): batch edits, process-tree kill, abort propagation, image read
- edit_file: optional `edits[]` for atomic multi-edit (resolve on original, reject overlaps, apply right-to-left). Reuses fuzzy match + nearMiss. - run_bash: own timeout with process-tree kill (POSIX detached group / Windows taskkill /T) so forked grandchildren can't outlive the call; `all:true` restores stdout/stderr interleave order. - ToolContext threads the turn AbortSignal into handlers; Ctrl-C now kills the running command tree mid-flight. - read_file: returns image files (png/jpg/gif/webp/bmp) as base64 attachments for vision models instead of refusing as binary; 8MB cap. Adapter injects a user message carrying the pixels after the tool message (Ollama only honors images on user messages). - JsonSchema gains `items` for array-of-object params. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 92e2b1c commit e2eaccf

9 files changed

Lines changed: 311 additions & 36 deletions

File tree

src/agent/adapter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export function toOllamaMessages(history: MiiMessage[], system: string): OllamaM
4343
const texts = msg.content.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
4444
for (const tr of tool_results) {
4545
out.push({ role: 'tool', content: tr.content, tool_call_id: tr.tool_use_id })
46+
// Ollama only honours `images` on user messages, not tool messages — so a
47+
// tool that returned pixels (read_file on an image) gets a follow-up user
48+
// message carrying the base64 for the vision model to actually see.
49+
if (tr.images && tr.images.length > 0) {
50+
out.push({ role: 'user', content: 'Image content from the previous tool result:', images: tr.images })
51+
}
4652
}
4753
if (texts.length > 0) {
4854
out.push({ role: 'user', content: texts.map((t) => t.text).join('') })

src/agent/loop.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,12 +405,13 @@ export async function* runAgent(opts: RunAgentOpts): AsyncGenerator<AgentEvent,
405405
try { await hooks?.firePre(use) } catch { /* hook error ignored */ }
406406
let r: ToolResultBlock
407407
try {
408-
const out = await tool.handler(use.input)
408+
const out = await tool.handler(use.input, { signal })
409409
r = {
410410
type: 'tool_result',
411411
tool_use_id: use.id,
412412
content: out.content,
413413
is_error: out.is_error,
414+
...(out.images && out.images.length > 0 ? { images: out.images } : {}),
414415
}
415416
} catch (err) {
416417
r = {

src/agent/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface ToolResultBlock {
1515
tool_use_id: string
1616
content: string
1717
is_error?: boolean
18+
/** Base64 images produced by the tool; surfaced to the model as a user message. */
19+
images?: string[]
1820
}
1921

2022
export type ContentBlock = TextBlock | ToolUse | ToolResultBlock

src/tools/edit_file.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
22
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs'
33
import { join, relative } from 'path'
4-
import { edit_file, fuzzyRange, similarity } from './edit_file.js'
4+
import { edit_file, fuzzyRange, similarity, applyBatch } from './edit_file.js'
55

66
describe('similarity', () => {
77
it('is 1 for identical (ws-trimmed) strings', () => {
@@ -34,6 +34,47 @@ describe('fuzzyRange', () => {
3434
})
3535
})
3636

37+
describe('applyBatch', () => {
38+
it('applies multiple non-overlapping edits atomically', () => {
39+
const src = 'const a = 1\nconst b = 2\nconst c = 3\n'
40+
const r = applyBatch(src, [
41+
{ old_str: 'a = 1', new_str: 'a = 10' },
42+
{ old_str: 'c = 3', new_str: 'c = 30' },
43+
])
44+
expect('out' in r).toBe(true)
45+
if ('out' in r) {
46+
expect(r.count).toBe(2)
47+
expect(r.out).toBe('const a = 10\nconst b = 2\nconst c = 30\n')
48+
}
49+
})
50+
51+
it('errors and applies nothing when one edit does not match', () => {
52+
const src = 'x = 1\ny = 2\n'
53+
const r = applyBatch(src, [
54+
{ old_str: 'x = 1', new_str: 'x = 9' },
55+
{ old_str: 'z = 3', new_str: 'z = 9' },
56+
])
57+
expect('error' in r).toBe(true)
58+
if ('error' in r) expect(r.error).toMatch(/edits\[1\].*not found/)
59+
})
60+
61+
it('rejects a non-unique old_str', () => {
62+
const r = applyBatch('dup\ndup\n', [{ old_str: 'dup', new_str: 'x' }])
63+
expect('error' in r).toBe(true)
64+
if ('error' in r) expect(r.error).toMatch(/not unique/)
65+
})
66+
67+
it('rejects overlapping edits', () => {
68+
const src = 'abcdef'
69+
const r = applyBatch(src, [
70+
{ old_str: 'abcd', new_str: 'X' },
71+
{ old_str: 'cdef', new_str: 'Y' },
72+
])
73+
expect('error' in r).toBe(true)
74+
if ('error' in r) expect(r.error).toMatch(/overlap/)
75+
})
76+
})
77+
3778
describe('edit_file handler', () => {
3879
let dir: string
3980
const rel = (abs: string) => relative(process.cwd(), abs)
@@ -90,6 +131,33 @@ describe('edit_file handler', () => {
90131
expect(readFileSync(join(dir, 'a.py'), 'utf-8')).toBe('def f():\n\tfoo = 2\n')
91132
})
92133

134+
it('applies a batch of edits via the edits[] param', async () => {
135+
const p = seed('a.txt', 'one\ntwo\nthree\n')
136+
const out = await edit_file.handler({
137+
path: p,
138+
edits: [
139+
{ old_str: 'one', new_str: '1' },
140+
{ old_str: 'three', new_str: '3' },
141+
],
142+
})
143+
expect(out.is_error).toBeFalsy()
144+
expect(out.content).toMatch(/2 edits/)
145+
expect(readFileSync(join(dir, 'a.txt'), 'utf-8')).toBe('1\ntwo\n3\n')
146+
})
147+
148+
it('writes nothing when a batch edit fails to match', async () => {
149+
const p = seed('a.txt', 'alpha\nbeta\n')
150+
const out = await edit_file.handler({
151+
path: p,
152+
edits: [
153+
{ old_str: 'alpha', new_str: 'A' },
154+
{ old_str: 'gamma', new_str: 'G' },
155+
],
156+
})
157+
expect(out.is_error).toBe(true)
158+
expect(readFileSync(join(dir, 'a.txt'), 'utf-8')).toBe('alpha\nbeta\n')
159+
})
160+
93161
it('on no match, returns the closest text in the file', async () => {
94162
const p = seed('a.txt', 'const alpha = 1\nconst beta = 2\n')
95163
const out = await edit_file.handler({ path: p, old_str: 'const alph = 1', new_str: 'x' })

src/tools/edit_file.ts

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import { confinePath } from './paths.js'
33
import { verifyHint } from './verifyHint.js'
44
import type { Tool } from './types.js'
55

6-
interface Input {
7-
path: string
6+
interface EditSpec {
87
old_str: string
98
new_str: string
9+
}
10+
11+
interface Input {
12+
path: string
13+
old_str?: string
14+
new_str?: string
1015
replace_all?: boolean
16+
/** Batch mode: apply several exact-string edits atomically in one call. */
17+
edits?: EditSpec[]
1118
}
1219

1320
/** Cheap line-similarity: fraction of matching chars by position, ignoring leading/trailing ws. */
@@ -90,22 +97,96 @@ function nearMiss(src: string, old_str: string): string {
9097
return `\nClosest text in file (lines ${from + 1}-${to}):\n${ctx}`
9198
}
9299

100+
/**
101+
* Find the unique char range of `old_str` in `src`: exact match first, then a
102+
* whitespace-tolerant fuzzy match. Used by batch mode, which has no replace_all
103+
* — every edit must resolve to exactly one location. Returns the range or a
104+
* reason string explaining why it couldn't (with the closest text on no match).
105+
*/
106+
function locate(src: string, old_str: string): [number, number] | { error: string } {
107+
const first = src.indexOf(old_str)
108+
if (first !== -1) {
109+
if (src.indexOf(old_str, first + 1) !== -1) {
110+
return { error: `old_str not unique — add surrounding context to disambiguate.` }
111+
}
112+
return [first, first + old_str.length]
113+
}
114+
const fuzzy = fuzzyRange(src, old_str)
115+
if (fuzzy) return fuzzy
116+
return { error: `old_str not found.${nearMiss(src, old_str)}` }
117+
}
118+
119+
/**
120+
* Batch edit: resolve every edit against the ORIGINAL buffer, reject overlaps,
121+
* then apply right-to-left so earlier offsets stay valid. All-or-nothing — if
122+
* any edit fails to resolve or two edits overlap, nothing is written.
123+
*/
124+
export function applyBatch(src: string, edits: EditSpec[]): { out: string; count: number } | { error: string } {
125+
const ranges: Array<{ start: number; end: number; new_str: string }> = []
126+
for (let i = 0; i < edits.length; i++) {
127+
const { old_str, new_str } = edits[i]
128+
if (typeof old_str !== 'string' || typeof new_str !== 'string') {
129+
return { error: `edits[${i}] must have string old_str and new_str.` }
130+
}
131+
if (old_str === '') return { error: `edits[${i}].old_str is empty.` }
132+
if (old_str === new_str) return { error: `edits[${i}] old_str and new_str are identical — nothing to change.` }
133+
const r = locate(src, old_str)
134+
if (!Array.isArray(r)) return { error: `edits[${i}]: ${r.error}` }
135+
ranges.push({ start: r[0], end: r[1], new_str })
136+
}
137+
const sorted = [...ranges].sort((a, b) => a.start - b.start)
138+
for (let i = 1; i < sorted.length; i++) {
139+
if (sorted[i].start < sorted[i - 1].end) {
140+
return { error: `edits overlap in the file — split them into separate calls or widen the context.` }
141+
}
142+
}
143+
let out = src
144+
for (const r of [...ranges].sort((a, b) => b.start - a.start)) {
145+
out = out.slice(0, r.start) + r.new_str + out.slice(r.end)
146+
}
147+
return { out, count: ranges.length }
148+
}
149+
93150
export const edit_file: Tool<Input> = {
94151
name: 'edit_file',
95152
description:
96-
'Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file.',
153+
'Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file. To make several edits to one file at once, pass an `edits` array of {old_str,new_str} — they apply atomically (all or nothing).',
97154
input_schema: {
98155
type: 'object',
99156
properties: {
100157
path: { type: 'string', description: 'File path' },
101-
old_str: { type: 'string', description: 'Exact text to replace (whitespace-sensitive)' },
102-
new_str: { type: 'string', description: 'Replacement text' },
158+
old_str: { type: 'string', description: 'Exact text to replace (whitespace-sensitive). Omit when using edits[].' },
159+
new_str: { type: 'string', description: 'Replacement text. Omit when using edits[].' },
103160
replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring uniqueness' },
161+
edits: {
162+
type: 'array',
163+
description: 'Batch mode: several edits applied atomically. Each old_str must be unique in the file. Alternative to old_str/new_str.',
164+
items: {
165+
type: 'object',
166+
properties: {
167+
old_str: { type: 'string', description: 'Exact text to replace (whitespace-sensitive)' },
168+
new_str: { type: 'string', description: 'Replacement text' },
169+
},
170+
required: ['old_str', 'new_str'],
171+
},
172+
},
104173
},
105-
required: ['path', 'old_str', 'new_str'],
174+
required: ['path'],
106175
},
107-
handler: ({ path, old_str, new_str, replace_all }) => {
176+
handler: ({ path, old_str, new_str, replace_all, edits }) => {
108177
try {
178+
// Batch mode: resolve + apply all edits atomically against the original.
179+
if (Array.isArray(edits) && edits.length > 0) {
180+
const abs = confinePath(path)
181+
const src = readFileSync(abs, 'utf-8')
182+
const res = applyBatch(src, edits)
183+
if ('error' in res) return { content: `${res.error} (in ${path})`, is_error: true }
184+
writeFileSync(abs, res.out, 'utf-8')
185+
return { content: `Edited ${path} (${res.count} edits).${verifyHint(path)}` }
186+
}
187+
if (typeof old_str !== 'string' || typeof new_str !== 'string') {
188+
return { content: `edit_file needs old_str and new_str (or an edits[] array) for ${path}.`, is_error: true }
189+
}
109190
if (old_str === new_str) {
110191
return {
111192
content: `old_str and new_str are identical — nothing to change in ${path}. If the file is already correct, do NOT edit again: finish with the respond action and tell the user it is done.`,

src/tools/read_file.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,26 @@ function numbered(lines: string[], start: number): string {
1616
.join('\n')
1717
}
1818

19+
// Raster formats a vision model can consume. SVG is intentionally excluded — it
20+
// is text, so it falls through to the normal text path (and is more useful read
21+
// as source anyway).
22+
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'])
23+
// Base64 roughly quadruples size and rides in the prompt; refuse oversized images
24+
// rather than blow the context window.
25+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024
26+
27+
/** Sniff the leading magic bytes so an image with a wrong/missing extension is still caught. */
28+
function looksImage(buf: Buffer): boolean {
29+
if (buf.length < 4) return false
30+
if (buf[0] === 0x89 && buf[1] === 0x50) return true // PNG
31+
if (buf[0] === 0xff && buf[1] === 0xd8) return true // JPEG
32+
if (buf[0] === 0x47 && buf[1] === 0x49) return true // GIF
33+
if (buf[0] === 0x42 && buf[1] === 0x4d) return true // BMP
34+
// WEBP: "RIFF"...."WEBP"
35+
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return true
36+
return false
37+
}
38+
1939
export const read_file: Tool<Input> = {
2040
name: 'read_file',
2141
description:
@@ -33,6 +53,23 @@ export const read_file: Tool<Input> = {
3353
try {
3454
const MAX_CHARS = 200_000
3555
const buf = readFileSync(confinePath(path))
56+
57+
// Image: hand the raw pixels back as a base64 attachment for a vision model
58+
// rather than refusing it as binary. Extension OR magic bytes qualifies.
59+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
60+
if (IMAGE_EXT.has(ext) || looksImage(buf)) {
61+
if (buf.length > MAX_IMAGE_BYTES) {
62+
return {
63+
content: `${path} is an image but too large to attach (${buf.length} bytes > ${MAX_IMAGE_BYTES}). Resize it first.`,
64+
is_error: true,
65+
}
66+
}
67+
return {
68+
content: `[image ${path}${buf.length} bytes, attached for viewing]`,
69+
images: [buf.toString('base64')],
70+
}
71+
}
72+
3673
// Refuse binary — NUL byte in the head is the cheap, reliable signal.
3774
if (buf.subarray(0, 8000).includes(0)) {
3875
return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true }

src/tools/run_bash.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ interface Input {
77
timeout_ms?: number
88
}
99

10+
/**
11+
* Kill the whole process tree rooted at `pid`, not just the direct child.
12+
* execa's built-in timeout only SIGTERMs the shell we spawned — a `bash -c`
13+
* that forked a build/test process leaves that grandchild running. On POSIX we
14+
* spawn detached (child is its own process-group leader) so a negative-pid kill
15+
* takes out the group; on Windows we shell out to taskkill /T.
16+
*/
17+
function killTree(pid: number | undefined, isWin: boolean): void {
18+
if (!pid) return
19+
try {
20+
if (isWin) {
21+
execa('taskkill', ['/pid', String(pid), '/T', '/F'], { reject: false })
22+
} else {
23+
process.kill(-pid, 'SIGKILL')
24+
}
25+
} catch {
26+
/* process already gone — nothing to kill */
27+
}
28+
}
29+
1030
export const run_bash: Tool<Input> = {
1131
name: 'run_bash',
1232
description: 'Execute a shell command (bash on Unix, cmd on Windows). Returns stdout+stderr. Non-interactive only.',
@@ -18,26 +38,50 @@ export const run_bash: Tool<Input> = {
1838
},
1939
required: ['command'],
2040
},
21-
handler: async ({ command, timeout_ms }) => {
41+
handler: async ({ command, timeout_ms }, ctx) => {
42+
const isWin = process.platform === 'win32'
43+
const shell = isWin ? 'cmd' : 'bash'
44+
const shellArgs = isWin ? ['/c', command] : ['-c', command]
45+
const timeout = timeout_ms ?? 120000
46+
47+
// Own timeout + tree-kill instead of execa's `timeout` so a forked grandchild
48+
// process can't outlive the call. `all:true` interleaves stdout/stderr in the
49+
// real order they were written (the old filter+join lost that ordering).
50+
const child = execa(shell, shellArgs, {
51+
reject: false,
52+
all: true,
53+
detached: !isWin, // POSIX: new process group so killTree(-pid) hits the whole tree
54+
})
55+
56+
let timedOut = false
57+
let aborted = false
58+
const timer = setTimeout(() => {
59+
timedOut = true
60+
killTree(child.pid, isWin)
61+
}, timeout)
62+
const onAbort = () => {
63+
aborted = true
64+
killTree(child.pid, isWin)
65+
}
66+
ctx?.signal?.addEventListener('abort', onAbort, { once: true })
67+
2268
try {
23-
const isWin = process.platform === 'win32'
24-
const shell = isWin ? 'cmd' : 'bash'
25-
const shellArgs = isWin ? ['/c', command] : ['-c', command]
26-
const { stdout, stderr, exitCode } = await execa(shell, shellArgs, {
27-
timeout: timeout_ms ?? 120000,
28-
reject: false,
29-
all: false,
30-
})
31-
const out = [stdout, stderr].filter(Boolean).join('\n')
32-
const is_error = exitCode !== 0
69+
const { all, exitCode } = await child
70+
const out = all ?? ''
71+
const is_error = aborted || timedOut || exitCode !== 0
72+
const note = timedOut
73+
? `\n[timed out after ${timeout}ms — process tree killed]`
74+
: aborted
75+
? `\n[aborted — process tree killed]`
76+
: ''
3377
const body = out || (is_error ? `(no output)` : '')
34-
const content = `${spillIfLarge(body, 'command output')}\n[exit ${exitCode}]`
35-
return {
36-
content,
37-
is_error,
38-
}
78+
const content = `${spillIfLarge(body, 'command output')}\n[exit ${exitCode ?? 'killed'}]${note}`
79+
return { content, is_error }
3980
} catch (err) {
4081
return { content: err instanceof Error ? err.message : String(err), is_error: true }
82+
} finally {
83+
clearTimeout(timer)
84+
ctx?.signal?.removeEventListener('abort', onAbort)
4185
}
4286
},
4387
}

0 commit comments

Comments
 (0)