From 287a3c0f34614e4ff96be1b807b38c8f5dce45f4 Mon Sep 17 00:00:00 2001 From: ninglinLiu Date: Fri, 30 Oct 2026 10:22:36 +0800 Subject: [PATCH] fix(workflow): decode base64 bytes after JSON session resume Event.output is typed Any, so Database/Sqlite JSON round-trips turn bytes into base64 strings. TypeAdapter(bytes) then utf-8-encodes the string. Prefer base64 decode when the parameter annotation is bytes. Fixes #7852 --- src/google/adk/workflow/_function_node.py | 17 ++++++ .../test_function_node_bytes_json_resume.py | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/unittests/workflow/test_function_node_bytes_json_resume.py diff --git a/src/google/adk/workflow/_function_node.py b/src/google/adk/workflow/_function_node.py index 176dae21f2e..e10437e0450 100644 --- a/src/google/adk/workflow/_function_node.py +++ b/src/google/adk/workflow/_function_node.py @@ -14,6 +14,9 @@ from __future__ import annotations +import base64 +import binascii + import asyncio import collections.abc from collections.abc import AsyncGenerator @@ -59,6 +62,11 @@ from .utils._workflow_hitl_utils import process_auth_resume logger = logging.getLogger("google_adk." + __name__) + +def _annotation_is_bytes(annotated_type: Any) -> bool: + """True when the annotation is exactly ``bytes`` or ``bytearray``.""" + return annotated_type is bytes or annotated_type is bytearray + _ANY_TYPE_ADAPTER: TypeAdapter[Any] = TypeAdapter(Any) @@ -696,6 +704,15 @@ def _coerce_param( value = _unwrap_content_payload(value, annotated_type) if param_name in self._failed_type_adapters: return value + # JSON session stores (DatabaseSessionService / SqliteSessionService) dump + # bytes inside Event.output (typed Any) as base64 strings. TypeAdapter(bytes) + # then utf-8-encodes the string (so b"\x00\x01" becomes b"AAE="). Prefer + # base64 decode when the annotation is bytes and the value is still a str. + if isinstance(value, str) and _annotation_is_bytes(annotated_type): + try: + return base64.b64decode(value, validate=True) + except binascii.Error: + pass adapter = self._type_adapters.get(param_name) if adapter is None: adapter = self._build_type_adapter(param_name, annotated_type) diff --git a/tests/unittests/workflow/test_function_node_bytes_json_resume.py b/tests/unittests/workflow/test_function_node_bytes_json_resume.py new file mode 100644 index 00000000000..96cd6822b62 --- /dev/null +++ b/tests/unittests/workflow/test_function_node_bytes_json_resume.py @@ -0,0 +1,54 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression: bytes node_input after JSON Event round-trip (Fixes #7852).""" + +from __future__ import annotations + +import base64 + +from google.adk.events.event import Event +from google.adk.workflow._function_node import FunctionNode + + +def test_function_node_coerces_base64_str_to_bytes_after_event_json_roundtrip(): + """Database/Sqlite session stores Event.output as JSON; bytes become base64. + + Event.output is typed Any, so val_json_bytes does not restore bytes on load. + FunctionNode must base64-decode when the parameter annotation is bytes. + """ + + def consume(node_input: bytes): + return node_input + + node = FunctionNode(func=consume, name="consume") + original = b"\x00\x01" + event = Event(author="produce", invocation_id="inv", output=original) + restored = Event.model_validate(event.model_dump(mode="json", exclude_none=True)) + assert isinstance(restored.output, str) + assert restored.output == base64.b64encode(original).decode("ascii") + + coerced = node._coerce_param("node_input", restored.output, bytes) + assert coerced == original + assert isinstance(coerced, bytes) + + +def test_function_node_bytes_coerce_falls_back_for_non_base64_str(): + def consume(node_input: bytes): + return node_input + + node = FunctionNode(func=consume, name="consume") + # Not valid base64 (wrong padding / alphabet) → TypeAdapter encodes utf-8 + out = node._coerce_param("node_input", "hello", bytes) + assert out == b"hello"