Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/google/adk/workflow/_function_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

from __future__ import annotations

import base64
import binascii

import asyncio
import collections.abc
from collections.abc import AsyncGenerator
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions tests/unittests/workflow/test_function_node_bytes_json_resume.py
Original file line number Diff line number Diff line change
@@ -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"
Loading