Skip to content

Commit 154bbc6

Browse files
committed
computer: kill the backend when the exec tool turn aborts
The streaming exec tool ignored the turn's abort signal: aborting the model turn abandoned the iteration but left the backend execution running. It also built the not-callable rejection from its own string copy. Wire the abort signal to the handle's kill so an aborted turn stops the backend, removing the listener once the run settles, and reject a non-callable input with the runtime's exported message. Cover the abort path with a test that asserts kill fires.
1 parent 92028ec commit 154bbc6

2 files changed

Lines changed: 130 additions & 61 deletions

File tree

packages/computer/src/tools/ai.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,57 @@ describe("createAITools exec streaming", () => {
988988
stdout: "aaaaaaaa\n\n[truncated, 12 more bytes]",
989989
});
990990
});
991+
992+
it("kills the backend execution when the turn aborts", async () => {
993+
let killed = 0;
994+
const controller = new AbortController();
995+
const workspace = {
996+
runtime: {
997+
async exec() {
998+
return {
999+
async *[Symbol.asyncIterator]() {
1000+
yield { name: "stdout", value: "working\n" } as ExecStreamEvent;
1001+
controller.abort();
1002+
// The abort listener calls kill(); yield once more so
1003+
// the iteration observes the signal before the stream
1004+
// ends on its own.
1005+
yield { name: "exit", code: 130 } as ExecStreamEvent;
1006+
},
1007+
result: async () => {
1008+
throw new Error("result() must not be called on a streamed handle");
1009+
},
1010+
kill: async () => {
1011+
killed += 1;
1012+
},
1013+
};
1014+
},
1015+
},
1016+
};
1017+
const tools = createAITools({
1018+
workspace,
1019+
shell: {
1020+
defaultBackend: "shell",
1021+
backends: { shell: { description: "fast shell" } },
1022+
},
1023+
});
1024+
1025+
const execute = (
1026+
tools.exec as {
1027+
execute: (
1028+
input: unknown,
1029+
options: { toolCallId: string; messages: []; abortSignal: AbortSignal },
1030+
) => AsyncIterable<unknown>;
1031+
}
1032+
).execute;
1033+
const output = execute(
1034+
{ command: "sleep" },
1035+
{ toolCallId: "t", messages: [], abortSignal: controller.signal },
1036+
);
1037+
for await (const _chunk of output) {
1038+
// Drain to completion.
1039+
}
1040+
expect(killed).toBe(1);
1041+
});
9911042
});
9921043

9931044
describe("createAITools publish tool", () => {

packages/computer/src/tools/exec.ts

Lines changed: 79 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type Tool, tool } from "ai";
22
import { z } from "zod";
33

4+
import { notCallableMessage } from "../runtime/runtime.js";
45
import type { WorkspaceRuntimeValue } from "../runtime/types.js";
56

67
// A finite JSON value: what a callable backend accepts as `input` and
@@ -40,6 +41,10 @@ export interface ExecRuntimeHandle extends Partial<AsyncIterable<ExecStreamEvent
4041
stderr: string;
4142
value?: unknown;
4243
}>;
44+
// Signal the running execution. The tool calls it when the model
45+
// turn aborts, so the backend stops rather than running on after
46+
// the tool stops iterating.
47+
kill?(): Promise<void>;
4348
}
4449

4550
export interface ExecWorkspaceLike {
@@ -188,14 +193,11 @@ export function createExecTool(options: ExecToolOptions): Tool<
188193
"Structured value handed to a callable backend's module. Only callable backends accept it; other backends reject it.",
189194
),
190195
}),
191-
execute: async function* ({ command, cwd, backend, env, input }) {
196+
execute: async function* ({ command, cwd, backend, env, input }, { abortSignal }) {
192197
const selectedBackend = backend ?? options.defaultBackend;
193198
const base = { command, cwd: cwd ?? null, backend: selectedBackend };
194199
if (input !== undefined && !callableBackendIds.has(selectedBackend)) {
195-
yield {
196-
...base,
197-
error: `Backend ${JSON.stringify(selectedBackend)} is not callable; it does not accept structured input.`,
198-
};
200+
yield { ...base, error: notCallableMessage(selectedBackend) };
199201
return;
200202
}
201203
let handle: ExecRuntimeHandle;
@@ -212,68 +214,84 @@ export function createExecTool(options: ExecToolOptions): Tool<
212214
return;
213215
}
214216

215-
// Stream stdout / stderr chunks as they arrive when the handle
216-
// is iterable. Each chunk yields a fresh snapshot with the
217-
// running output so the model sees progress before the run
218-
// ends; the exit event settles the terminal snapshot.
219-
if (typeof handle[Symbol.asyncIterator] === "function") {
220-
const stdout = new StreamBuffer(streamMaxBytes);
221-
const stderr = new StreamBuffer(streamMaxBytes);
222-
let exitCode: number | null = null;
223-
let value: unknown;
224-
let hasValue = false;
225-
// Coalesce running snapshots to at most one per interval. A
226-
// chatty command would otherwise yield a full-buffer snapshot
227-
// per chunk; the terminal snapshot below always fires.
228-
let lastSnapshot = 0;
229-
try {
230-
for await (const event of handle as AsyncIterable<ExecStreamEvent>) {
231-
if (event.name === "stdout") stdout.push(event.value);
232-
else if (event.name === "stderr") stderr.push(event.value);
233-
else {
234-
exitCode = event.code;
235-
if ("result" in event) {
236-
value = event.result;
237-
hasValue = true;
217+
// Aborting the model turn kills the backend execution so it does
218+
// not run on unobserved after the tool stops iterating. The run
219+
// then emits its terminal event and the stream closes normally.
220+
const onAbort = () => void handle.kill?.().catch(() => undefined);
221+
if (abortSignal?.aborted) onAbort();
222+
else abortSignal?.addEventListener("abort", onAbort, { once: true });
223+
try {
224+
yield* runExecution();
225+
} finally {
226+
abortSignal?.removeEventListener("abort", onAbort);
227+
}
228+
229+
// Produce the run's snapshots. Streams the raw events when the
230+
// handle is iterable; otherwise drains the aggregate result.
231+
async function* runExecution(): AsyncGenerator<ExecToolOutput> {
232+
// Stream stdout / stderr chunks as they arrive when the handle
233+
// is iterable. Each chunk yields a fresh snapshot with the
234+
// running output so the model sees progress before the run
235+
// ends; the exit event settles the terminal snapshot.
236+
if (typeof handle[Symbol.asyncIterator] === "function") {
237+
const stdout = new StreamBuffer(streamMaxBytes);
238+
const stderr = new StreamBuffer(streamMaxBytes);
239+
let exitCode: number | null = null;
240+
let value: unknown;
241+
let hasValue = false;
242+
// Coalesce running snapshots to at most one per interval. A
243+
// chatty command would otherwise yield a full-buffer snapshot
244+
// per chunk; the terminal snapshot below always fires.
245+
let lastSnapshot = 0;
246+
try {
247+
for await (const event of handle as AsyncIterable<ExecStreamEvent>) {
248+
if (event.name === "stdout") stdout.push(event.value);
249+
else if (event.name === "stderr") stderr.push(event.value);
250+
else {
251+
exitCode = event.code;
252+
if ("result" in event) {
253+
value = event.result;
254+
hasValue = true;
255+
}
256+
continue;
238257
}
239-
continue;
258+
const at = now();
259+
if (at - lastSnapshot < STREAM_COALESCE_MS) continue;
260+
lastSnapshot = at;
261+
yield {
262+
...base,
263+
exitCode: null,
264+
stdout: stdout.render(maxBytes),
265+
stderr: stderr.render(maxBytes),
266+
};
240267
}
241-
const at = now();
242-
if (at - lastSnapshot < STREAM_COALESCE_MS) continue;
243-
lastSnapshot = at;
244-
yield {
245-
...base,
246-
exitCode: null,
247-
stdout: stdout.render(maxBytes),
248-
stderr: stderr.render(maxBytes),
249-
};
268+
} catch (err) {
269+
yield { ...base, error: errorMessage(err) };
270+
return;
250271
}
251-
} catch (err) {
252-
yield { ...base, error: errorMessage(err) };
272+
yield {
273+
...base,
274+
exitCode,
275+
stdout: stdout.render(maxBytes),
276+
stderr: stderr.render(maxBytes),
277+
...(hasValue ? { result: value } : {}),
278+
};
253279
return;
254280
}
255-
yield {
256-
...base,
257-
exitCode,
258-
stdout: stdout.render(maxBytes),
259-
stderr: stderr.render(maxBytes),
260-
...(hasValue ? { result: value } : {}),
261-
};
262-
return;
263-
}
264281

265-
// Non-streaming handle: drain the aggregate result.
266-
try {
267-
const result = await handle.result();
268-
yield {
269-
...base,
270-
exitCode: result.exitCode,
271-
stdout: truncate(result.stdout, maxBytes),
272-
stderr: truncate(result.stderr, maxBytes),
273-
...(result.value === undefined ? {} : { result: result.value }),
274-
};
275-
} catch (err) {
276-
yield { ...base, error: errorMessage(err) };
282+
// Non-streaming handle: drain the aggregate result.
283+
try {
284+
const result = await handle.result();
285+
yield {
286+
...base,
287+
exitCode: result.exitCode,
288+
stdout: truncate(result.stdout, maxBytes),
289+
stderr: truncate(result.stderr, maxBytes),
290+
...(result.value === undefined ? {} : { result: result.value }),
291+
};
292+
} catch (err) {
293+
yield { ...base, error: errorMessage(err) };
294+
}
277295
}
278296
},
279297
});

0 commit comments

Comments
 (0)