Skip to content

Commit 8dab330

Browse files
committed
fix(agent-core): skip prepared tool execution after abort
Signed-off-by: Ho Lim <[email protected]>
1 parent f85d438 commit 8dab330

2 files changed

Lines changed: 107 additions & 3 deletions

File tree

packages/agent-core/src/agent-loop.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,98 @@ describe("agentLoop tool termination", () => {
11831183
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
11841184
});
11851185

1186+
it("does not execute a prepared parallel tool after a later tool aborts the batch", async () => {
1187+
const controller = new AbortController();
1188+
const paidExecute = vi.fn(async (): Promise<AgentToolResult<unknown>> => {
1189+
return {
1190+
content: [{ type: "text", text: "charged" }],
1191+
details: { charged: true },
1192+
};
1193+
});
1194+
const abortingExecute = vi.fn(async (): Promise<AgentToolResult<unknown>> => {
1195+
return {
1196+
content: [{ type: "text", text: "should not run" }],
1197+
details: {},
1198+
};
1199+
});
1200+
const paidTool: AgentTool = {
1201+
name: "paid_generation",
1202+
label: "paid_generation",
1203+
description: "Represents a paid side-effectful tool",
1204+
parameters: Type.Object({}, { additionalProperties: false }),
1205+
execute: paidExecute,
1206+
};
1207+
const abortingTool: AgentTool = {
1208+
name: "abort_gate",
1209+
label: "abort_gate",
1210+
description: "Aborts during preparation",
1211+
parameters: Type.Object({}, { additionalProperties: false }),
1212+
execute: abortingExecute,
1213+
};
1214+
const streamFn: StreamFn = () => {
1215+
const stream = createAssistantMessageEventStream();
1216+
queueMicrotask(() => {
1217+
const message = makeAssistantMessage([
1218+
{ type: "toolCall", id: "call-paid", name: paidTool.name, arguments: {} },
1219+
{ type: "toolCall", id: "call-abort", name: abortingTool.name, arguments: {} },
1220+
]);
1221+
stream.push({ type: "done", reason: "toolUse", message });
1222+
stream.end();
1223+
});
1224+
return stream;
1225+
};
1226+
const events: AgentEvent[] = [];
1227+
const preparedToolNames: string[] = [];
1228+
1229+
const messages = await runAgentLoop(
1230+
[{ role: "user", content: "abort mid batch", timestamp: 1 }],
1231+
{
1232+
systemPrompt: "",
1233+
messages: [],
1234+
tools: [paidTool, abortingTool],
1235+
},
1236+
{
1237+
...config,
1238+
beforeToolCall: async ({ toolCall }) => {
1239+
preparedToolNames.push(toolCall.name);
1240+
if (toolCall.name === abortingTool.name) {
1241+
await Promise.resolve();
1242+
controller.abort(new Error("user aborted"));
1243+
}
1244+
return undefined;
1245+
},
1246+
},
1247+
(event) => {
1248+
events.push(event);
1249+
},
1250+
controller.signal,
1251+
streamFn,
1252+
);
1253+
1254+
expect(preparedToolNames).toEqual([paidTool.name, abortingTool.name]);
1255+
expect(paidExecute).not.toHaveBeenCalled();
1256+
expect(abortingExecute).not.toHaveBeenCalled();
1257+
expect(messages.map((message) => message.role)).toEqual([
1258+
"user",
1259+
"assistant",
1260+
"toolResult",
1261+
"toolResult",
1262+
"assistant",
1263+
]);
1264+
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
1265+
1266+
const endEvents = events.filter(
1267+
(event): event is Extract<AgentEvent, { type: "tool_execution_end" }> =>
1268+
event.type === "tool_execution_end",
1269+
);
1270+
expect(
1271+
Object.fromEntries(endEvents.map((event) => [event.toolName, event.executionStarted])),
1272+
).toEqual({
1273+
[paidTool.name]: false,
1274+
[abortingTool.name]: false,
1275+
});
1276+
});
1277+
11861278
it("does not request another model turn when an async turn hook aborts the run", async () => {
11871279
const controller = new AbortController();
11881280
let streamCalls = 0;

packages/agent-core/src/agent-loop.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,7 @@ type ImmediateToolCallOutcome = {
793793
type ExecutedToolCallOutcome = {
794794
result: AgentToolResult<unknown>;
795795
isError: boolean;
796+
executionStarted: boolean;
796797
};
797798

798799
type FinalizedToolCallOutcome = {
@@ -985,6 +986,16 @@ async function executePreparedToolCall(
985986
signal: AbortSignal | undefined,
986987
emit: AgentEventSink,
987988
): Promise<ExecutedToolCallOutcome> {
989+
// Parallel batches prepare calls before running them; an abort from a later
990+
// preflight must not start an earlier prepared side-effectful tool.
991+
if (signal?.aborted) {
992+
return {
993+
result: createErrorToolResult("Operation aborted"),
994+
isError: true,
995+
executionStarted: false,
996+
};
997+
}
998+
988999
const updateEvents: Promise<void>[] = [];
9891000
let acceptingUpdates = true;
9901001

@@ -1015,13 +1026,14 @@ async function executePreparedToolCall(
10151026
);
10161027
acceptingUpdates = false;
10171028
await Promise.all(updateEvents);
1018-
return { result, isError: false };
1029+
return { result, isError: false, executionStarted: true };
10191030
} catch (error) {
10201031
acceptingUpdates = false;
10211032
await Promise.all(updateEvents);
10221033
return {
10231034
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
10241035
isError: true,
1036+
executionStarted: true,
10251037
};
10261038
} finally {
10271039
acceptingUpdates = false;
@@ -1039,7 +1051,7 @@ async function finalizeExecutedToolCall(
10391051
let result = executed.result;
10401052
let isError = executed.isError;
10411053

1042-
if (config.afterToolCall) {
1054+
if (executed.executionStarted && config.afterToolCall) {
10431055
try {
10441056
const afterResult = await config.afterToolCall(
10451057
{
@@ -1070,7 +1082,7 @@ async function finalizeExecutedToolCall(
10701082
toolCall: prepared.toolCall,
10711083
result,
10721084
isError,
1073-
executionStarted: true,
1085+
executionStarted: executed.executionStarted,
10741086
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
10751087
};
10761088
}

0 commit comments

Comments
 (0)