agents: repair empty Gemini tool names on every outbound request#48748
agents: repair empty Gemini tool names on every outbound request#48748maxtongwang wants to merge 2 commits intoopenclaw:mainfrom
Conversation
Gemini 3 (preview) can return functionCall blocks with empty name fields. When this happens, pi-ai stores name="" in the assistant message and pi-agent-core creates a toolResult with toolName="". On the next turn, google-shared.js serializes this as functionResponse.name="" and Gemini rejects the whole request with HTTP 400 INVALID_ARGUMENT: "GenerateContentRequest.contents[N].parts[0].function_response.name: Name cannot be empty." sanitizeSessionHistory (which calls sanitizeToolCallInputs + sanitizeToolUseResultPairing) correctly handles this case, but it only runs once at attempt start. Tool call → result cycles within the agent loop build new messages and pass them directly to the provider, bypassing the repair. Fix: add a streamFn wrapper (enabled via new repairToolNamesOnEveryTurn flag in TranscriptPolicy, set true for all Google model APIs) that re-runs sanitizeToolCallInputs + sanitizeToolUseResultPairing on every outbound request. Empty-name tool calls are dropped from the assistant message; the now-orphaned tool results are then dropped as well. This mirrors the existing sanitizeToolCallIds wrapper added for Mistral. Verified: fixes the Gemini 3 Flash Preview "Name cannot be empty" 400s in live production sessions with multi-turn tool use. Refs: openclaw#16263 Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
The two failing CI jobs (
My changes ( |
Greptile SummaryThis PR fixes a hard 400 crash that affected all multi-turn Gemini 3 sessions that involved tool use. Gemini 3 (preview) can return Key changes:
Minor observations:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 2075
Comment:
**Short-circuit check compares `repaired2` against `messages`, not `repaired1`**
The early-return condition `repaired2 === messages` works correctly because both `sanitizeToolCallInputs` and `sanitizeToolUseResultPairing` return the input reference unchanged when they make no modifications. However, a small clarification:
- If step 1 changes the array (`repaired1 !== messages`) but step 2 makes no further changes, then `repaired2 === repaired1 !== messages` — the condition is still false and the correctly repaired array is forwarded. ✓
- Only when _both_ steps are no-ops is `repaired2 === messages` true, allowing the fast path.
This is technically correct, but the intent might be clearer if the check explicitly tests that neither step mutated the array:
```suggestion
if (repaired1 === messages && repaired2 === repaired1) {
```
This makes the "no repair needed" invariant explicit and won't silently misfire if either sanitizer's reference-equality contract ever changes.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 2074
Comment:
**`sanitizeToolUseResultPairing` may insert synthetic tool results**
`sanitizeToolUseResultPairing` not only drops orphaned results — it also inserts synthetic `isError: true` "missing tool result" entries for any assistant tool call that lacks a matching result in the history. This means the wrapper does slightly more than what the comment at line 2069–2070 describes.
In practice this should be benign (the attempt-start `sanitizeSessionHistory` will already have repaired any mismatches), but the in-flight messages built by the agent loop during a turn are not guaranteed to be fully paired before this wrapper runs. If a mid-turn incomplete assistant message were somehow in `context.messages`, this could synthesize unexpected results.
Consider narrowing the wrapper to only `sanitizeToolCallInputs` (the actual fix for #16263) and only calling `sanitizeToolUseResultPairing` when the first step actually dropped something:
```typescript
const repaired1 = sanitizeToolCallInputs(messages as AgentMessage[], {
allowedToolNames: repairAllowedToolNames,
});
if (repaired1 === messages) {
return inner(model, context, options);
}
const repaired2 = sanitizeToolUseResultPairing(repaired1);
```
This is strictly targeted at the reported issue and avoids the (admittedly unlikely) side-effect of synthetic result injection.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: e0709f5 |
Address Greptile review findings: - Only call sanitizeToolUseResultPairing when sanitizeToolCallInputs actually dropped something (repaired1 !== messages). This avoids the side-effect of synthetic result injection on normal turns where all tool call names are already valid. - Restructure to fast-path on repaired1 === messages, making the no-repair invariant explicit rather than relying on a compound reference-equality check across both steps. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
Addressed both Greptile P2 findings in 6894e4f:
|
|
All CI failures on this run are pre-existing on
This PR only modifies: |
Summary
functionCallblocks with emptynamefields. When this happens,pi-aistoresname: ""in the assistant message andpi-agent-corecreates atoolResultwithtoolName: "". On the next turn,google-shared.jsserializes this asfunctionResponse.name: "". Gemini rejects the entire request with HTTP 400 INVALID_ARGUMENT:sanitizeSessionHistory(which callssanitizeToolCallInputs+sanitizeToolUseResultPairing) handles this case correctly, but it only runs once at attempt start. Tool call → result cycles within the agent loop build new messages and pass them directly to the provider, bypassing the repair.streamFnwrapper (enabled via newrepairToolNamesOnEveryTurnflag inTranscriptPolicy, settruefor all Google model APIs) that re-runssanitizeToolCallInputs+sanitizeToolUseResultPairingon every outbound request. Empty-name tool calls are dropped from the assistant message; the orphaned tool results are then dropped as well.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
Google model sessions that previously crashed with "Name cannot be empty" 400 errors after the first tool call now recover gracefully — empty-name tool calls and their results are silently dropped, allowing the conversation to continue.
Security Impact (required)
Repro + Verification
Environment
google/gemini-3-flash-previewviagoogle-generative-aiAPISteps
google/gemini-3-flash-previewas the primary model.Expected
Conversation continues normally even after Gemini returns a function call with an empty name field.
Actual (before fix)
Every subsequent turn in the session accumulated more empty-name entries and continued to fail.
Evidence
Regression test added:
"drops empty-name tool calls and their paired tool results (Gemini #16263)"insession-transcript-repair.test.ts. Verifies the two-step repair pipeline correctly handles the empty-name scenario end-to-end.Policy test added:
"enables repairToolNamesOnEveryTurn for Google APIs"and"disables repairToolNamesOnEveryTurn for non-Google providers"intranscript-policy.test.ts.Live verification: fix deployed on a local OpenClaw instance with
google/gemini-3-flash-preview. Multi-turn tool use sessions that previously hard-crashed at turn 2+ now run to completion.Human Verification (required)
Review Conversations
Compatibility / Migration
Failure Recovery (if this breaks)
repairToolNamesOnEveryTurn: falseinresolveTranscriptPolicysrc/agents/transcript-policy.tsRisks and Mitigations
sanitizeToolCallInputs+sanitizeToolUseResultPairingon every Google request