Skip to content

Commit 7fe287b

Browse files
szsip239vincentkoc
andauthored
fix(agent-core): stop loop after aborted tool run (#94412)
Merged via squash. Prepared head SHA: e11d971 Co-authored-by: szsip239 <[email protected]> Co-authored-by: vincentkoc <[email protected]> Reviewed-by: @vincentkoc
1 parent f77a74d commit 7fe287b

2 files changed

Lines changed: 191 additions & 0 deletions

File tree

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,154 @@ describe("agentLoop tool termination", () => {
767767
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
768768
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
769769
});
770+
771+
it("does not request another model turn after a tool aborts the run", async () => {
772+
const controller = new AbortController();
773+
let streamCalls = 0;
774+
const streamFn: StreamFn = () => {
775+
streamCalls += 1;
776+
if (streamCalls > 1) {
777+
throw new Error("model was called after abort");
778+
}
779+
const stream = createAssistantMessageEventStream();
780+
queueMicrotask(() => {
781+
const message = makeAssistantMessage([
782+
{ type: "toolCall", id: "call-abort", name: "abort_tool", arguments: {} },
783+
]);
784+
stream.push({ type: "done", reason: "toolUse", message });
785+
stream.end();
786+
});
787+
return stream;
788+
};
789+
const abortTool: AgentTool = {
790+
name: "abort_tool",
791+
label: "abort_tool",
792+
description: "Abort the active run",
793+
parameters: Type.Object({}, { additionalProperties: false }),
794+
execute: async () => {
795+
controller.abort(new Error("user aborted"));
796+
return {
797+
content: [{ type: "text", text: "aborted" }],
798+
details: { aborted: true },
799+
};
800+
},
801+
};
802+
const events: AgentEvent[] = [];
803+
804+
const messages = await runAgentLoop(
805+
[{ role: "user", content: "abort during tool", timestamp: 1 }],
806+
{
807+
systemPrompt: "",
808+
messages: [],
809+
tools: [abortTool],
810+
},
811+
config,
812+
(event) => {
813+
events.push(event);
814+
},
815+
controller.signal,
816+
streamFn,
817+
);
818+
819+
expect(streamCalls).toBe(1);
820+
expect(messages.map((message) => message.role)).toEqual([
821+
"user",
822+
"assistant",
823+
"toolResult",
824+
"assistant",
825+
]);
826+
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
827+
expect(events.map((event) => event.type)).toEqual([
828+
"agent_start",
829+
"turn_start",
830+
"message_start",
831+
"message_end",
832+
"message_start",
833+
"message_end",
834+
"tool_execution_start",
835+
"tool_execution_end",
836+
"message_start",
837+
"message_end",
838+
"turn_end",
839+
"turn_start",
840+
"message_start",
841+
"message_end",
842+
"turn_end",
843+
"agent_end",
844+
]);
845+
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
846+
});
847+
848+
it("does not request another model turn when an async turn hook aborts the run", async () => {
849+
const controller = new AbortController();
850+
let streamCalls = 0;
851+
const streamFn: StreamFn = () => {
852+
streamCalls += 1;
853+
if (streamCalls > 1) {
854+
throw new Error("model was called after abort");
855+
}
856+
const stream = createAssistantMessageEventStream();
857+
queueMicrotask(() => {
858+
const message = makeAssistantMessage([
859+
{ type: "toolCall", id: "call-hook-abort", name: "hook_abort", arguments: {} },
860+
]);
861+
stream.push({ type: "done", reason: "toolUse", message });
862+
stream.end();
863+
});
864+
return stream;
865+
};
866+
const events: AgentEvent[] = [];
867+
868+
const messages = await runAgentLoop(
869+
[{ role: "user", content: "abort from hook", timestamp: 1 }],
870+
{
871+
systemPrompt: "",
872+
messages: [],
873+
tools: [makeTool("hook_abort", [])],
874+
},
875+
{
876+
...config,
877+
prepareNextTurn: async () => {
878+
await Promise.resolve();
879+
controller.abort(new Error("user aborted"));
880+
return undefined;
881+
},
882+
},
883+
(event) => {
884+
events.push(event);
885+
},
886+
controller.signal,
887+
streamFn,
888+
);
889+
890+
expect(streamCalls).toBe(1);
891+
expect(messages.map((message) => message.role)).toEqual([
892+
"user",
893+
"assistant",
894+
"toolResult",
895+
"assistant",
896+
]);
897+
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
898+
expect(events.map((event) => event.type)).toEqual([
899+
"agent_start",
900+
"turn_start",
901+
"message_start",
902+
"message_end",
903+
"message_start",
904+
"message_end",
905+
"tool_execution_start",
906+
"tool_execution_end",
907+
"message_start",
908+
"message_end",
909+
"turn_end",
910+
"turn_start",
911+
"message_start",
912+
"message_end",
913+
"turn_end",
914+
"agent_end",
915+
]);
916+
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
917+
});
770918
});
771919

772920
describe("agentLoop thinking state", () => {

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,46 @@ async function runLoop(
267267
let currentContext = initialContext;
268268
let config = initialConfig;
269269
let firstTurn = true;
270+
let turnOpen = true;
270271
// Check for steering messages at start (user may have typed while waiting)
271272
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
273+
const stopIfAborted = async (): Promise<boolean> => {
274+
if (!signal?.aborted) {
275+
return false;
276+
}
277+
// Persist an aborted assistant outcome so session post-processing does not
278+
// compact or continue from the preceding toolUse message.
279+
const abortedMessage = createLoopFailureMessage(
280+
config,
281+
signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted"),
282+
true,
283+
);
284+
newMessages.push(abortedMessage);
285+
if (!turnOpen) {
286+
await emit({ type: "turn_start" });
287+
turnOpen = true;
288+
}
289+
await emit({ type: "message_start", message: abortedMessage });
290+
await emit({ type: "message_end", message: abortedMessage });
291+
await emit({ type: "turn_end", message: abortedMessage, toolResults: [] });
292+
turnOpen = false;
293+
await emit({ type: "agent_end", messages: newMessages });
294+
return true;
295+
};
272296

273297
// Outer loop: continues when queued follow-up messages arrive after agent would stop
274298
while (true) {
275299
let hasMoreToolCalls = true;
276300

277301
// Inner loop: process tool calls and steering messages
278302
while (hasMoreToolCalls || pendingMessages.length > 0) {
303+
if (await stopIfAborted()) {
304+
return;
305+
}
306+
279307
if (!firstTurn) {
280308
await emit({ type: "turn_start" });
309+
turnOpen = true;
281310
} else {
282311
firstTurn = false;
283312
}
@@ -292,6 +321,10 @@ async function runLoop(
292321
}
293322
}
294323

324+
if (await stopIfAborted()) {
325+
return;
326+
}
327+
295328
// Stream assistant response
296329
const message = await streamAssistantResponse(
297330
currentContext,
@@ -332,6 +365,10 @@ async function runLoop(
332365
}
333366

334367
await emit({ type: "turn_end", message, toolResults });
368+
turnOpen = false;
369+
if (await stopIfAborted()) {
370+
return;
371+
}
335372

336373
const nextTurnContext = {
337374
message,
@@ -357,6 +394,9 @@ async function runLoop(
357394
reasoning: nextReasoning,
358395
});
359396
}
397+
if (await stopIfAborted()) {
398+
return;
399+
}
360400

361401
if (
362402
await config.shouldStopAfterTurn?.({
@@ -371,6 +411,9 @@ async function runLoop(
371411
}
372412

373413
pendingMessages = (await config.getSteeringMessages?.()) || [];
414+
if (await stopIfAborted()) {
415+
return;
416+
}
374417
}
375418

376419
const followUpMessages = (await config.getFollowUpMessages?.()) || [];

0 commit comments

Comments
 (0)