-
Notifications
You must be signed in to change notification settings - Fork 69
Description
Summary
When using @convex-dev/agent with Google's Gemini models (@ai-sdk/google), tool call history can become corrupted in a way that causes subsequent requests to fail with Gemini's strict function call ordering validation. The error only manifests when Gemini attempts to output a new function call, making it intermittent and difficult to diagnose.
Environment
@convex-dev/agent: ^0.3.2ai(Vercel AI SDK): ^5.0.113@ai-sdk/google: (latest compatible)- Model:
gemini-3-flash-preview(also likely affects other Gemini models)
Error Message
Error: Please ensure that function call turn comes immediately after a user turn or after a function response turn.
AI_APICallError
statusCode: 400
url: https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent
Reproduction Steps
- Create an agent with tools using Gemini:
const chatAgent = new Agent(components.agent, {
name: "Assistant",
languageModel: google("gemini-3-flash-preview"),
tools: {
myTool: createTool({
description: "Does something",
args: z.object({ input: z.string() }),
handler: async (ctx, args) => ({ success: true }),
}),
},
maxSteps: 5,
});- Send a message that triggers a tool call:
const result = await chatAgent.streamText(
ctx,
{ threadId },
{ model: google("gemini-3-flash-preview"), prompt: "Do the thing" },
{ saveStreamDeltas: true }
);
await result.consumeStream();-
Tool call succeeds, tasks/data created correctly
-
Send another message that might trigger a tool call:
// This fails with the Gemini validation error
const result2 = await chatAgent.streamText(
ctx,
{ threadId },
{ model: google("gemini-3-flash-preview"), prompt: "Do another thing" },
{ saveStreamDeltas: true }
);- Intermittent failure: If Gemini decides to call a tool, validation fails. If Gemini just outputs text, it succeeds.
Root Cause Analysis
Gemini's Strict Requirements
Gemini requires this exact message sequence for function calls:
user → model(functionCall) → user(functionResponse) → model(text)
Key constraints:
functionCallmust come immediately after auserturn orfunctionResponsefunctionResponsehasrole: "user"in Gemini's format (notrole: "tool")- Cannot have
model(functionCall) → model(text)without thefunctionResponsein between
Suspected Issues in @convex-dev/agent
1. filterOutOrphanedToolMessages May Drop Valid Tool Results
In client/search.js:
export function filterOutOrphanedToolMessages(docs) {
const toolCallIds = new Set();
const toolResultIds = new Set();
// Collect IDs...
// Filter tool results to only those with matching toolCallIds
else if (doc.message?.role === "tool") {
const content = doc.message.content.filter((c) => toolCallIds.has(c.toolCallId));
if (content.length) {
result.push({...});
}
// If no match, message is SILENTLY DROPPED
}
}If toolCallId doesn't match exactly (due to serialization, encoding, or race conditions), the tool result is dropped, leaving:
user → model(functionCall) → model(text)
This is invalid and causes Gemini to reject new function calls.
2. serializeNewMessagesInStep May Miss Messages
In mapping.js:
const hasToolMessage = step.response.messages.at(-1)?.role === "tool";
const messages = hasToolMessage
? step.response.messages.slice(-2) // Only last 2 messages
: step.response.messages.slice(-1); // Only last messageIf step.response.messages contains more than 2 messages (e.g., [assistant(tool_call), tool(result), assistant(text)]), only the last 1-2 are saved, potentially losing the tool_call or tool_result.
3. docsToModelMessages Filter
In mapping.js:
export function docsToModelMessages(messages) {
return messages
.map((m) => m.message)
.filter((m) => !!m)
.filter((m) => !!m.content.length) // Could filter valid messages
.map(toModelMessage);
}If a tool message has empty content after filterOutOrphanedToolMessages processing, it gets dropped here.
Evidence from Production Logs
12/20/2025, 6:33:35 AM [CONVEX A(chatActions:streamResponse)] Function executed in 20679 ms // SUCCESS with tool call
12/20/2025, 6:48:35 AM [CONVEX M(chat:sendMessage)] Function executed in 137 ms
12/20/2025, 6:48:39 AM [CONVEX A(chatActions:streamResponse)] [ERROR] 'onError' {
error: Error: Please ensure that function call turn comes immediately after a user turn...
statusCode: 400,
}
12/20/2025, 8:02:56 AM [CONVEX A(chatActions:streamResponse)] Function executed in 7236 ms // SUCCESS (no tool call)
Note: The 8:02 request succeeded because Gemini responded with text only (no tool call validation triggered).
Suggested Fixes
Option A: Fix filterOutOrphanedToolMessages
Add logging when messages are dropped:
else if (doc.message?.role === "tool") {
const content = doc.message.content.filter((c) => toolCallIds.has(c.toolCallId));
if (content.length) {
result.push({...});
} else {
console.warn('Dropping orphaned tool message:', doc._id,
'toolCallIds in message:', doc.message.content.map(c => c.toolCallId),
'available toolCallIds:', [...toolCallIds]);
}
}Option B: Fix serializeNewMessagesInStep
Save ALL messages from the step, not just the last 1-2:
const messages = step.response.messages.filter(m =>
m.role === "assistant" || m.role === "tool"
);Option C: Add Gemini-Specific Validation
Before sending to Gemini, validate the conversation structure:
function validateGeminiHistory(messages) {
let expectingFunctionResponse = false;
for (const msg of messages) {
if (msg.role === "model" && hasFunctionCall(msg)) {
expectingFunctionResponse = true;
} else if (msg.role === "user" && hasFunctionResponse(msg)) {
expectingFunctionResponse = false;
} else if (expectingFunctionResponse && msg.role === "model") {
throw new Error("Missing functionResponse after functionCall");
}
}
}Workaround
Until fixed, users can add error handling with context truncation:
try {
await chatAgent.streamText(...);
} catch (error) {
if (error.message?.includes("function call turn")) {
// Retry with tool messages filtered out
await chatAgent.streamText(ctx, { threadId }, {
...args,
contextOptions: {
excludeToolMessages: true,
}
});
}
}