Skip to content

Commit 5230482

Browse files
fix(ai): Don't create duplicate tool parts when models call non-existent tools (#12774)
## Background When a model calls a tool that doesn't exist in the tools object (e.g. a hallucinated tool name), processUIMessageStream creates two message parts for the same toolCallId — one static (tool-{toolName}) and one dynamic (dynamic-tool). This causes downstream failures when the conversation is continued, since the model API rejects requests containing two tool results for the same tool call ID. ## Summary Fixes a bug in `processUIMessageStream` where a model calling a non-existent tool produces two UI message parts for the same `toolCallId` — one static (`tool-{toolName}`) and one dynamic (`dynamic-tool`). This happens because `tool-input-start` creates a static part (when `dynamic` is undefined for an unknown tool), but the subsequent `tool-input-error` arrives with `dynamic: true` and only searches for a `dynamic-tool` part, missing the existing static one and creating a duplicate. The fix checks for an existing part by `toolCallId` before branching on `chunk.dynamic`, so the error updates the existing part in place instead of creating a second one. ## Manual Verification Manually verified against my own application. After the fix, I got a single tool part like: ``` { "type": "tool-dashboard-page-create-dashboard", "state": "output-error", "rawInput": { "name": "Staging Monitoring" }, "errorText": "Model tried to call unavailable tool 'dashboard-page-create-dashboard'. Available tools: ....", "toolCallId": "toolu_015E6Kd6cwSMkw7SFpsa7w88", "callProviderMetadata": { "anthropic": { "caller": { "type": "direct" } } } }, ``` ## Checklist - [x] Tests have been added / updated (for bug fixes / features) - [ ] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) - [x] I have reviewed this pull request (self-review) ## Related Issues Fixes #12772
1 parent 032d9a3 commit 5230482

File tree

3 files changed

+119
-1
lines changed

3 files changed

+119
-1
lines changed

.changeset/quiet-tables-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
fix(ai): Don't create duplicate tool parts when models call non-existent tools

packages/ai/src/ui/process-ui-message-stream.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6433,6 +6433,108 @@ describe('processUIMessageStream', () => {
64336433
});
64346434
});
64356435

6436+
describe('tool input error with dynamic flag mismatch', () => {
6437+
// Regression: when tool-input-start creates a static part (dynamic is
6438+
// undefined because the tool isn't in the tools object) and tool-input-error
6439+
// arrives with dynamic: true (from parseToolCall's catch for NoSuchToolError),
6440+
// the error should update the existing static part instead of creating a
6441+
// second dynamic-tool part.
6442+
beforeEach(async () => {
6443+
const stream = createUIMessageStream([
6444+
{
6445+
type: 'start',
6446+
},
6447+
{
6448+
type: 'start-step',
6449+
},
6450+
{
6451+
toolCallId: 'call-1',
6452+
toolName: 'nonExistentTool',
6453+
type: 'tool-input-start',
6454+
// dynamic is NOT set (undefined) — this is what happens when the
6455+
// tool isn't in the tools object and the provider doesn't set it
6456+
},
6457+
{
6458+
inputTextDelta: '{ "foo": "bar" }',
6459+
toolCallId: 'call-1',
6460+
type: 'tool-input-delta',
6461+
},
6462+
{
6463+
errorText: "Model tried to call unavailable tool 'nonExistentTool'.",
6464+
input: '{ "foo": "bar" }',
6465+
toolCallId: 'call-1',
6466+
toolName: 'nonExistentTool',
6467+
type: 'tool-input-error',
6468+
// dynamic IS set to true — this is what parseToolCall returns for
6469+
// invalid tool calls (NoSuchToolError catch)
6470+
dynamic: true,
6471+
},
6472+
{
6473+
errorText: "Model tried to call unavailable tool 'nonExistentTool'.",
6474+
toolCallId: 'call-1',
6475+
type: 'tool-output-error',
6476+
},
6477+
{
6478+
type: 'finish-step',
6479+
},
6480+
{
6481+
type: 'finish',
6482+
},
6483+
]);
6484+
6485+
state = createStreamingUIMessageState({
6486+
messageId: 'msg-123',
6487+
lastMessage: undefined,
6488+
});
6489+
6490+
await consumeStream({
6491+
stream: processUIMessageStream({
6492+
stream,
6493+
runUpdateMessageJob,
6494+
onError: error => {
6495+
throw error;
6496+
},
6497+
}),
6498+
});
6499+
});
6500+
6501+
it('should produce exactly one tool part (no duplicate)', async () => {
6502+
const toolParts = state!.message.parts.filter(
6503+
(p: any) => p.toolCallId === 'call-1',
6504+
);
6505+
expect(toolParts).toHaveLength(1);
6506+
});
6507+
6508+
it('should keep the static tool type from tool-input-start', async () => {
6509+
const toolPart = state!.message.parts.find(
6510+
(p: any) => p.toolCallId === 'call-1',
6511+
) as any;
6512+
expect(toolPart.type).toBe('tool-nonExistentTool');
6513+
});
6514+
6515+
it('should have the correct final message state', async () => {
6516+
expect(state!.message.parts).toMatchInlineSnapshot(`
6517+
[
6518+
{
6519+
"type": "step-start",
6520+
},
6521+
{
6522+
"errorText": "Model tried to call unavailable tool 'nonExistentTool'.",
6523+
"input": undefined,
6524+
"output": undefined,
6525+
"preliminary": undefined,
6526+
"providerExecuted": undefined,
6527+
"rawInput": "{ "foo": "bar" }",
6528+
"state": "output-error",
6529+
"title": undefined,
6530+
"toolCallId": "call-1",
6531+
"type": "tool-nonExistentTool",
6532+
},
6533+
]
6534+
`);
6535+
});
6536+
});
6537+
64366538
describe('preliminary tool results', () => {
64376539
beforeEach(async () => {
64386540
const stream = createUIMessageStream([

packages/ai/src/ui/process-ui-message-stream.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,18 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
558558
}
559559

560560
case 'tool-input-error': {
561-
if (chunk.dynamic) {
561+
// When a part already exists for this toolCallId (e.g. from
562+
// tool-input-start), honour its type so we update in place
563+
// instead of creating a duplicate with a mismatched type.
564+
const existingPart = state.message.parts
565+
.filter(isToolUIPart)
566+
.find(p => p.toolCallId === chunk.toolCallId);
567+
const isDynamic =
568+
existingPart != null
569+
? existingPart.type === 'dynamic-tool'
570+
: !!chunk.dynamic;
571+
572+
if (isDynamic) {
562573
updateDynamicToolPart({
563574
toolCallId: chunk.toolCallId,
564575
toolName: chunk.toolName,

0 commit comments

Comments
 (0)