Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/fix-tool-output-provider-metadata.md
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions packages/core/src/agent/streaming/guardrail-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
for await (const chunk of uiStream) {
uiChunks.push(chunk as Record<string, unknown>);
}

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 },
});
});
});
7 changes: 6 additions & 1 deletion packages/core/src/agent/streaming/guardrail-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,13 +573,15 @@ function convertFullStreamChunkToUIMessageStream<UI_MESSAGE extends UIMessage>({
toolCallId?: string;
output?: unknown;
providerExecuted?: boolean;
providerMetadata?: unknown;
dynamic?: unknown;
};
return {
type: "tool-output-available",
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<UI_MESSAGE>;
}
Expand All @@ -589,23 +591,26 @@ function convertFullStreamChunkToUIMessageStream<UI_MESSAGE extends UIMessage>({
toolCallId?: string;
error?: unknown;
providerExecuted?: boolean;
providerMetadata?: unknown;
dynamic?: unknown;
};
return {
type: "tool-output-error",
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<UI_MESSAGE>;
}

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<UI_MESSAGE>;
}

Expand Down
Loading