Skip to content

Commit 4472364

Browse files
committed
fix(agent-core): ignore truncated tool calls
1 parent e5123e4 commit 4472364

2 files changed

Lines changed: 128 additions & 6 deletions

File tree

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

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,120 @@ describe("agentLoop streaming updates", () => {
164164
expect(update.assistantMessageEvent).not.toHaveProperty("partial");
165165
}
166166
});
167+
168+
it("does not execute tool calls from a max-token-truncated assistant turn", async () => {
169+
const execute = vi.fn(
170+
async (): Promise<AgentToolResult<unknown>> => ({
171+
content: [{ type: "text", text: "should not run" }],
172+
details: {},
173+
}),
174+
);
175+
const contexts: Context[] = [];
176+
let streamCalls = 0;
177+
const streamFn: StreamFn = async (_model, context) => {
178+
contexts.push(context);
179+
streamCalls += 1;
180+
const stream = createAssistantMessageEventStream();
181+
if (streamCalls > 1) {
182+
const message: AssistantMessage = {
183+
role: "assistant",
184+
content: [{ type: "text", text: "continued" }],
185+
api: model.api,
186+
provider: model.provider,
187+
model: model.id,
188+
usage: TEST_USAGE,
189+
stopReason: "stop",
190+
timestamp: 2,
191+
};
192+
queueMicrotask(() => {
193+
stream.push({ type: "done", reason: "stop", message });
194+
});
195+
return stream;
196+
}
197+
const toolCall = {
198+
type: "toolCall" as const,
199+
id: "call-truncated-spawn",
200+
name: "sessions_spawn",
201+
arguments: {},
202+
};
203+
const message: AssistantMessage = {
204+
role: "assistant",
205+
content: [{ type: "text", text: "spawning" }, toolCall],
206+
api: model.api,
207+
provider: model.provider,
208+
model: model.id,
209+
usage: TEST_USAGE,
210+
stopReason: "length",
211+
timestamp: 1,
212+
};
213+
214+
queueMicrotask(() => {
215+
stream.push({ type: "start", partial: { ...message, content: [] } });
216+
stream.push({ type: "toolcall_start", contentIndex: 1, partial: message });
217+
stream.push({
218+
type: "toolcall_end",
219+
contentIndex: 1,
220+
toolCall,
221+
partial: message,
222+
});
223+
stream.push({ type: "done", reason: "length", message });
224+
});
225+
226+
return stream;
227+
};
228+
229+
const stream = agentLoop(
230+
[{ role: "user", content: "spawn specialists", timestamp: 1 }],
231+
{
232+
systemPrompt: "",
233+
messages: [],
234+
tools: [
235+
{
236+
name: "sessions_spawn",
237+
label: "sessions_spawn",
238+
description: "Spawn a child session",
239+
parameters: Type.Object({}, { additionalProperties: false }),
240+
execute,
241+
},
242+
],
243+
},
244+
{
245+
...config,
246+
getFollowUpMessages: async () =>
247+
streamCalls === 1 ? [{ role: "user", content: "continue", timestamp: 2 }] : [],
248+
},
249+
undefined,
250+
streamFn,
251+
);
252+
253+
const events = await collectEvents(stream);
254+
const messages = await stream.result();
255+
const truncatedMessageEnd = events.find(
256+
(event): event is Extract<AgentEvent, { type: "message_end" }> =>
257+
event.type === "message_end" &&
258+
event.message.role === "assistant" &&
259+
event.message.stopReason === "length",
260+
);
261+
262+
expect(execute).not.toHaveBeenCalled();
263+
expect(events.some((event) => event.type === "tool_execution_start")).toBe(false);
264+
expect(messages.map((message) => message.role)).toEqual([
265+
"user",
266+
"assistant",
267+
"user",
268+
"assistant",
269+
]);
270+
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "length" });
271+
expect(messages[1]).not.toMatchObject({
272+
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
273+
});
274+
expect(truncatedMessageEnd?.message).not.toMatchObject({
275+
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
276+
});
277+
expect(contexts[1]?.messages[1]).not.toMatchObject({
278+
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
279+
});
280+
});
167281
});
168282

169283
describe("runAgentLoop deferred tool hydration", () => {
@@ -936,7 +1050,7 @@ describe("agentLoop thinking state", () => {
9361050
totalTokens: 0,
9371051
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
9381052
},
939-
stopReason: "stop",
1053+
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
9401054
timestamp: 1,
9411055
};
9421056
}
@@ -968,7 +1082,7 @@ describe("agentLoop thinking state", () => {
9681082
: [{ type: "text", text: "done" }];
9691083
stream.push({
9701084
type: "done",
971-
reason: "stop",
1085+
reason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
9721086
message: makeAssistantMessage(activeModel, content),
9731087
});
9741088
stream.end();

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ function resolveAssistantMessageUpdate(
8080
return currentMessage;
8181
}
8282

83+
function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessage {
84+
if (message.stopReason === "toolUse") {
85+
return message;
86+
}
87+
const content = message.content.filter((item) => item.type !== "toolCall");
88+
return content.length === message.content.length ? message : { ...message, content };
89+
}
90+
8391
/**
8492
* Start an agent loop with a new prompt message.
8593
* The prompt is added to the context and events are emitted for it.
@@ -342,12 +350,12 @@ async function runLoop(
342350
return;
343351
}
344352

345-
// Check for tool calls
353+
// Only completed toolUse turns dispatch; length/stop can carry partial stream blocks.
346354
const toolCalls = message.content.filter((c) => c.type === "toolCall");
347355

348356
const toolResults: ToolResultMessage[] = [];
349357
hasMoreToolCalls = false;
350-
if (toolCalls.length > 0) {
358+
if (message.stopReason === "toolUse" && toolCalls.length > 0) {
351359
const executedToolBatch = await executeToolCalls(
352360
currentContext,
353361
message,
@@ -508,7 +516,7 @@ async function streamAssistantResponse(
508516

509517
case "done":
510518
case "error": {
511-
const finalMessage = await response.result();
519+
const finalMessage = removeNonExecutableToolCalls(await response.result());
512520
if (addedPartial) {
513521
context.messages[context.messages.length - 1] = finalMessage;
514522
} else {
@@ -523,7 +531,7 @@ async function streamAssistantResponse(
523531
}
524532
}
525533

526-
const finalMessage = await response.result();
534+
const finalMessage = removeNonExecutableToolCalls(await response.result());
527535
if (addedPartial) {
528536
context.messages[context.messages.length - 1] = finalMessage;
529537
} else {

0 commit comments

Comments
 (0)