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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions