fix(ai): preserve unique Gemini function call IDs (#44051)

Signed-off-by: Major Hayden <major@mhtx.net>
Co-authored-by: Major Hayden <major@mhtx.net>
This commit is contained in:
Aiden Cline
2026-08-21 23:12:04 -05:00
committed by GitHub
parent 030b2d9543
commit a1d0f43531
2 changed files with 56 additions and 31 deletions
+4 -5
View File
@@ -212,7 +212,6 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
@@ -580,7 +579,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
@@ -620,7 +618,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}`
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
@@ -649,7 +649,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
@@ -673,7 +672,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
+52 -26
View File
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "tool_0",
id: "provider_call",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
@@ -862,14 +862,14 @@ describe("Gemini route", () => {
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({
id: "tool_0",
id: "provider_call",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
id: "provider_call",
name: "lookup",
result: "done",
resultType: "text",
@@ -1101,21 +1101,17 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "weather" },
})
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: "tool_0",
id: response.toolCalls[0].id,
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1158,7 +1154,8 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
}),
)
@@ -1198,7 +1195,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1212,16 +1209,20 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
id: "call_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "call_0" } },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1229,6 +1230,31 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe(