Skip to content

Commit 231812d

Browse files
committed
fix(agent-core): skip prepared tools after abort
1 parent b81666c commit 231812d

2 files changed

Lines changed: 90 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: 13 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,14 @@ async function executePreparedToolCall(
985986
signal: AbortSignal | undefined,
986987
emit: AgentEventSink,
987988
): Promise<ExecutedToolCallOutcome> {
989+
if (signal?.aborted) {
990+
return {
991+
result: createErrorToolResult("Operation aborted"),
992+
isError: true,
993+
executionStarted: false,
994+
};
995+
}
996+
988997
const updateEvents: Promise<void>[] = [];
989998
let acceptingUpdates = true;
990999

@@ -1015,13 +1024,14 @@ async function executePreparedToolCall(
10151024
);
10161025
acceptingUpdates = false;
10171026
await Promise.all(updateEvents);
1018-
return { result, isError: false };
1027+
return { result, isError: false, executionStarted: true };
10191028
} catch (error) {
10201029
acceptingUpdates = false;
10211030
await Promise.all(updateEvents);
10221031
return {
10231032
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
10241033
isError: true,
1034+
executionStarted: true,
10251035
};
10261036
} finally {
10271037
acceptingUpdates = false;
@@ -1039,7 +1049,7 @@ async function finalizeExecutedToolCall(
10391049
let result = executed.result;
10401050
let isError = executed.isError;
10411051

1042-
if (config.afterToolCall) {
1052+
if (executed.executionStarted && config.afterToolCall) {
10431053
try {
10441054
const afterResult = await config.afterToolCall(
10451055
{
@@ -1070,7 +1080,7 @@ async function finalizeExecutedToolCall(
10701080
toolCall: prepared.toolCall,
10711081
result,
10721082
isError,
1073-
executionStarted: true,
1083+
executionStarted: executed.executionStarted,
10741084
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
10751085
};
10761086
}

0 commit comments

Comments
 (0)