Skip to content

Commit ce8ccb7

Browse files
Merge branch 'main' into fix/2652-agentevaluator-csv-export
2 parents 9f20128 + b2dda6e commit ce8ccb7

2 files changed

Lines changed: 318 additions & 1 deletion

File tree

src/google/adk/models/interactions_utils.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from google.genai.interactions import ThoughtStep
7373
from google.genai.interactions import ThoughtStepParam
7474
from google.genai.interactions import ToolParam
75+
from google.genai.interactions import UnknownStepDeltaData
7576
from google.genai.interactions import UserInputStepParam
7677
from google.genai.interactions import VideoContentParam
7778
from pydantic import BaseModel
@@ -779,6 +780,101 @@ def _handle_arguments_delta(
779780
return _partial_part_response(chunk_part, interaction_id)
780781

781782

783+
def _handle_unknown_delta(
784+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
785+
) -> LlmResponse | None:
786+
"""Generic fallback: log the unhandled delta, emit nothing."""
787+
if isinstance(delta, UnknownStepDeltaData):
788+
# Forward-compat surprise: preserve the raw payload so it isn't lost.
789+
logger.warning(
790+
'Interactions streaming converter received unrecognized step delta;'
791+
' skipping (no event emitted). raw=%r',
792+
delta.raw,
793+
)
794+
else:
795+
# Known delta type we deliberately don't handle yet: keep log noise low.
796+
logger.debug(
797+
'Interactions streaming converter received unhandled step delta type'
798+
' %r; skipping (no event emitted).',
799+
delta.type,
800+
)
801+
return None
802+
803+
804+
def _handle_thought_summary(
805+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
806+
) -> LlmResponse | None:
807+
content = delta.content
808+
text = None
809+
if content is not None and getattr(content, 'type', None) == 'text':
810+
text = content.text
811+
if not text:
812+
return None
813+
part = types.Part(text=text, thought=True)
814+
state.parts.append(part)
815+
return _partial_part_response(part, interaction_id)
816+
817+
818+
def _handle_thought_signature(
819+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
820+
) -> LlmResponse | None:
821+
signature = delta.signature
822+
if not signature:
823+
return None
824+
for part in reversed(state.parts):
825+
if part.thought:
826+
part.thought_signature = base64.b64decode(signature)
827+
break
828+
return None
829+
830+
831+
def _handle_code_execution_call(
832+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
833+
) -> LlmResponse | None:
834+
args = delta.arguments
835+
code = args.code if args else None
836+
if not code:
837+
return None
838+
language = (
839+
types.Language.PYTHON
840+
if args.language and args.language.lower() == 'python'
841+
else types.Language.LANGUAGE_UNSPECIFIED
842+
)
843+
part = types.Part(
844+
executable_code=types.ExecutableCode(code=code, language=language)
845+
)
846+
state.parts.append(part)
847+
return _partial_part_response(part, interaction_id)
848+
849+
850+
def _handle_code_execution_result(
851+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
852+
) -> LlmResponse | None:
853+
part = types.Part(
854+
code_execution_result=types.CodeExecutionResult(
855+
output=delta.result or '',
856+
outcome=types.Outcome.OUTCOME_FAILED
857+
if delta.is_error
858+
else types.Outcome.OUTCOME_OK,
859+
)
860+
)
861+
state.parts.append(part)
862+
return _partial_part_response(part, interaction_id)
863+
864+
865+
def _handle_function_result(
866+
delta: StepDeltaData, state: _StreamState, interaction_id: str | None
867+
) -> LlmResponse | None:
868+
part = types.Part(
869+
function_response=types.FunctionResponse(
870+
id=delta.call_id or '',
871+
response=_function_result_to_response(delta.result),
872+
)
873+
)
874+
state.parts.append(part)
875+
return _partial_part_response(part, interaction_id)
876+
877+
782878
def convert_interaction_event_to_llm_response(
783879
event: InteractionSSEEvent,
784880
state: _StreamState,
@@ -823,10 +919,22 @@ def convert_interaction_event_to_llm_response(
823919

824920
if delta_type == 'text':
825921
return _handle_text(delta, state, interaction_id)
826-
elif delta_type == 'image':
922+
elif delta_type == 'thought_summary':
923+
return _handle_thought_summary(delta, state, interaction_id)
924+
elif delta_type == 'thought_signature':
925+
return _handle_thought_signature(delta, state, interaction_id)
926+
elif delta_type in ('image', 'audio', 'video', 'document'):
827927
return _handle_media(delta, state, interaction_id)
828928
elif delta_type == 'arguments_delta':
829929
return _handle_arguments_delta(delta, state, interaction_id)
930+
elif delta_type == 'code_execution_call':
931+
return _handle_code_execution_call(delta, state, interaction_id)
932+
elif delta_type == 'code_execution_result':
933+
return _handle_code_execution_result(delta, state, interaction_id)
934+
elif delta_type == 'function_result':
935+
return _handle_function_result(delta, state, interaction_id)
936+
else:
937+
return _handle_unknown_delta(delta, state, interaction_id)
830938

831939
elif isinstance(event, StepStop):
832940
if state.parts and state.parts[-1].function_call:

tests/unittests/models/test_interactions_utils.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from datetime import datetime
2222
from datetime import timezone
2323
import json
24+
import logging
2425
from unittest.mock import MagicMock
2526

2627
from google.adk.models import interactions_utils
@@ -1248,6 +1249,214 @@ def test_image_delta_with_data(self):
12481249
assert result.content.parts[0].inline_data.data == b'image_bytes'
12491250
assert len(state.parts) == 1
12501251

1252+
def test_thought_summary_delta(self):
1253+
"""thought_summary delta becomes a thought part."""
1254+
event = StepDelta(
1255+
event_type='step.delta',
1256+
index=0,
1257+
delta={
1258+
'type': 'thought_summary',
1259+
'content': {'type': 'text', 'text': 'Let me think...'},
1260+
},
1261+
)
1262+
state = interactions_utils._StreamState()
1263+
result = interactions_utils.convert_interaction_event_to_llm_response(
1264+
event, state, interaction_id='int_t'
1265+
)
1266+
assert result is not None
1267+
assert result.partial is True
1268+
part = result.content.parts[0]
1269+
assert part.text == 'Let me think...'
1270+
assert part.thought is True
1271+
assert len(state.parts) == 1
1272+
1273+
def test_thought_signature_delta_attaches_to_last_thought(self):
1274+
"""thought_signature mutates the last thought part and emits no event."""
1275+
state = interactions_utils._StreamState()
1276+
interactions_utils.convert_interaction_event_to_llm_response(
1277+
StepDelta(
1278+
event_type='step.delta',
1279+
index=0,
1280+
delta={
1281+
'type': 'thought_summary',
1282+
'content': {'type': 'text', 'text': 'reasoning'},
1283+
},
1284+
),
1285+
state,
1286+
interaction_id='int_ts',
1287+
)
1288+
sig_b64 = base64.b64encode(b'sig-bytes').decode('utf-8')
1289+
result = interactions_utils.convert_interaction_event_to_llm_response(
1290+
StepDelta(
1291+
event_type='step.delta',
1292+
index=0,
1293+
delta={'type': 'thought_signature', 'signature': sig_b64},
1294+
),
1295+
state,
1296+
interaction_id='int_ts',
1297+
)
1298+
assert result is None
1299+
assert state.parts[-1].thought_signature == b'sig-bytes'
1300+
1301+
def test_audio_delta_with_data(self):
1302+
"""audio delta becomes an inline_data part via the shared media handler."""
1303+
event = StepDelta(
1304+
event_type='step.delta',
1305+
index=0,
1306+
delta={
1307+
'type': 'audio',
1308+
'data': base64.b64encode(b'audio_bytes').decode('utf-8'),
1309+
'mime_type': 'audio/wav',
1310+
},
1311+
)
1312+
state = interactions_utils._StreamState()
1313+
result = interactions_utils.convert_interaction_event_to_llm_response(
1314+
event, state, interaction_id='int_a'
1315+
)
1316+
assert result is not None
1317+
assert result.partial is True
1318+
assert result.content.parts[0].inline_data.data == b'audio_bytes'
1319+
assert result.content.parts[0].inline_data.mime_type == 'audio/wav'
1320+
assert len(state.parts) == 1
1321+
1322+
def test_code_execution_call_delta(self):
1323+
"""code_execution_call delta becomes an executable_code part."""
1324+
event = StepDelta(
1325+
event_type='step.delta',
1326+
index=0,
1327+
delta={
1328+
'type': 'code_execution_call',
1329+
'arguments': {'code': 'print(1)', 'language': 'python'},
1330+
},
1331+
)
1332+
state = interactions_utils._StreamState()
1333+
result = interactions_utils.convert_interaction_event_to_llm_response(
1334+
event, state, interaction_id='int_c'
1335+
)
1336+
assert result is not None
1337+
part = result.content.parts[0]
1338+
assert part.executable_code.code == 'print(1)'
1339+
assert part.executable_code.language == types.Language.PYTHON
1340+
assert len(state.parts) == 1
1341+
1342+
def test_code_execution_result_delta(self):
1343+
"""code_execution_result delta becomes a code_execution_result part."""
1344+
event = StepDelta(
1345+
event_type='step.delta',
1346+
index=0,
1347+
delta={
1348+
'type': 'code_execution_result',
1349+
'result': '1\n',
1350+
'is_error': False,
1351+
},
1352+
)
1353+
state = interactions_utils._StreamState()
1354+
result = interactions_utils.convert_interaction_event_to_llm_response(
1355+
event, state, interaction_id='int_cr'
1356+
)
1357+
assert result is not None
1358+
part = result.content.parts[0]
1359+
assert part.code_execution_result.output == '1\n'
1360+
assert part.code_execution_result.outcome == types.Outcome.OUTCOME_OK
1361+
assert len(state.parts) == 1
1362+
1363+
def test_code_execution_result_error_delta(self):
1364+
"""code_execution_result with is_error maps to OUTCOME_FAILED."""
1365+
event = StepDelta(
1366+
event_type='step.delta',
1367+
index=0,
1368+
delta={
1369+
'type': 'code_execution_result',
1370+
'result': 'Traceback (most recent call last): ...',
1371+
'is_error': True,
1372+
},
1373+
)
1374+
state = interactions_utils._StreamState()
1375+
result = interactions_utils.convert_interaction_event_to_llm_response(
1376+
event, state, interaction_id='int_cr_err'
1377+
)
1378+
assert result is not None
1379+
part = result.content.parts[0]
1380+
assert (
1381+
part.code_execution_result.output
1382+
== 'Traceback (most recent call last): ...'
1383+
)
1384+
assert part.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED
1385+
assert len(state.parts) == 1
1386+
1387+
def test_function_result_delta(self):
1388+
"""function_result delta becomes a function_response part."""
1389+
event = StepDelta(
1390+
event_type='step.delta',
1391+
index=0,
1392+
delta={
1393+
'type': 'function_result',
1394+
'call_id': 'call_9',
1395+
'result': {'temp': 72},
1396+
},
1397+
)
1398+
state = interactions_utils._StreamState()
1399+
result = interactions_utils.convert_interaction_event_to_llm_response(
1400+
event, state, interaction_id='int_fr'
1401+
)
1402+
assert result is not None
1403+
part = result.content.parts[0]
1404+
assert part.function_response.id == 'call_9'
1405+
assert part.function_response.response == {'temp': 72}
1406+
assert len(state.parts) == 1
1407+
1408+
def test_known_unhandled_delta_type_logs_debug_and_drops(self, caplog):
1409+
"""A known but unhandled delta type logs at debug and emits no event."""
1410+
# 'url_context_call' is a recognized genai delta variant that ADK does not
1411+
# handle yet, so it must fall through to the debug branch (not a warning).
1412+
event = StepDelta(
1413+
event_type='step.delta',
1414+
index=0,
1415+
delta={'type': 'url_context_call', 'arguments': {}},
1416+
)
1417+
state = interactions_utils._StreamState()
1418+
with caplog.at_level(logging.DEBUG, logger=interactions_utils.logger.name):
1419+
result = interactions_utils.convert_interaction_event_to_llm_response(
1420+
event, state, interaction_id='int_u'
1421+
)
1422+
assert result is None
1423+
assert not state.parts
1424+
debug_records = [
1425+
r
1426+
for r in caplog.records
1427+
if r.levelno == logging.DEBUG
1428+
and 'unhandled step delta type' in r.message
1429+
]
1430+
assert len(debug_records) == 1
1431+
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
1432+
1433+
def test_unrecognized_delta_logs_raw_warning_and_drops(self, caplog):
1434+
"""A truly-unrecognized delta logs a warning preserving its raw payload."""
1435+
event = StepDelta(
1436+
event_type='step.delta',
1437+
index=0,
1438+
delta={'type': 'totally_made_up_xyz', 'foo': 'bar'},
1439+
)
1440+
state = interactions_utils._StreamState()
1441+
with caplog.at_level(
1442+
logging.WARNING, logger=interactions_utils.logger.name
1443+
):
1444+
result = interactions_utils.convert_interaction_event_to_llm_response(
1445+
event, state, interaction_id='int_u2'
1446+
)
1447+
assert result is None
1448+
assert not state.parts
1449+
warnings = [
1450+
r
1451+
for r in caplog.records
1452+
if r.levelno == logging.WARNING
1453+
and 'unrecognized step delta' in r.message
1454+
]
1455+
assert len(warnings) == 1
1456+
assert 'foo' in warnings[0].message
1457+
# The full raw payload (not just delta.type='UNKNOWN') is preserved.
1458+
assert warnings[0].args == {'type': 'totally_made_up_xyz', 'foo': 'bar'}
1459+
12511460
def test_unknown_event_type_returns_none(self):
12521461
"""Test that unknown event types return None."""
12531462
event = MagicMock()

0 commit comments

Comments
 (0)