Skip to content

Commit b4857f5

Browse files
roaminroMastra Code (anthropic/claude-opus-4-7)
andcommitted
fix(core): drop orphaned provider-executed tool calls on closed assistant turns
When a provider dropped a tool-result chunk (#15668) or a run aborted mid-stream (#14148), the unresolved provider-executed tool call (e.g. Anthropic web_search, Gemini code_execution) was replayed on every subsequent request, deterministically bricking the thread. sanitizeV5UIMessages now only preserves deferred input-available provider-executed tool parts on the most recent assistant message and only when no later user message has closed the turn. Orphans on earlier assistant turns are dropped so outgoing history always satisfies the tool-call/tool-result pairing invariant. Fixes #15668 Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
1 parent df97812 commit b4857f5

3 files changed

Lines changed: 232 additions & 4 deletions

File tree

.changeset/puny-rats-talk.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed a bug where orphaned provider-executed tool calls (e.g. Anthropic `web_search`, Gemini `code_execution`) could brick a thread. When a provider dropped the tool-result chunk ([#15668](https://github.com/mastra-ai/mastra/issues/15668)) or a run aborted mid-stream ([#14148](https://github.com/mastra-ai/mastra/issues/14148)), the unresolved call was replayed on every subsequent request — causing Gemini to return empty text and Anthropic to reject the tool-call/tool-result invariant.
6+
7+
`sanitizeV5UIMessages` now only keeps an `input-available` provider-executed tool part on the most recent assistant message, and only when no user turn has followed it. Orphans on earlier assistant turns are dropped so the outgoing history always satisfies the tool-call/tool-result pairing required by provider APIs.

packages/core/src/agent/message-list/conversion/output-converter-provider-executed.test.ts

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import type { AIV5Type } from '../types';
3-
import { addStartStepPartsForAIV5, sanitizeV5UIMessages } from './output-converter';
3+
import { addStartStepPartsForAIV5, aiV5UIMessagesToAIV5ModelMessages, sanitizeV5UIMessages } from './output-converter';
44

55
/**
66
* Tests for provider-executed tool handling in sanitizeV5UIMessages.
@@ -209,6 +209,202 @@ describe('sanitizeV5UIMessages — provider-executed tool handling', () => {
209209
});
210210
});
211211

212+
/**
213+
* Regression tests for https://github.com/mastra-ai/mastra/issues/15668
214+
*
215+
* Gemini `code_execution` (and `google_search`) on Vertex AI non-deterministically
216+
* omits the paired `codeExecutionResult` after emitting `executableCode`. The AI SDK
217+
* surfaces this as a `tool-call { providerExecuted: true }` chunk with no paired
218+
* `tool-result`. Mastra currently persists that as
219+
* `tool-invocation { state: 'call', providerExecuted: true }` with no result part.
220+
*
221+
* When the thread is replayed on the next turn, `sanitizeV5UIMessages` keeps the
222+
* orphaned provider-executed call (line ~150 of output-converter.ts). The Google
223+
* provider then serializes it as a bare `functionCall` in the `contents` array —
224+
* with no paired `functionResponse`. Gemini returns `text: ""` for this turn and
225+
* every subsequent turn on the thread (verified by the reporter: 5/5 empty STOP).
226+
*
227+
* The bricking is deterministic once the orphan lands. The contract that must
228+
* hold is: after UI→Model conversion, there must NEVER be a `tool-call` content
229+
* part without a corresponding `tool-result` somewhere in the outgoing messages.
230+
* Whether the fix synthesizes a placeholder result or strips the call is an
231+
* implementation detail — but one of the two must happen.
232+
*/
233+
describe('sanitizeV5UIMessages — orphaned provider-executed tool calls (issue #15668)', () => {
234+
const makeToolPart = (
235+
overrides: Partial<AIV5Type.ToolUIPart> & { type: string; toolCallId: string },
236+
): AIV5Type.ToolUIPart =>
237+
({
238+
state: 'input-available' as const,
239+
input: {},
240+
...overrides,
241+
}) as AIV5Type.ToolUIPart;
242+
243+
it('drops or resolves an orphaned Gemini code_execution call so the next request is not an unpaired functionCall', () => {
244+
// This is the exact shape Mastra persists after Vertex drops codeExecutionResult.
245+
// It is NOT a legitimate deferred provider tool — Gemini code_execution does not
246+
// defer across turns (unlike Anthropic web_search). Replaying this to the model
247+
// deterministically produces empty text on every subsequent turn.
248+
const user: AIV5Type.UIMessage = {
249+
id: 'u1',
250+
role: 'user',
251+
parts: [{ type: 'text', text: 'Parse the attached spreadsheet and tell me the column names.' }],
252+
};
253+
254+
const assistantWithOrphan: AIV5Type.UIMessage = {
255+
id: 'a1',
256+
role: 'assistant',
257+
parts: [
258+
{ type: 'text', text: "I'll parse it with pandas via code_execution." },
259+
makeToolPart({
260+
type: 'tool-code_execution',
261+
toolCallId: 'gem_tool_1',
262+
state: 'input-available',
263+
input: { code: "import pandas as pd; print(pd.read_excel('/tmp/x.xlsx').columns.tolist())" },
264+
providerExecuted: true,
265+
}),
266+
],
267+
};
268+
269+
const followUp: AIV5Type.UIMessage = {
270+
id: 'u2',
271+
role: 'user',
272+
parts: [{ type: 'text', text: 'what were the columns?' }],
273+
};
274+
275+
const modelMessages = aiV5UIMessagesToAIV5ModelMessages(
276+
[user, assistantWithOrphan, followUp],
277+
[],
278+
/* filterIncompleteToolCalls */ true,
279+
);
280+
281+
// Walk all assistant content parts and collect tool-call / tool-result IDs.
282+
const orphanCallIds: string[] = [];
283+
const resultIds = new Set<string>();
284+
for (const m of modelMessages) {
285+
if (typeof m.content === 'string') continue;
286+
for (const part of m.content) {
287+
if (part.type === 'tool-call') {
288+
orphanCallIds.push((part as any).toolCallId);
289+
}
290+
if (part.type === 'tool-result') {
291+
resultIds.add((part as any).toolCallId);
292+
}
293+
}
294+
}
295+
296+
// The invariant: every tool-call sent to the provider must have a matching
297+
// tool-result. An orphaned `gem_tool_1` call without a result is exactly
298+
// what bricks the thread.
299+
const unpaired = orphanCallIds.filter(id => !resultIds.has(id));
300+
expect(unpaired).toEqual([]);
301+
});
302+
303+
it('drops or resolves an orphaned Anthropic web_search call stuck in state:"call" (same defect class)', () => {
304+
// Same underlying issue as #14148 — once a provider-executed tool is persisted
305+
// as state:"call" with no paired result, every subsequent turn replays it.
306+
// The safeguard must be provider-agnostic.
307+
const user: AIV5Type.UIMessage = {
308+
id: 'u1',
309+
role: 'user',
310+
parts: [{ type: 'text', text: 'find me recent news on X' }],
311+
};
312+
313+
const assistantWithOrphan: AIV5Type.UIMessage = {
314+
id: 'a1',
315+
role: 'assistant',
316+
parts: [
317+
makeToolPart({
318+
type: 'tool-web_search_20250305',
319+
toolCallId: 'srvtoolu_orphan1',
320+
state: 'input-available',
321+
input: { query: 'X' },
322+
providerExecuted: true,
323+
}),
324+
],
325+
};
326+
327+
const followUp: AIV5Type.UIMessage = {
328+
id: 'u2',
329+
role: 'user',
330+
parts: [{ type: 'text', text: 'you good?' }],
331+
};
332+
333+
const modelMessages = aiV5UIMessagesToAIV5ModelMessages(
334+
[user, assistantWithOrphan, followUp],
335+
[],
336+
/* filterIncompleteToolCalls */ true,
337+
);
338+
339+
const orphanCallIds: string[] = [];
340+
const resultIds = new Set<string>();
341+
for (const m of modelMessages) {
342+
if (typeof m.content === 'string') continue;
343+
for (const part of m.content) {
344+
if (part.type === 'tool-call') orphanCallIds.push((part as any).toolCallId);
345+
if (part.type === 'tool-result') resultIds.add((part as any).toolCallId);
346+
}
347+
}
348+
349+
const unpaired = orphanCallIds.filter(id => !resultIds.has(id));
350+
expect(unpaired).toEqual([]);
351+
});
352+
353+
it('drops a stale deferred provider call on an EARLIER assistant message when a later assistant message exists', () => {
354+
// Multi-assistant history shape — this guards against the class of bug #14192:
355+
// a provider-executed tool left in `input-available` on an earlier assistant
356+
// turn must not be replayed to the provider just because the final turn is
357+
// also an assistant. Only the most recent assistant message may legitimately
358+
// carry a deferred provider tool.
359+
const user: AIV5Type.UIMessage = {
360+
id: 'u1',
361+
role: 'user',
362+
parts: [{ type: 'text', text: 'search for recent news about X' }],
363+
};
364+
365+
const earlierAssistantWithStaleProviderCall: AIV5Type.UIMessage = {
366+
id: 'a1',
367+
role: 'assistant',
368+
parts: [
369+
makeToolPart({
370+
type: 'tool-web_search_20250305',
371+
toolCallId: 'srvtoolu_stale',
372+
state: 'input-available',
373+
input: { query: 'X' },
374+
providerExecuted: true,
375+
}),
376+
],
377+
};
378+
379+
const laterAssistant: AIV5Type.UIMessage = {
380+
id: 'a2',
381+
role: 'assistant',
382+
parts: [{ type: 'text', text: 'Here is a summary based on what I found earlier.' }],
383+
};
384+
385+
const modelMessages = aiV5UIMessagesToAIV5ModelMessages(
386+
[user, earlierAssistantWithStaleProviderCall, laterAssistant],
387+
[],
388+
/* filterIncompleteToolCalls */ true,
389+
);
390+
391+
const orphanCallIds: string[] = [];
392+
const resultIds = new Set<string>();
393+
for (const m of modelMessages) {
394+
if (typeof m.content === 'string') continue;
395+
for (const part of m.content) {
396+
if (part.type === 'tool-call') orphanCallIds.push((part as any).toolCallId);
397+
if (part.type === 'tool-result') resultIds.add((part as any).toolCallId);
398+
}
399+
}
400+
401+
const unpaired = orphanCallIds.filter(id => !resultIds.has(id));
402+
expect(unpaired).toEqual([]);
403+
// Belt-and-suspenders: the stale id must not have leaked through at all.
404+
expect(orphanCallIds).not.toContain('srvtoolu_stale');
405+
});
406+
});
407+
212408
describe('addStartStepPartsForAIV5 — client/provider tool splitting', () => {
213409
const makeToolPart = (
214410
overrides: Partial<AIV5Type.ToolUIPart> & { type: string; toolCallId: string },

packages/core/src/agent/message-list/conversion/output-converter.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,32 @@ export function sanitizeV5UIMessages(
108108
messages: AIV5Type.UIMessage[],
109109
filterIncompleteToolCalls = false,
110110
): AIV5Type.UIMessage[] {
111+
// Precompute the index of the last user message AND the index of the last
112+
// assistant message. A deferred provider-executed tool call (e.g. Anthropic
113+
// non-deterministically defers web_search across steps N→N+1 within the same
114+
// run) may legitimately carry `input-available` state ONLY on the most recent
115+
// assistant message, AND only if no user turn has followed it. On any earlier
116+
// assistant turn (or after a later user message) an unresolved provider-executed
117+
// call is an orphan — provider dropped the result chunk (#15668), run aborted
118+
// mid-stream (#14148), or a stale call from an earlier step (#14192) — and
119+
// must be dropped to keep the tool-call/tool-result invariant.
120+
let lastUserIdx = -1;
121+
let lastAssistantIdx = -1;
122+
for (let i = messages.length - 1; i >= 0; i--) {
123+
const role = messages[i]!.role;
124+
if (role === 'user' && lastUserIdx === -1) lastUserIdx = i;
125+
if (role === 'assistant' && lastAssistantIdx === -1) lastAssistantIdx = i;
126+
if (lastUserIdx !== -1 && lastAssistantIdx !== -1) break;
127+
}
128+
111129
const msgs = messages
112-
.map(m => {
130+
.map((m, idx) => {
113131
if (m.parts.length === 0) return false;
114132

133+
// Deferred-provider-tool behavior is ONLY valid on the most recent assistant
134+
// message AND only when no user turn has followed it.
135+
const assistantTurnStillOpen = m.role === 'assistant' && idx === lastAssistantIdx && idx > lastUserIdx;
136+
115137
// Filter out streaming states and optionally input-available (which aren't supported by convertToModelMessages)
116138
const safeParts = m.parts.filter(p => {
117139
// Filter out data-* parts (custom streaming data from writer.custom())
@@ -146,8 +168,11 @@ export function sanitizeV5UIMessages(
146168
if (p.state === 'output-available' || p.state === 'output-error') return true;
147169
// Provider-executed tools may be deferred by the provider (e.g. Anthropic non-deterministically
148170
// defers web_search when mixed with client tool calls). Keep these so the provider API sees
149-
// the server_tool_use block on the next request.
150-
if (p.state === 'input-available' && p.providerExecuted) return true;
171+
// the server_tool_use block on the next request — but ONLY on the most recent assistant
172+
// message. On any earlier assistant turn an unresolved provider-executed call is an orphan
173+
// (provider dropped the result chunk, or the run aborted mid-stream) and must be dropped
174+
// to keep the tool-call/tool-result invariant required by provider APIs. See #15668, #14148.
175+
if (p.state === 'input-available' && p.providerExecuted && assistantTurnStillOpen) return true;
151176
return false;
152177
}
153178

0 commit comments

Comments
 (0)