Skip to content

fix(ai): recover Gemini malformed tool call and incomplete Interactions streams - #60764

Merged
rekram1-node merged 1 commit into
devfrom
gemini-malformed-recovery
Oct 31, 2026
Merged

rekram1-node merged 1 commit into
devfrom
gemini-malformed-recovery

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Gemini sporadically ends valid tool-calling turns with MALFORMED_FUNCTION_CALL or UNEXPECTED_TOOL_CALL, and Google Interactions streams can drop before interaction.completed or fail with matching malformed tool-call codes. Unlike MALFORMED_RESPONSE, those paths were not marked incomplete-stream, causing immediate 0-retry failures on recoverable model glitches and 10x retries on blocked Interactions responses.

I moved both protocols onto incomplete-stream directly inside their own stream parsers:

  • packages/ai/src/protocols/gemini.ts: add MALFORMED_FUNCTION_CALL and UNEXPECTED_TOOL_CALL alongside MALFORMED_RESPONSE as InvalidProviderOutputError({ classification: "incomplete-stream" }), and ignore non-terminal FINISH_REASON_UNSPECIFIED.
  • packages/ai/src/protocols/google-interactions.ts: drop the custom onHalt override so requireTerminalEvent marks premature EOF as incomplete-stream, normalize completed with function_call steps to "tool-calls", and classify Interactions stop/block error codes locally via GeminiGenerateContent.contentFiltered before falling back to classifyProviderFailure.

Verification

  • bun run typecheck:tsc and bun test in packages/ai (1995 passed)
  • bun run check (35/35 workspace packages passed)

Visual

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" width="240" height="72" fill="none">
  <style>
    @media (prefers-reduced-motion: no-preference) {
      .pulse { animation: pulse 2.8s ease-in-out infinite; }
      .arc { stroke-dasharray: 44; stroke-dashoffset: 44; animation: arc 2.8s ease-in-out infinite; }
      .ok { animation: ok 2.8s ease-in-out infinite; }
    }
    @keyframes pulse {
      0%, 20% { opacity: 0.9; }
      45%, 100% { opacity: 0.35; }
    }
    @keyframes arc {
      0%, 20% { stroke-dashoffset: 44; opacity: 0.25; }
      55%, 85% { stroke-dashoffset: 0; opacity: 0.85; }
      100% { stroke-dashoffset: 0; opacity: 0.35; }
    }
    @keyframes ok {
      0%, 45% { opacity: 0.35; }
      65%, 90% { opacity: 1; }
      100% { opacity: 0.75; }
    }
  </style>
  <rect x="14" y="26" width="54" height="20" rx="5" stroke="currentColor" stroke-opacity="0.45" stroke-width="1.5"/>
  <line x1="23" y1="36" x2="59" y2="36" stroke="currentColor" stroke-opacity="0.7" stroke-width="1.5" stroke-linecap="round"/>
  <line x1="68" y1="36" x2="90" y2="36" stroke="currentColor" stroke-opacity="0.35" stroke-width="1.5"/>
  <g class="pulse">
    <rect x="90" y="24" width="52" height="24" rx="5" stroke="currentColor" stroke-opacity="0.65" stroke-width="1.5" stroke-dasharray="3 2"/>
    <path d="M112 32l8 8m0-8l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </g>
  <path class="arc" d="M116 24 C116 11, 166 11, 184 23" stroke="currentColor" stroke-opacity="0.7" stroke-width="1.5" stroke-linecap="round"/>
  <path d="M181 19l4 5-6 1" stroke="currentColor" stroke-opacity="0.7" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
  <g class="ok">
    <rect x="162" y="24" width="64" height="24" rx="5" fill="currentColor" fill-opacity="0.08" stroke="currentColor" stroke-opacity="0.8" stroke-width="1.5"/>
    <path d="M175 36.5l4 4 8-8.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="194" y1="36" x2="215" y2="36" stroke="currentColor" stroke-opacity="0.75" stroke-width="1.5" stroke-linecap="round"/>
  </g>
</svg>

Added by the opencode bot (AI-generated).

Related: #52378

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks sound. I copied the new tests onto the base: 7 of the 65 fail there, matching the described cases, and all 65 pass with this change. typecheck:tsc in packages/ai passes. Reading the code, I found no regressions:

  • Gemini: finish reasons other than MALFORMED_RESPONSE were already invalid provider output; only the retry classification of MALFORMED_FUNCTION_CALL and UNEXPECTED_TOOL_CALL changes.
  • Interactions route: the route name now follows the same pattern as Gemini.
  • Tool calls: checking for function_call steps works because steps stay in parser state.

Public API is unchanged; this only affects how packages/ai classifies errors and finish reasons. No blocking concerns.

Related: #52378

@rekram1-node
rekram1-node force-pushed the gemini-malformed-recovery branch 2 times, most recently from 0317d83 to 0d12081 Compare October 31, 2026 01:36

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My earlier review left no open points. The new version is sound:

  • Google's error codes now go through the shared classifyProviderFailure.
  • A Google Interactions stream that ends early is now caught by the generic missing-terminal-event check and marked retryable.

The Gemini, Interactions and provider-error tests pass (107/107), as do the full packages/ai suite and typecheck:tsc. Run against the base, the same Gemini and Interactions tests fail in 7 of 65 cases. The new codes only match Google-specific error codes and come after the context-overflow and payload-too-large checks, so other providers shouldn't be affected. Public API is unchanged. No blocking concerns.

@rekram1-node
rekram1-node force-pushed the gemini-malformed-recovery branch from 0d12081 to b6797fb Compare October 31, 2026 01:45

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit is sound; I found nothing that needs fixing. Moving the Google-specific codes out of the shared classifyProviderFailure and into the Interactions parser leaves error handling for every other provider as it is on dev. It also reuses GeminiGenerateContent.contentFiltered instead of keeping a second list of block codes, and any other code still goes to the shared classifier.

The Gemini, Interactions and provider-error tests pass (107/107), as do the full packages/ai suite and typecheck:tsc. The tests didn't change in this commit, and the same Gemini and Interactions tests fail in 7 of 65 cases on the base. Public API is unchanged.

@rekram1-node
rekram1-node merged commit c626de0 into dev Oct 31, 2026
22 checks passed
@rekram1-node
rekram1-node deleted the gemini-malformed-recovery branch October 31, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant