Skip to content

Commit 51f21e7

Browse files
wyf7107copybara-github
authored andcommitted
fix: preserve Gemini 3 thought signatures in interaction history
Gemini 3 returns an opaque thought signature alongside function calls and rejects follow-up requests unless this signature is echoed back verbatim Co-authored-by: Yifan Wang <[email protected]> PiperOrigin-RevId: 972130356
1 parent 198a328 commit 51f21e7

2 files changed

Lines changed: 242 additions & 14 deletions

File tree

‎src/google/adk/models/interactions_utils.py‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,16 @@ def _convert_interaction_step_to_parts(step: Step) -> list[types.Part]:
636636
)
637637
]
638638
elif isinstance(step, ThoughtStep):
639-
# ThoughtContent has a 'signature' attribute, not 'thought'
640-
# These are internal model reasoning and typically not exposed as Parts
641-
# Skip thought outputs for now
639+
# Gemini 3 rejects the follow-up unless this signature is echoed back.
640+
# Keep it on a part of its own: a part that also carries text is
641+
# re-encoded as a text step, which drops the signature.
642+
if step.signature:
643+
return [
644+
types.Part(
645+
thought=True,
646+
thought_signature=base64.b64decode(step.signature),
647+
)
648+
]
642649
return []
643650
elif isinstance(step, CodeExecutionResultStep):
644651
return [
@@ -903,7 +910,12 @@ def _handle_thought_signature(
903910
for part in reversed(state.parts):
904911
if part.thought:
905912
part.thought_signature = base64.b64decode(signature)
906-
break
913+
return None
914+
# A signature often arrives with no preceding thought summary, leaving no
915+
# part to attach it to. Keep it on its own part rather than dropping it.
916+
state.parts.append(
917+
types.Part(thought=True, thought_signature=base64.b64decode(signature))
918+
)
907919
return None
908920

909921

@@ -1469,15 +1481,26 @@ def _get_latest_user_contents(
14691481

14701482
# Find the latest continuous user messages from the end
14711483
latest_user_contents: list[types.Content] = []
1472-
for i in range(len(contents) - 1, -1, -1):
1473-
content = contents[i]
1474-
if content.role == 'user':
1475-
latest_user_contents.append(content)
1476-
else:
1477-
# Stop when we hit a non-user message
1478-
break
1484+
index = len(contents) - 1
1485+
while index >= 0 and contents[index].role == 'user':
1486+
latest_user_contents.append(contents[index])
1487+
index -= 1
14791488

14801489
latest_user_contents.reverse()
1490+
1491+
# The thought signature Gemini model needs back sits on the preceding
1492+
# model turn, outside the user-only window above. Carry just those parts
1493+
# across; the rest of that turn is already server-side under
1494+
# previous_interaction_id.
1495+
if index >= 0:
1496+
signature_parts = [
1497+
part for part in contents[index].parts or [] if part.thought_signature
1498+
]
1499+
if signature_parts:
1500+
latest_user_contents.insert(
1501+
0, types.Content(role='model', parts=signature_parts)
1502+
)
1503+
14811504
return latest_user_contents
14821505

14831506

‎tests/unittests/models/test_interactions_utils.py‎

Lines changed: 208 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,12 +1094,57 @@ def test_code_execution_result_error_output(self):
10941094
assert result.code_execution_result.output == 'Error: division by zero'
10951095
assert result.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED
10961096

1097-
def test_thought_output_returns_empty(self):
1098-
"""Test that thought output returns empty list (not exposed as Part)."""
1099-
output = ThoughtStep(type='thought', signature='thinking...')
1097+
def test_thought_output_without_signature_returns_empty(self):
1098+
"""A thought step with no signature contributes no parts."""
1099+
output = ThoughtStep(type='thought')
11001100
result = interactions_utils._convert_interaction_step_to_parts(output)
11011101
assert result == []
11021102

1103+
def test_thought_output_with_signature_becomes_thought_part(self):
1104+
"""A thought step's signature is decoded onto a thought part."""
1105+
output = ThoughtStep(
1106+
type='thought',
1107+
signature=base64.b64encode(b'sig-bytes').decode('utf-8'),
1108+
)
1109+
1110+
result = interactions_utils._convert_interaction_step_to_parts(output)
1111+
1112+
assert len(result) == 1
1113+
assert result[0].thought is True
1114+
assert result[0].thought_signature == b'sig-bytes'
1115+
1116+
def test_thought_output_signature_survives_the_round_trip_back(self):
1117+
"""The signature part re-encodes as a thought step carrying the signature.
1118+
1119+
Gemini 3 only accepts the follow-up request if the signature comes back
1120+
verbatim, so the part has to convert back to a step that still has one.
1121+
"""
1122+
signature = base64.b64encode(b'sig-bytes').decode('utf-8')
1123+
output = ThoughtStep(type='thought', signature=signature)
1124+
1125+
part = interactions_utils._convert_interaction_step_to_parts(output)[0]
1126+
1127+
assert interactions_utils._convert_part_to_interaction_content(part) == {
1128+
'type': 'thought',
1129+
'signature': signature,
1130+
}
1131+
1132+
def test_thought_output_summary_is_not_carried_onto_the_part(self):
1133+
"""The signature part stays text-free.
1134+
1135+
A part that also carries text converts back to a text step, which has no
1136+
signature field, so the signature would be dropped on the next request.
1137+
"""
1138+
output = ThoughtStep(
1139+
type='thought',
1140+
signature=base64.b64encode(b'sig-bytes').decode('utf-8'),
1141+
summary=[TextContent(type='text', text='Let me think...')],
1142+
)
1143+
1144+
result = interactions_utils._convert_interaction_step_to_parts(output)
1145+
1146+
assert result[0].text is None
1147+
11031148
def test_no_type_attribute(self):
11041149
"""Test handling output without type attribute."""
11051150
output = MagicMock(spec=[]) # No 'type' attribute
@@ -1422,6 +1467,93 @@ def test_full_conversation(self):
14221467
assert result[0].parts[0].text == 'Great'
14231468
assert result[1].parts[0].text == 'Tell me more'
14241469

1470+
def test_signature_from_preceding_model_turn_is_carried_over(self):
1471+
"""A tool call's thought signature leads the returned contents.
1472+
1473+
The signature sits on the model turn that requested the tool call, one
1474+
step outside the trailing user window, but Gemini 3 rejects the request
1475+
that carries the tool result unless the signature comes back with it.
1476+
"""
1477+
contents = [
1478+
types.Content(role='user', parts=[types.Part(text='Weather?')]),
1479+
types.Content(
1480+
role='model',
1481+
parts=[
1482+
types.Part(
1483+
function_call=types.FunctionCall(name='get_weather'),
1484+
thought_signature=b'sig-bytes',
1485+
)
1486+
],
1487+
),
1488+
types.Content(
1489+
role='user',
1490+
parts=[
1491+
types.Part(
1492+
function_response=types.FunctionResponse(
1493+
name='get_weather', response={'temp': 20}
1494+
)
1495+
)
1496+
],
1497+
),
1498+
]
1499+
1500+
result = interactions_utils._get_latest_user_contents(contents)
1501+
1502+
assert len(result) == 2
1503+
assert result[0].role == 'model'
1504+
assert result[0].parts[0].thought_signature == b'sig-bytes'
1505+
assert result[1].role == 'user'
1506+
1507+
def test_signature_free_parts_of_that_turn_are_left_behind(self):
1508+
"""Only the signature-bearing parts of the model turn come across.
1509+
1510+
The rest of that turn is already server-side under
1511+
previous_interaction_id, so re-sending it would duplicate it.
1512+
"""
1513+
contents = [
1514+
types.Content(
1515+
role='model',
1516+
parts=[
1517+
types.Part(text='Let me check the forecast.'),
1518+
types.Part(thought=True, thought_signature=b'sig-bytes'),
1519+
],
1520+
),
1521+
types.Content(role='user', parts=[types.Part(text='Thanks')]),
1522+
]
1523+
1524+
result = interactions_utils._get_latest_user_contents(contents)
1525+
1526+
assert len(result[0].parts) == 1
1527+
assert result[0].parts[0].thought_signature == b'sig-bytes'
1528+
1529+
def test_signature_carried_across_several_trailing_user_messages(self):
1530+
"""The signature part stays ahead of every message in the user window."""
1531+
contents = [
1532+
types.Content(
1533+
role='model',
1534+
parts=[types.Part(thought=True, thought_signature=b'sig-bytes')],
1535+
),
1536+
types.Content(role='user', parts=[types.Part(text='First')]),
1537+
types.Content(role='user', parts=[types.Part(text='Second')]),
1538+
]
1539+
1540+
result = interactions_utils._get_latest_user_contents(contents)
1541+
1542+
assert [content.role for content in result] == ['model', 'user', 'user']
1543+
assert result[0].parts[0].thought_signature == b'sig-bytes'
1544+
1545+
def test_partless_preceding_model_turn_is_skipped(self):
1546+
"""A model turn with no parts contributes nothing and does not raise."""
1547+
contents = [
1548+
types.Content(role='model'),
1549+
types.Content(role='user', parts=[types.Part(text='Hello')]),
1550+
]
1551+
1552+
result = interactions_utils._get_latest_user_contents(contents)
1553+
1554+
assert len(result) == 1
1555+
assert result[0].parts[0].text == 'Hello'
1556+
14251557

14261558
class TestConvertInteractionEventToLlmResponse:
14271559
"""Tests for convert_interaction_event_to_llm_response."""
@@ -1514,6 +1646,79 @@ def test_thought_signature_delta_attaches_to_last_thought(self):
15141646
assert result is None
15151647
assert state.parts[-1].thought_signature == b'sig-bytes'
15161648

1649+
def test_thought_signature_delta_alone_becomes_its_own_part(self):
1650+
"""A signature with no preceding thought summary still reaches the stream.
1651+
1652+
The model routinely emits a signature without a summary, and dropping it
1653+
would make the next request fail.
1654+
"""
1655+
state = interactions_utils._StreamState()
1656+
1657+
result = interactions_utils.convert_interaction_event_to_llm_response(
1658+
StepDelta(
1659+
event_type='step.delta',
1660+
index=0,
1661+
delta={
1662+
'type': 'thought_signature',
1663+
'signature': base64.b64encode(b'lone-sig').decode('utf-8'),
1664+
},
1665+
),
1666+
state,
1667+
interaction_id='int_ts',
1668+
)
1669+
1670+
assert result is None
1671+
assert len(state.parts) == 1
1672+
assert state.parts[0].thought is True
1673+
assert state.parts[0].thought_signature == b'lone-sig'
1674+
1675+
def test_thought_signature_delta_does_not_attach_to_a_text_part(self):
1676+
"""A signature never lands on a text part, which cannot carry one."""
1677+
state = interactions_utils._StreamState()
1678+
interactions_utils.convert_interaction_event_to_llm_response(
1679+
StepDelta(
1680+
event_type='step.delta',
1681+
index=0,
1682+
delta={'type': 'text', 'text': 'The weather is sunny.'},
1683+
),
1684+
state,
1685+
interaction_id='int_ts',
1686+
)
1687+
1688+
interactions_utils.convert_interaction_event_to_llm_response(
1689+
StepDelta(
1690+
event_type='step.delta',
1691+
index=0,
1692+
delta={
1693+
'type': 'thought_signature',
1694+
'signature': base64.b64encode(b'sig-bytes').decode('utf-8'),
1695+
},
1696+
),
1697+
state,
1698+
interaction_id='int_ts',
1699+
)
1700+
1701+
assert state.parts[0].text == 'The weather is sunny.'
1702+
assert state.parts[0].thought_signature is None
1703+
assert state.parts[1].thought_signature == b'sig-bytes'
1704+
1705+
def test_thought_signature_delta_without_signature_adds_no_part(self):
1706+
"""An empty signature delta leaves the stream state untouched."""
1707+
state = interactions_utils._StreamState()
1708+
1709+
result = interactions_utils.convert_interaction_event_to_llm_response(
1710+
StepDelta(
1711+
event_type='step.delta',
1712+
index=0,
1713+
delta={'type': 'thought_signature', 'signature': ''},
1714+
),
1715+
state,
1716+
interaction_id='int_ts',
1717+
)
1718+
1719+
assert result is None
1720+
assert state.parts == []
1721+
15171722
def test_audio_delta_with_data(self):
15181723
"""audio delta becomes an inline_data part via the shared media handler."""
15191724
event = StepDelta(

0 commit comments

Comments
 (0)