Skip to content

Commit 85aa26d

Browse files
fix(agent-core): stop canceled parallel tools from starting (#102276)
* fix(agent-core): skip prepared tools after abort * docs(agent-core): explain parallel abort guard * docs(changelog): note parallel cancellation fix * docs(changelog): leave release notes release-owned --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent ccdb60e commit 85aa26d

2 files changed

Lines changed: 92 additions & 3 deletions

File tree

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

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

1186+
it("does not start prepared parallel tools after the run aborts mid-batch", async () => {
1187+
const controller = new AbortController();
1188+
const executed: string[] = [];
1189+
const afterToolCall = vi.fn(async () => undefined);
1190+
const streamFn: StreamFn = () => {
1191+
const stream = createAssistantMessageEventStream();
1192+
queueMicrotask(() => {
1193+
stream.push({
1194+
type: "done",
1195+
reason: "toolUse",
1196+
message: makeAssistantMessage([
1197+
{ type: "toolCall", id: "call-paid", name: "paid", arguments: {} },
1198+
{ type: "toolCall", id: "call-gated", name: "gated", arguments: {} },
1199+
]),
1200+
});
1201+
stream.end();
1202+
});
1203+
return stream;
1204+
};
1205+
const events: AgentEvent[] = [];
1206+
1207+
await runAgentLoop(
1208+
[{ role: "user", content: "abort during parallel tool preparation", timestamp: 1 }],
1209+
{
1210+
systemPrompt: "",
1211+
messages: [],
1212+
tools: [makeTool("paid", executed), makeTool("gated", executed)],
1213+
},
1214+
{
1215+
...config,
1216+
toolExecution: "parallel",
1217+
beforeToolCall: async ({ toolCall }) => {
1218+
if (toolCall.name === "gated") {
1219+
await Promise.resolve();
1220+
controller.abort(new Error("user aborted"));
1221+
}
1222+
return undefined;
1223+
},
1224+
afterToolCall,
1225+
},
1226+
(event) => {
1227+
events.push(event);
1228+
},
1229+
controller.signal,
1230+
streamFn,
1231+
);
1232+
1233+
const endEvents = events.filter(
1234+
(event): event is Extract<AgentEvent, { type: "tool_execution_end" }> =>
1235+
event.type === "tool_execution_end",
1236+
);
1237+
1238+
expect(executed).toEqual([]);
1239+
expect(afterToolCall).not.toHaveBeenCalled();
1240+
expect(endEvents).toHaveLength(2);
1241+
expect(endEvents).toEqual(
1242+
expect.arrayContaining([
1243+
expect.objectContaining({
1244+
toolName: "paid",
1245+
isError: true,
1246+
executionStarted: false,
1247+
result: expect.objectContaining({
1248+
content: [{ type: "text", text: "Operation aborted" }],
1249+
}),
1250+
}),
1251+
expect.objectContaining({
1252+
toolName: "gated",
1253+
isError: true,
1254+
executionStarted: false,
1255+
result: expect.objectContaining({
1256+
content: [{ type: "text", text: "Operation aborted" }],
1257+
}),
1258+
}),
1259+
]),
1260+
);
1261+
});
1262+
11861263
it("does not request another model turn when an async turn hook aborts the run", async () => {
11871264
const controller = new AbortController();
11881265
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 every call first. A later preflight abort must not
990+
// let an earlier prepared, side-effectful tool start afterward.
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)