Skip to content

fix(gemini): preserve mixed tool turn thought signatures#488

Merged
Chevey339 merged 3 commits into
Chevey339:masterfrom
luosc:feat/gemini-thoughtsig-repro
Apr 27, 2026
Merged

fix(gemini): preserve mixed tool turn thought signatures#488
Chevey339 merged 3 commits into
Chevey339:masterfrom
luosc:feat/gemini-thoughtsig-repro

Conversation

@luosc
Copy link
Copy Markdown
Contributor

@luosc luosc commented Apr 19, 2026

Summary

  • Preserve Gemini 3 model turns in their original part order when mixed tool outputs are present.
  • Keep thoughtSignature attached to the original toolCall / functionCall part instead of rebuilding tool history by buckets.
  • Add a regression test covering mixed google_search + custom function tool turns.

Testing

  • flutter analyze lib/core/services/api/providers/google_common.dart test/gemini_thought_signature_repro_test.dart
  • flutter test test/gemini_thought_signature_repro_test.dart

Notes

  • The fix targets the Gemini replay path in lib/core/services/api/providers/google_common.dart.
  • The regression test reproduces the failure deterministically with a local HTTP server.

Closes #487

@Chevey339
Copy link
Copy Markdown
Owner

gpt reveiw的,这种情况有可能发生吗;

• 1. 高: 这版回填逻辑一旦看到“本轮已有任意一个 call 带签名”,就会停止给后续未签名 call 回填。hasAnyCallThoughtSignature() 是全局判定,而调用点又
用 !hasAnyCallThoughtSignature() 作为前置条件,所以像“第一个 functionCall 已内联签名、第二个 functionCall 的签名在后续 chunk 才到”的场景,第
二个 call 仍会被原样重放为无签名,#487 的 400 还能复现。相关代码在 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:859
和 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:994。现有测试没有覆盖这个分支,因为 /tmp/kelivo-pr488/test/
gemini_thought_signature_repro_test.dart:74 只有单个 call,而 /tmp/kelivo-pr488/test/gemini_thought_signature_repro_test.dart:270 里两个 part
都是“签名已内联”的理想路径。
2. 中: “晚到的签名”现在仍然会被无条件写回到“第一个未签名的 functionCall”,但代码并没有确认这个签名原本属于 functionCall。
assignThoughtSignatureToFirstUnassignedCall() 只按队列顺序改第一个未签名 call,而调用点只排除了 functionCall 自身,没有排除 toolCall、
toolResponse 或其他带 thoughtSignature 的非函数 part;这意味着在 mixed turn 里,只要 Gemini 把某个 built-in toolCall 的签名延后发回来,就可能
继续把它错挂到自定义函数上。相关代码在 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:842 和 /tmp/kelivo-pr488/lib/
core/services/api/providers/google_common.dart:994。当前 mixed regression 只验证了“toolCall 自己已经带签名”的情况,见 /tmp/kelivo-pr488/test/
gemini_thought_signature_repro_test.dart:270,没有覆盖这个仍然危险的错绑分支。

luosc added 2 commits April 23, 2026 15:24
Keep Gemini 3 model turns in their original part order so mixed tool responses retain the correct `thoughtSignature` on the original `toolCall` / `functionCall` parts. Add a regression test for mixed `google_search` + custom function tool turns.

Related: Chevey339#487

Files changed:
- lib/core/services/api/providers/google_common.dart: preserve original Gemini parts order and thought signature assignment during replay
- test/gemini_thought_signature_repro_test.dart: add deterministic regression coverage for mixed tool turns and signed parts

Signed-off-by: Shuchen Luo <nemo0806@gmail.com>
Keep Gemini 3 replay attaching late `thoughtSignature` values to the next pending part instead of stopping after the first signed call. Avoid rebinding built-in tool signatures onto later custom `functionCall` parts, and add regression coverage for both mixed-turn edge cases.

Related: Chevey339#488

Files changed:
- lib/core/services/api/providers/google_common.dart: track pending replay parts so late thought signatures can be applied without crossing tool boundaries
- test/gemini_thought_signature_repro_test.dart: add regression coverage for multi-function-call replay and mixed built-in/custom tool turns

Signed-off-by: Shuchen Luo <nemo0806@gmail.com>
@luosc
Copy link
Copy Markdown
Contributor Author

luosc commented Apr 24, 2026

gpt reveiw的,这种情况有可能发生吗;

• 1. 高: 这版回填逻辑一旦看到“本轮已有任意一个 call 带签名”,就会停止给后续未签名 call 回填。hasAnyCallThoughtSignature() 是全局判定,而调用点又
用 !hasAnyCallThoughtSignature() 作为前置条件,所以像“第一个 functionCall 已内联签名、第二个 functionCall 的签名在后续 chunk 才到”的场景,第
二个 call 仍会被原样重放为无签名,#487 的 400 还能复现。相关代码在 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:859
和 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:994。现有测试没有覆盖这个分支,因为 /tmp/kelivo-pr488/test/
gemini_thought_signature_repro_test.dart:74 只有单个 call,而 /tmp/kelivo-pr488/test/gemini_thought_signature_repro_test.dart:270 里两个 part
都是“签名已内联”的理想路径。
2. 中: “晚到的签名”现在仍然会被无条件写回到“第一个未签名的 functionCall”,但代码并没有确认这个签名原本属于 functionCall。
assignThoughtSignatureToFirstUnassignedCall() 只按队列顺序改第一个未签名 call,而调用点只排除了 functionCall 自身,没有排除 toolCall、
toolResponse 或其他带 thoughtSignature 的非函数 part;这意味着在 mixed turn 里,只要 Gemini 把某个 built-in toolCall 的签名延后发回来,就可能
继续把它错挂到自定义函数上。相关代码在 /tmp/kelivo-pr488/lib/core/services/api/providers/google_common.dart:842 和 /tmp/kelivo-pr488/lib/
core/services/api/providers/google_common.dart:994。当前 mixed regression 只验证了“toolCall 自己已经带签名”的情况,见 /tmp/kelivo-pr488/test/
gemini_thought_signature_repro_test.dart:270,没有覆盖这个仍然危险的错绑分支。

updated the Gemini 3 replay logic to track pending parts in order instead of stopping once any call already has a signature, so a later signature can still be applied to the next unsigned functionCall.
I also tightened the detached-signature handling so a late signature is no longer rebound onto a later custom functionCall when a built-in toolCall or another non-function part is still pending.
Added regression coverage for:

  • a signed first functionCall followed by an unsigned second functionCall whose signature arrives later
  • a mixed toolCall + custom functionCall turn where the late signature belongs to the built-in tool part
    Validation:
  • dart format lib/core/services/api/providers/google_common.dart test/gemini_thought_signature_repro_test.dart
  • flutter analyze
  • flutter test test/gemini_thought_signature_repro_test.dart

@luosc luosc force-pushed the feat/gemini-thoughtsig-repro branch from c2551ae to 7b6cd02 Compare April 24, 2026 00:16
@Chevey339
Copy link
Copy Markdown
Owner

Findings / 发现的问题

  • [High] 非法的 Thought Signature 搬移与冗余,极易触发 INVALID_ARGUMENT 错误
    lib/core/services/api/providers/google_common.dart:1016-1033, 1246-1250, 1410-1411 中,当前的逻辑会在收到 thoughtSignature 时将其回填至前一个 functionCall。但该过程未从 roundModelParts 中剔除原始带有 thoughtSignature 的空文本 Part。

    • 产生后果:导致第二轮请求回放时,既发送了被修改的 functionCall,又残留了原始的独立 Part(无法实现“严格按原样返回接收到的 Part”)。Gemini 官方文档明确要求 signature 必须保留在其原本所在的 Part 中,此种随意搬移及冗余修改极易在服务端引发 INVALID_ARGUMENT 报错。
    • 测试漏洞:当前新增的测试仅断言了 functionCall 是否包含 signature,并未校验冗余的 Detached Part 是否被正确清理,导致该隐患无法被测试有效拦截。
  • [Medium] 错误理解并固化了未文档化的 Gemini 响应契约 (Contract)
    test/gemini_thought_signature_repro_test.dart:279-321 及对应实现 lib/core/services/api/providers/google_common.dart:857-873 中,错误地将“后续空 Part 的 Signature 应回填至首个未签名的 functionCall”作为成型的协议规则。

    • 规范冲突:根据官方文档(https://ai.google.dev/gemini-api/docs/thought-signatures?hl=en) ,当响应中存在 functionCall 时,Gemini 3 会将 thoughtSignature 直接附加于首个 functionCall Part;而“最后一个携带 Signature 的空文本 Part”仅适用于无 Function Calls 的流式响应场景。
    • 产生后果:该测试用例固化了一种缺乏官方文档支持且与官方说明相悖的特殊响应结构。未来若基于此测试用例继续维护,极易破坏合法的并发函数调用(Parallel Function Call)回放逻辑。
  • [Medium] 过严的封闭白名单(Allow-list)过滤策略违背了严格回放原则
    lib/core/services/api/providers/google_common.dart:844-855, 1025-1037 中,将“需原样保留至 roundModelParts 的非函数 Part”的过滤条件,变更为严格的封闭白名单(仅包含 toolCall, toolResponse, inlineData, fileData, executableCode, codeExecutionResult)。

    • 产生后果:旧版实现基于黑名单(Deny-list)策略,未见的未知服务端 Tool Part 仍可正常参与上下文回放。而改为封闭白名单后,若后续 Gemini 引入任何未列入白名单的新类型 Part,均会被静默丢弃。这直接违背了本次 PR 旨在实现“严格保留原始顺序和完整部件 (Preserve exact order / exact parts)”的核心设计目标。

Stop relocating detached Gemini thought signatures onto functionCall parts so replay keeps signatures in the original Part returned by the model. Preserve unknown non-thought model parts during Gemini 3 replay and update regression coverage for detached signatures, parallel function calls, and future tool parts.

Related: Chevey339#488

Files changed:
- lib/core/services/api/providers/google_common.dart: remove late signature relocation and replay raw Gemini 3 model parts
- test/gemini_thought_signature_repro_test.dart: cover no signature relocation, parallel function calls, detached signature parts, and unknown part preservation

Signed-off-by: Shuchen Luo <nemo0806@gmail.com>
@luosc
Copy link
Copy Markdown
Contributor Author

luosc commented Apr 25, 2026

Findings / 发现的问题

  • [High] 非法的 Thought Signature 搬移与冗余,极易触发 INVALID_ARGUMENT 错误
    lib/core/services/api/providers/google_common.dart:1016-1033, 1246-1250, 1410-1411 中,当前的逻辑会在收到 thoughtSignature 时将其回填至前一个 functionCall。但该过程未从 roundModelParts 中剔除原始带有 thoughtSignature 的空文本 Part。

    • 产生后果:导致第二轮请求回放时,既发送了被修改的 functionCall,又残留了原始的独立 Part(无法实现“严格按原样返回接收到的 Part”)。Gemini 官方文档明确要求 signature 必须保留在其原本所在的 Part 中,此种随意搬移及冗余修改极易在服务端引发 INVALID_ARGUMENT 报错。
    • 测试漏洞:当前新增的测试仅断言了 functionCall 是否包含 signature,并未校验冗余的 Detached Part 是否被正确清理,导致该隐患无法被测试有效拦截。
  • [Medium] 错误理解并固化了未文档化的 Gemini 响应契约 (Contract)
    test/gemini_thought_signature_repro_test.dart:279-321 及对应实现 lib/core/services/api/providers/google_common.dart:857-873 中,错误地将“后续空 Part 的 Signature 应回填至首个未签名的 functionCall”作为成型的协议规则。

    • 规范冲突:根据官方文档(https://ai.google.dev/gemini-api/docs/thought-signatures?hl=en) ,当响应中存在 functionCall 时,Gemini 3 会将 thoughtSignature 直接附加于首个 functionCall Part;而“最后一个携带 Signature 的空文本 Part”仅适用于无 Function Calls 的流式响应场景。
    • 产生后果:该测试用例固化了一种缺乏官方文档支持且与官方说明相悖的特殊响应结构。未来若基于此测试用例继续维护,极易破坏合法的并发函数调用(Parallel Function Call)回放逻辑。
  • [Medium] 过严的封闭白名单(Allow-list)过滤策略违背了严格回放原则
    lib/core/services/api/providers/google_common.dart:844-855, 1025-1037 中,将“需原样保留至 roundModelParts 的非函数 Part”的过滤条件,变更为严格的封闭白名单(仅包含 toolCall, toolResponse, inlineData, fileData, executableCode, codeExecutionResult)。

    • 产生后果:旧版实现基于黑名单(Deny-list)策略,未见的未知服务端 Tool Part 仍可正常参与上下文回放。而改为封闭白名单后,若后续 Gemini 引入任何未列入白名单的新类型 Part,均会被静默丢弃。这直接违背了本次 PR 旨在实现“严格保留原始顺序和完整部件 (Preserve exact order / exact parts)”的核心设计目标。

这次移除了 late thoughtSignature 搬移逻辑。Gemini 3 replay 现在会把 thoughtSignature 保留在模型原始返回的 Part 上,不再把 detached signature 挪到 functionCall part 上。
具体改动:

  • 移除了 pending/backfill 机制,避免 detached signature 被重新绑定到 functionCall
  • Gemini 3 replay 改为按原始顺序保留非 thought 的 model parts。
  • 去掉封闭 allow-list 过滤,避免未来新增的未知 model/tool part 被静默丢弃。
  • 更新回归测试覆盖:
    • detached signature 不会被搬到 unsigned functionCall
    • 合法的 functionCall signature 保持在原 functionCall part 上
    • parallel function calls 保持只有第一个 call 带 signature
    • detached signature part 会作为独立 part 回放
    • 未知非 thought part 会被保留
      验证:
  • dart format lib/core/services/api/providers/google_common.dart test/gemini_thought_signature_repro_test.dart
  • flutter analyze
  • flutter test test/gemini_thought_signature_repro_test.dart
  • git diff --check

@Chevey339 Chevey339 merged commit b143c3b into Chevey339:master Apr 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini 3 mixed tool turns lose or misassign thought_signature when google_search coexists with custom function tools

2 participants