Skip to content

Commit 9106c6e

Browse files
author
Mateusz
committed
fix: selectively drop unsigned Gemini tool calls
When preparing Code Assist requests, keep only tool calls that have thought_signature and downgrade only orphaned tool results to bounded plain text; avoid injecting internal downgrade transcript into the prompt.
1 parent a02d889 commit 9106c6e

File tree

2 files changed

+176
-16
lines changed

2 files changed

+176
-16
lines changed

src/connectors/gemini_base/chat_request_preparer.py

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -503,24 +503,59 @@ def _downgrade_tool_calls_to_text(self, canonical_request: Any) -> Any:
503503

504504
downgraded: list[ChatMessage] = []
505505

506+
# Track which tool calls are safe to keep for signature-required backends.
507+
# Any tool call without a thought_signature must be removed, and its tool
508+
# result messages must be downgraded to plain text (otherwise Gemini may
509+
# see orphaned functionResponse parts).
510+
kept_tool_call_ids: set[str] = set()
511+
512+
for raw in messages:
513+
role = (
514+
raw.get("role") if isinstance(raw, dict) else getattr(raw, "role", None)
515+
)
516+
tool_calls = (
517+
raw.get("tool_calls")
518+
if isinstance(raw, dict)
519+
else getattr(raw, "tool_calls", None)
520+
)
521+
if role != "assistant" or not isinstance(tool_calls, list):
522+
continue
523+
for tc in tool_calls:
524+
if not self._extract_thought_signature(tc):
525+
continue
526+
tc_id = (
527+
tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
528+
)
529+
if isinstance(tc_id, str) and tc_id:
530+
kept_tool_call_ids.add(tc_id)
531+
506532
# Avoid exploding prompt size when tool signature recovery is impossible.
507533
# This path is a best-effort salvage mode, typically triggered after a proxy
508534
# restart or when a client does not preserve thought signatures.
509535
max_tool_result_chars = 2000
510536
max_converted_tool_messages = 50
511537

512-
# Keep only the most recent tool result messages.
513-
tool_message_count = 0
538+
# Keep only the most recent tool result messages that we need to downgrade.
539+
convertible_tool_message_count = 0
514540
for raw in messages:
515541
role = (
516542
raw.get("role") if isinstance(raw, dict) else getattr(raw, "role", None)
517543
)
518-
if role == "tool":
519-
tool_message_count += 1
520-
tool_message_skip_before = max(
521-
0, tool_message_count - max_converted_tool_messages
544+
if role != "tool":
545+
continue
546+
tool_call_id = (
547+
raw.get("tool_call_id")
548+
if isinstance(raw, dict)
549+
else getattr(raw, "tool_call_id", None)
550+
)
551+
if isinstance(tool_call_id, str) and tool_call_id in kept_tool_call_ids:
552+
continue
553+
convertible_tool_message_count += 1
554+
555+
convertible_tool_message_skip_before = max(
556+
0, convertible_tool_message_count - max_converted_tool_messages
522557
)
523-
tool_message_seen = 0
558+
convertible_tool_message_seen = 0
524559

525560
for msg in messages:
526561
if isinstance(msg, dict):
@@ -535,23 +570,71 @@ def _downgrade_tool_calls_to_text(self, canonical_request: Any) -> Any:
535570
continue
536571

537572
if msg.role == "assistant" and msg.tool_calls:
573+
kept_tool_calls: list[Any] = []
574+
for tc in msg.tool_calls:
575+
sig = self._extract_thought_signature(tc)
576+
if sig:
577+
kept_tool_calls.append(tc)
578+
tc_id = (
579+
tc.get("id")
580+
if isinstance(tc, dict)
581+
else getattr(tc, "id", None)
582+
)
583+
if isinstance(tc_id, str) and tc_id:
584+
kept_tool_call_ids.add(tc_id)
585+
538586
# IMPORTANT: Do not append any "downgrade" transcript text.
539587
# That text becomes part of the prompt and can easily cause the model
540588
# to repeat it, creating visible loops for clients.
541-
downgraded.append(
542-
ChatMessage(
543-
role="assistant",
544-
content=msg.content,
545-
reasoning_content=msg.reasoning_content,
546-
name=msg.name,
589+
if kept_tool_calls:
590+
# Preserve any descriptive content in a separate message.
591+
if msg.content:
592+
downgraded.append(
593+
ChatMessage(
594+
role="assistant",
595+
content=msg.content,
596+
name=msg.name,
597+
)
598+
)
599+
600+
downgraded.append(
601+
ChatMessage(
602+
role="assistant",
603+
content=None,
604+
tool_calls=kept_tool_calls,
605+
reasoning_content=msg.reasoning_content,
606+
name=msg.name,
607+
)
608+
)
609+
else:
610+
# No tool calls can be kept; keep the text content.
611+
downgraded.append(
612+
ChatMessage(
613+
role="assistant",
614+
content=msg.content,
615+
reasoning_content=msg.reasoning_content,
616+
name=msg.name,
617+
)
547618
)
548-
)
549619
continue
550620

551621
if msg.role == "tool":
552-
tool_message_seen += 1
553-
if tool_message_seen <= tool_message_skip_before:
622+
tool_call_id = msg.tool_call_id
623+
if (
624+
isinstance(tool_call_id, str)
625+
and tool_call_id
626+
and tool_call_id in kept_tool_call_ids
627+
):
628+
downgraded.append(msg)
554629
continue
630+
631+
convertible_tool_message_seen += 1
632+
if (
633+
convertible_tool_message_seen
634+
<= convertible_tool_message_skip_before
635+
):
636+
continue
637+
555638
tool_text = extract_prompt_text([msg])
556639
if tool_text.startswith("tool:"):
557640
tool_text = tool_text[len("tool:") :].lstrip()

tests/unit/connectors/gemini_base/test_chat_request_preparer_thought_signature_downgrade.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,80 @@ def capture(canonical_request: CanonicalChatRequest):
126126
await preparer.prepare(
127127
request_data=request_data, effective_model="gemini-3-flash-preview"
128128
)
129+
130+
131+
@pytest.mark.asyncio
132+
async def test_prepare_keeps_tool_calls_with_signatures_and_downgrades_only_missing() -> (
133+
None
134+
):
135+
context = MockConnectorContext()
136+
converter = MockMessageConverter()
137+
limiter = MockPromptLimiter()
138+
builder = MockRequestBodyBuilder()
139+
140+
translation_service = MagicMock()
141+
142+
def capture(canonical_request: CanonicalChatRequest):
143+
roles = [m.role for m in canonical_request.messages]
144+
assert roles == ["user", "assistant", "assistant", "user", "tool"]
145+
146+
# Descriptive content preserved as text-only assistant message.
147+
assert canonical_request.messages[1].tool_calls is None
148+
assert str(canonical_request.messages[1].content) == "doing tool"
149+
150+
# Only the signed tool call remains.
151+
assert canonical_request.messages[2].tool_calls is not None
152+
assert len(canonical_request.messages[2].tool_calls) == 1
153+
assert canonical_request.messages[2].content is None
154+
assert canonical_request.messages[2].tool_calls[0].id == "t2"
155+
156+
# Unsigned tool response is converted to user text.
157+
assert canonical_request.messages[3].role == "user"
158+
assert "tool_call_id=t1" in str(canonical_request.messages[3].content)
159+
160+
# Signed tool response remains structured.
161+
assert canonical_request.messages[4].role == "tool"
162+
assert canonical_request.messages[4].tool_call_id == "t2"
163+
return {"contents": [{"parts": [{"text": "ok"}]}]}
164+
165+
translation_service.from_domain_to_gemini_request = MagicMock(side_effect=capture)
166+
167+
preparer = ChatRequestPreparer(
168+
connector_context=context,
169+
message_converter=converter,
170+
prompt_limiter=limiter,
171+
request_body_builder=builder,
172+
translation_service=translation_service,
173+
)
174+
175+
request_data = CanonicalChatRequest(
176+
model="gemini-3-flash-preview",
177+
stream=True,
178+
session_id="s1",
179+
messages=[
180+
ChatMessage(role="user", content="hi"),
181+
ChatMessage(
182+
role="assistant",
183+
content="doing tool",
184+
tool_calls=[
185+
ToolCall(
186+
id="t1",
187+
type="function",
188+
function=FunctionCall(name="list_files", arguments="{}"),
189+
),
190+
ToolCall(
191+
id="t2",
192+
type="function",
193+
function=FunctionCall(name="read", arguments="{}"),
194+
extra_content={"google": {"thought_signature": "sig-1"}},
195+
),
196+
],
197+
),
198+
ChatMessage(role="tool", tool_call_id="t1", content="result-one"),
199+
ChatMessage(role="tool", tool_call_id="t2", content="result-two"),
200+
],
201+
)
202+
203+
await preparer.prepare(
204+
request_data=request_data, effective_model="gemini-3-flash-preview"
205+
)

0 commit comments

Comments
 (0)