diff --git a/.changeset/fix-tool-output-provider-metadata.md b/.changeset/fix-tool-output-provider-metadata.md new file mode 100644 index 000000000..88f20eaa1 --- /dev/null +++ b/.changeset/fix-tool-output-provider-metadata.md @@ -0,0 +1,14 @@ +--- +"@voltagent/core": patch +--- + +fix(core): forward providerMetadata on tool-result and tool-error stream chunks + +Google Vertex thinking models attach `providerMetadata` (containing `thoughtSignature`) to +tool-output stream events. The `tool-result` → `tool-output-available` and `tool-error` → +`tool-output-error` conversions in `convertFullStreamChunkToUIMessageStream` were not forwarding +this field, causing the AI SDK's UI message stream schema validation to reject the chunk as +having unrecognized keys. This broke all tool calls when using `@ai-sdk/google-vertex` with +thinking models (e.g. `gemini-3-flash-preview`). + +Fixes #1195 diff --git a/packages/core/src/agent/streaming/guardrail-stream.spec.ts b/packages/core/src/agent/streaming/guardrail-stream.spec.ts index 58eed50a3..a9c600cbe 100644 --- a/packages/core/src/agent/streaming/guardrail-stream.spec.ts +++ b/packages/core/src/agent/streaming/guardrail-stream.spec.ts @@ -402,4 +402,110 @@ describe("Output guardrail streaming integration", () => { } expect(collected.join("")).toBe("hello"); }); + + it("preserves providerMetadata on tool-result events (Google Vertex thinking models)", async () => { + const googleThoughtSignature = "CiQBjz1rXwFvT1I/B/3qqqGc2FdAgzW+FJY4Tg/zPaWUtF6imFw="; + const parts: VoltAgentTextStreamPart[] = [ + { type: "start" } as VoltAgentTextStreamPart, + { + type: "tool-call", + toolCallId: "tool-1", + toolName: "getWeather", + input: { location: "Perth" }, + providerMetadata: { google: { thoughtSignature: googleThoughtSignature } }, + } as VoltAgentTextStreamPart, + { + type: "tool-result", + toolCallId: "tool-1", + output: { weather: { location: "Perth", condition: "Sunny", temperature: 25 } }, + providerMetadata: { google: { thoughtSignature: googleThoughtSignature } }, + } as VoltAgentTextStreamPart, + { type: "text-start", id: "text-1" } as VoltAgentTextStreamPart, + { type: "text-delta", id: "text-1", delta: "The weather is sunny." } as any, + { type: "text-end", id: "text-1" } as VoltAgentTextStreamPart, + { type: "finish", finishReason: "stop" } as any, + ]; + + const pipeline = buildPipeline(parts, { + id: "passthrough", + name: "Passthrough", + handler: async (_ctx) => ({ pass: true }) as const, + streamHandler: ({ part }) => part, + }); + + const emitted: VoltAgentTextStreamPart[] = []; + for await (const chunk of pipeline.fullStream) { + emitted.push(chunk as VoltAgentTextStreamPart); + } + + await pipeline.finalizePromise; + + // tool-call should preserve providerMetadata + const toolCall = emitted.find((c) => c.type === "tool-call") as any; + expect(toolCall).toBeDefined(); + expect(toolCall.providerMetadata).toEqual({ + google: { thoughtSignature: googleThoughtSignature }, + }); + + // tool-result should preserve providerMetadata (the fix for #1195) + const toolResult = emitted.find((c) => c.type === "tool-result") as any; + expect(toolResult).toBeDefined(); + expect(toolResult.providerMetadata).toEqual({ + google: { thoughtSignature: googleThoughtSignature }, + }); + }); + + it("forwards providerMetadata to tool-output-available in UI stream (fixes #1195)", async () => { + const googleThoughtSignature = "CiQBjz1rXwFvT1I/B/3qqqGc2FdAgzW+FJY4Tg/zPaWUtF6imFw="; + const parts: VoltAgentTextStreamPart[] = [ + { type: "start" } as VoltAgentTextStreamPart, + { + type: "tool-call", + toolCallId: "tool-1", + toolName: "getWeather", + input: { location: "Perth" }, + providerMetadata: { google: { thoughtSignature: googleThoughtSignature } }, + } as VoltAgentTextStreamPart, + { + type: "tool-result", + toolCallId: "tool-1", + output: { weather: { location: "Perth", condition: "Sunny", temperature: 25 } }, + providerMetadata: { google: { thoughtSignature: googleThoughtSignature } }, + } as VoltAgentTextStreamPart, + { type: "text-start", id: "text-1" } as VoltAgentTextStreamPart, + { type: "text-delta", id: "text-1", delta: "The weather is sunny." } as any, + { type: "text-end", id: "text-1" } as VoltAgentTextStreamPart, + { type: "finish", finishReason: "stop" } as any, + ]; + + const pipeline = buildPipeline(parts, { + id: "passthrough", + name: "Passthrough", + handler: async (_ctx) => ({ pass: true }) as const, + streamHandler: ({ part }) => part, + }); + + const uiStream = pipeline.createUIStream(); + const uiChunks: Array> = []; + for await (const chunk of uiStream) { + uiChunks.push(chunk as Record); + } + + await pipeline.finalizePromise; + + // tool-input-available (from tool-call) should have providerMetadata + const toolInput = uiChunks.find((c) => c.type === "tool-input-available") as any; + expect(toolInput).toBeDefined(); + expect(toolInput.providerMetadata).toEqual({ + google: { thoughtSignature: googleThoughtSignature }, + }); + + // tool-output-available (from tool-result) should have providerMetadata + // Before the fix, this was missing and caused zod schema validation to fail + const toolOutput = uiChunks.find((c) => c.type === "tool-output-available") as any; + expect(toolOutput).toBeDefined(); + expect(toolOutput.providerMetadata).toEqual({ + google: { thoughtSignature: googleThoughtSignature }, + }); + }); }); diff --git a/packages/core/src/agent/streaming/guardrail-stream.ts b/packages/core/src/agent/streaming/guardrail-stream.ts index 9d88e4fb2..049d156ea 100644 --- a/packages/core/src/agent/streaming/guardrail-stream.ts +++ b/packages/core/src/agent/streaming/guardrail-stream.ts @@ -573,6 +573,7 @@ function convertFullStreamChunkToUIMessageStream({ toolCallId?: string; output?: unknown; providerExecuted?: boolean; + providerMetadata?: unknown; dynamic?: unknown; }; return { @@ -580,6 +581,7 @@ function convertFullStreamChunkToUIMessageStream({ toolCallId: typed.toolCallId, output: typed.output, ...(typed.providerExecuted != null ? { providerExecuted: typed.providerExecuted } : {}), + ...(typed.providerMetadata != null ? { providerMetadata: typed.providerMetadata } : {}), ...(typed.dynamic != null ? { dynamic: typed.dynamic } : {}), } as InferUIMessageChunk; } @@ -589,6 +591,7 @@ function convertFullStreamChunkToUIMessageStream({ toolCallId?: string; error?: unknown; providerExecuted?: boolean; + providerMetadata?: unknown; dynamic?: unknown; }; return { @@ -596,16 +599,18 @@ function convertFullStreamChunkToUIMessageStream({ toolCallId: typed.toolCallId, errorText: onError(typed.error), ...(typed.providerExecuted != null ? { providerExecuted: typed.providerExecuted } : {}), + ...(typed.providerMetadata != null ? { providerMetadata: typed.providerMetadata } : {}), ...(typed.dynamic != null ? { dynamic: typed.dynamic } : {}), } as InferUIMessageChunk; } case "tool-output": { - const typed = part as { toolCallId?: string; output: unknown }; + const typed = part as { toolCallId?: string; output: unknown; providerMetadata?: unknown }; return { type: "tool-output-available", toolCallId: typed.toolCallId, output: typed.output, + ...(typed.providerMetadata != null ? { providerMetadata: typed.providerMetadata } : {}), } as InferUIMessageChunk; }