Skip to content

Commit 9f3aeef

Browse files
GWealecopybara-github
authored andcommitted
fix: adapt interactions conversion to google-genai 2.9 SDK changes
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 936123710
1 parent 1ff84eb commit 9f3aeef

3 files changed

Lines changed: 110 additions & 68 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ dependencies = [
3636
"click>=8.1.8,<9",
3737
"fastapi>=0.133,<1",
3838
"google-auth[pyopenssl]>=2.47",
39-
"google-genai>=2.8,<3",
39+
"google-genai>=2.9,<3",
4040
"graphviz>=0.20.2,<1",
4141
"httpx>=0.27,<1",
4242
"jsonschema>=4.23,<5",

src/google/adk/models/interactions_utils.py

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131
from __future__ import annotations
3232

3333
import base64
34-
import binascii
3534
import json
3635
import logging
36+
from typing import Any
3737
from typing import AsyncGenerator
3838
from typing import TYPE_CHECKING
3939

@@ -72,6 +72,7 @@
7272
from google.genai.interactions import ToolParam
7373
from google.genai.interactions import UserInputStepParam
7474
from google.genai.interactions import VideoContentParam
75+
from pydantic import BaseModel
7576
from typing_extensions import deprecated
7677

7778
if TYPE_CHECKING:
@@ -108,18 +109,6 @@ def _extract_stream_interaction_id(
108109
return None
109110

110111

111-
def _decode_base64_string(signature: str | None) -> bytes | None:
112-
"""Decode a base64 encoded string."""
113-
if not signature or not isinstance(signature, str):
114-
return None
115-
116-
try:
117-
return base64.b64decode(signature)
118-
except binascii.Error as e:
119-
logger.error('Failed to decode base64 string: %s', e)
120-
return None
121-
122-
123112
def _encode_base64_string(data: bytes) -> str:
124113
"""Encode bytes to a base64 string."""
125114
return base64.b64encode(data).decode('utf-8')
@@ -284,17 +273,12 @@ def _convert_part_to_interaction_content(
284273
TextContentParam(type='text', text=part.text), role
285274
)
286275
elif part.function_call is not None:
287-
func_call_step = FunctionCallStepParam(
276+
return FunctionCallStepParam(
288277
type='function_call',
289278
id=part.function_call.id or '',
290279
name=part.function_call.name or '',
291280
arguments=part.function_call.args or {},
292281
)
293-
if part.thought_signature is not None:
294-
func_call_step['signature'] = _encode_base64_string(
295-
part.thought_signature
296-
)
297-
return func_call_step
298282
elif part.function_response is not None:
299283

300284
# genai.types.FunctionResponse specifies that
@@ -511,6 +495,31 @@ def convert_tools_config_to_interactions_format(
511495
return interaction_tools
512496

513497

498+
def _function_result_to_response(
499+
result: BaseModel | dict[str, Any] | list[Any] | str,
500+
) -> dict[str, Any]:
501+
"""Convert a FunctionResultStep result into a FunctionResponse dict.
502+
503+
The Interactions API types the result as a model, a list of content blocks,
504+
or a plain string, but types.FunctionResponse.response requires a dict. A
505+
dict is returned as-is; other non-dict shapes are wrapped under a 'result'
506+
key.
507+
"""
508+
if isinstance(result, dict):
509+
return result
510+
if isinstance(result, BaseModel):
511+
return result.model_dump()
512+
if isinstance(result, list):
513+
items: list[Any] = []
514+
for item in result:
515+
if isinstance(item, BaseModel):
516+
items.append(item.model_dump())
517+
else:
518+
items.append(item)
519+
return {'result': items}
520+
return {'result': result}
521+
522+
514523
def _convert_interaction_step_to_parts(step: Step) -> list[types.Part]:
515524
"""Convert an interaction output content to a list of types.Part.
516525
@@ -554,23 +563,21 @@ def _convert_interaction_step_to_parts(step: Step) -> list[types.Part]:
554563
step.name,
555564
step.id,
556565
)
557-
thought_signature = _decode_base64_string(step.signature)
558566
return [
559567
types.Part(
560568
function_call=types.FunctionCall(
561569
id=step.id,
562570
name=step.name,
563571
args=step.arguments or {},
564572
),
565-
thought_signature=thought_signature,
566573
)
567574
]
568575
elif isinstance(step, FunctionResultStep):
569576
return [
570577
types.Part(
571578
function_response=types.FunctionResponse(
572579
id=step.call_id or '',
573-
response=step.result,
580+
response=_function_result_to_response(step.result),
574581
)
575582
)
576583
]
@@ -624,13 +631,15 @@ def convert_interaction_to_llm_response(
624631
"""
625632
from .llm_response import LlmResponse
626633

627-
# Check for errors
634+
# Check for errors. Lifecycle SSE events carry a partial interaction
635+
# (InteractionSseEventInteraction) that has no 'error' attribute.
628636
if interaction.status == 'failed':
629637
error_msg = 'Unknown error'
630638
error_code = 'UNKNOWN_ERROR'
631-
if interaction.error:
632-
error_msg = interaction.error.message or error_msg
633-
error_code = interaction.error.code or error_code
639+
error = getattr(interaction, 'error', None)
640+
if error:
641+
error_msg = error.message or error_msg
642+
error_code = error.code or error_code
634643
return LlmResponse(
635644
error_code=error_code,
636645
error_message=error_msg,
@@ -703,14 +712,12 @@ def convert_interaction_event_to_llm_response(
703712
# 2. StepDelta (multiple): Streams arguments as raw JSON strings via arguments.
704713
# 3. StepStop: Signals the end of the step, where arguments are finalized and parsed.
705714
if isinstance(event.step, FunctionCallStep):
706-
thought_signature = _decode_base64_string(event.step.signature)
707-
708715
fc = types.FunctionCall(
709716
id=event.step.id,
710717
name=event.step.name,
711718
partial_args=[],
712719
)
713-
part = types.Part(function_call=fc, thought_signature=thought_signature)
720+
part = types.Part(function_call=fc)
714721
aggregated_parts.append(part)
715722

716723
return LlmResponse(

tests/unittests/models/test_interactions_utils.py

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from google.genai.interactions import Interaction
3434
from google.genai.interactions import InteractionCompletedEvent
3535
from google.genai.interactions import InteractionCreatedEvent
36+
from google.genai.interactions import InteractionSseEventInteraction
3637
from google.genai.interactions import ModelOutputStep
3738
from google.genai.interactions import StepDelta
3839
from google.genai.interactions import StepStart
@@ -110,9 +111,9 @@ def fc_step() -> FunctionCallStep:
110111

111112
def _build_lifecycle_streamed_events(fc_step: FunctionCallStep) -> list[object]:
112113
"""Build streamed events with lifecycle updates carrying the ID."""
113-
now = datetime.now(timezone.utc)
114+
now = datetime.now(timezone.utc).isoformat()
114115

115-
interaction = Interaction(
116+
interaction = InteractionSseEventInteraction(
116117
id='interaction_123',
117118
created=now,
118119
updated=now,
@@ -134,9 +135,9 @@ def _build_lifecycle_streamed_events(fc_step: FunctionCallStep) -> list[object]:
134135

135136
def _build_complete_streamed_events(fc_step: FunctionCallStep) -> list[object]:
136137
"""Build streamed events with the ID on an interaction.complete event."""
137-
now = datetime.now(timezone.utc)
138+
now = datetime.now(timezone.utc).isoformat()
138139

139-
interaction = Interaction(
140+
interaction = InteractionSseEventInteraction(
140141
id='interaction_complete_123',
141142
created=now,
142143
updated=now,
@@ -154,7 +155,7 @@ def _build_complete_streamed_events(fc_step: FunctionCallStep) -> list[object]:
154155

155156
def _build_legacy_streamed_events(fc_step: FunctionCallStep) -> list[object]:
156157
"""Build streamed events with the ID on the legacy interaction event."""
157-
now = datetime.now(timezone.utc)
158+
now = datetime.now(timezone.utc).isoformat()
158159

159160
interaction = Interaction(
160161
id='interaction_legacy_123',
@@ -246,8 +247,8 @@ def test_function_call_part_no_id(self):
246247
assert result['id'] == ''
247248
assert result['name'] == 'get_weather'
248249

249-
def test_function_call_part_with_thought_signature(self):
250-
"""Test converting a function call Part with thought_signature."""
250+
def test_function_call_part_thought_signature_dropped(self):
251+
"""Thought signatures are not sent on interactions function call steps."""
251252
part = types.Part(
252253
function_call=types.FunctionCall(
253254
id='call_456',
@@ -257,14 +258,13 @@ def test_function_call_part_with_thought_signature(self):
257258
thought_signature=b'test_signature_bytes',
258259
)
259260
result = interactions_utils._convert_part_to_interaction_content(part)
260-
assert result['type'] == 'function_call'
261-
assert result['id'] == 'call_456'
262-
assert result['name'] == 'my_tool'
263-
assert result['arguments'] == {'doc': 'content'}
264-
# signature should be base64 encoded
265-
assert 'signature' in result
266-
267-
assert base64.b64decode(result['signature']) == b'test_signature_bytes'
261+
assert result == {
262+
'type': 'function_call',
263+
'id': 'call_456',
264+
'name': 'my_tool',
265+
'arguments': {'doc': 'content'},
266+
}
267+
assert 'signature' not in result
268268

269269
def test_function_call_part_without_thought_signature(self):
270270
"""Test converting a function call Part without thought_signature."""
@@ -765,23 +765,6 @@ def test_function_call_output(self):
765765
assert result.function_call.name == 'get_weather'
766766
assert result.function_call.args == {'city': 'London'}
767767

768-
def test_function_call_output_with_thought_signature(self):
769-
"""Test converting function call output with thought_signature."""
770-
output = FunctionCallStep(
771-
type='function_call',
772-
id='call_sig_123',
773-
name='gemini3_tool',
774-
arguments={'content': 'hello'},
775-
signature=base64.b64encode(b'gemini3_signature').decode('utf-8'),
776-
)
777-
result_list = interactions_utils._convert_interaction_step_to_parts(output)
778-
result = result_list[0] if result_list else None
779-
assert result.function_call.id == 'call_sig_123'
780-
assert result.function_call.name == 'gemini3_tool'
781-
assert result.function_call.args == {'content': 'hello'}
782-
# thought_signature should be decoded back to bytes
783-
assert result.thought_signature == b'gemini3_signature'
784-
785768
def test_function_call_output_without_thought_signature(self):
786769
"""Test converting function call output without thought_signature."""
787770
output = FunctionCallStep(
@@ -809,6 +792,41 @@ def test_function_result_output(self):
809792
assert result.function_response.id == 'call_123'
810793
assert result.function_response.response == {'weather': 'Sunny'}
811794

795+
def test_function_result_output_preserves_none_values(self):
796+
"""None values in a dict result must not be dropped."""
797+
output = FunctionResultStep(
798+
type='function_result',
799+
call_id='call_none',
800+
result={'data': None, 'ok': True},
801+
)
802+
result_list = interactions_utils._convert_interaction_step_to_parts(output)
803+
result = result_list[0] if result_list else None
804+
assert result.function_response.response == {'data': None, 'ok': True}
805+
806+
def test_function_result_output_string(self):
807+
"""A plain string result is wrapped under a 'result' key."""
808+
output = FunctionResultStep(
809+
type='function_result',
810+
call_id='call_str',
811+
result='plain text',
812+
)
813+
result_list = interactions_utils._convert_interaction_step_to_parts(output)
814+
result = result_list[0] if result_list else None
815+
assert result.function_response.response == {'result': 'plain text'}
816+
817+
def test_function_result_output_list(self):
818+
"""A list result of content blocks is wrapped under a 'result' key."""
819+
output = FunctionResultStep(
820+
type='function_result',
821+
call_id='call_list',
822+
result=[{'type': 'text', 'text': 'hi'}],
823+
)
824+
result_list = interactions_utils._convert_interaction_step_to_parts(output)
825+
result = result_list[0] if result_list else None
826+
wrapped = result.function_response.response['result']
827+
assert wrapped[0]['type'] == 'text'
828+
assert wrapped[0]['text'] == 'hi'
829+
812830
def test_image_output_with_data(self):
813831
"""Test converting image output with inline data."""
814832
output = ModelOutputStep(
@@ -909,8 +927,8 @@ def test_successful_text_response(self):
909927
interaction = Interaction(
910928
id='interaction_123',
911929
status='completed',
912-
created=datetime.now(timezone.utc),
913-
updated=datetime.now(timezone.utc),
930+
created=datetime.now(timezone.utc).isoformat(),
931+
updated=datetime.now(timezone.utc).isoformat(),
914932
steps=[
915933
ModelOutputStep(
916934
type='model_output',
@@ -933,8 +951,8 @@ def test_failed_response(self):
933951
interaction = Interaction(
934952
id='interaction_123',
935953
status='failed',
936-
created=datetime.now(timezone.utc),
937-
updated=datetime.now(timezone.utc),
954+
created=datetime.now(timezone.utc).isoformat(),
955+
updated=datetime.now(timezone.utc).isoformat(),
938956
steps=[],
939957
)
940958
interaction.error = MagicMock(code='INVALID_REQUEST', message='Bad request')
@@ -950,8 +968,8 @@ def test_requires_action_response(self):
950968
interaction = Interaction(
951969
id='interaction_123',
952970
status='requires_action',
953-
created=datetime.now(timezone.utc),
954-
updated=datetime.now(timezone.utc),
971+
created=datetime.now(timezone.utc).isoformat(),
972+
updated=datetime.now(timezone.utc).isoformat(),
955973
steps=[
956974
FunctionCallStep(
957975
type='function_call',
@@ -1205,6 +1223,23 @@ def test_unknown_event_type_returns_none(self):
12051223
assert result is None
12061224
assert not aggregated_parts
12071225

1226+
def test_completed_event_failed_partial_interaction(self):
1227+
"""A failed lifecycle event with a partial interaction does not crash."""
1228+
event = InteractionCompletedEvent(
1229+
event_type='interaction.completed',
1230+
interaction=InteractionSseEventInteraction(
1231+
id='int_failed',
1232+
status='failed',
1233+
steps=[],
1234+
),
1235+
)
1236+
result = interactions_utils.convert_interaction_event_to_llm_response(
1237+
event, aggregated_parts=[], interaction_id='int_failed'
1238+
)
1239+
assert result is not None
1240+
assert result.error_code == 'UNKNOWN_ERROR'
1241+
assert result.interaction_id == 'int_failed'
1242+
12081243
def test_function_call_streaming_flow(self):
12091244
"""Test the complete streaming flow for function calls (Start, Delta, Stop)."""
12101245
# 1. StepStart

0 commit comments

Comments
 (0)