Skip to content

Gemini Tool Call History Corruption #200

@zain

Description

@zain

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.2
  • ai (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

  1. 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,
});
  1. 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();
  1. Tool call succeeds, tasks/data created correctly

  2. 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 }
);
  1. 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:

  • functionCall must come immediately after a user turn or functionResponse
  • functionResponse has role: "user" in Gemini's format (not role: "tool")
  • Cannot have model(functionCall) → model(text) without the functionResponse in 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 message

If 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,
      }
    });
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions