From 7fb409e56046778e50a760017b29115846035297 Mon Sep 17 00:00:00 2001 From: Roydon Sequeira <136970995+roydonsequeira@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:34:47 +0530 Subject: [PATCH 1/3] fix(models): stop Gemma 3 failing on datetime or Pydantic tool results Gemma and Gemma3Ollama rendered each function response as text with a bare json.dumps, but a tool result holds whatever the tool returned. A datetime, bytes or a Pydantic model, such as the LoadMemoryResponse of the built-in load_memory tool, raised TypeError on the next model call and failed the run, and non-ASCII text reached the model as \uXXXX escapes. Serialize the payload with to_jsonable_python the way a Gemini request carries a function response, and keep non-ASCII text as is. Fixes #7667 --- src/google/adk/models/gemma_llm.py | 30 +++++++++- tests/unittests/models/test_gemma_llm.py | 73 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/google/adk/models/gemma_llm.py b/src/google/adk/models/gemma_llm.py index a74b7d83743..37b02cf3882 100644 --- a/src/google/adk/models/gemma_llm.py +++ b/src/google/adk/models/gemma_llm.py @@ -35,6 +35,7 @@ from pydantic import BaseModel from pydantic import Field from pydantic import ValidationError +from pydantic_core import to_jsonable_python from typing_extensions import override logger = logging.getLogger('google_adk.' + __name__) @@ -286,7 +287,7 @@ def _convert_content_parts_for_gemma( has_function_response_part = True response_text = ( f'Invoking tool `{func_response.name}` produced:' - f' `{json.dumps(func_response.response)}`.' + f' `{_function_response_to_json(func_response.response)}`.' ) new_parts.append(Part.from_text(text=response_text)) elif func_call := part.function_call: @@ -299,6 +300,33 @@ def _convert_content_parts_for_gemma( return new_parts, has_function_response_part, has_function_call_part +def _function_response_to_json(response: dict[str, Any] | None) -> str: + """Serializes a function response payload as the JSON text Gemma 3 reads. + + The payload holds whatever the tool returned, which can include values the + json module cannot encode, such as datetimes, bytes or Pydantic models. They + are converted the way a Gemini request carries a function response: unset + model fields are dropped, bytes are base64-encoded, and values Pydantic + cannot serialize fall back to their str(). Non-ASCII text is kept as is, so + the model reads the characters rather than escape sequences. + + Args: + response: The payload of the function response. + + Returns: + The payload as a JSON string. + """ + return json.dumps( + to_jsonable_python( + response, + exclude_none=True, + bytes_mode='base64', + serialize_unknown=True, + ), + ensure_ascii=False, + ) + + def _build_gemma_function_system_instruction( function_declarations: list[FunctionDeclaration], ) -> str: diff --git a/tests/unittests/models/test_gemma_llm.py b/tests/unittests/models/test_gemma_llm.py index 124da4b3120..1b350e78810 100644 --- a/tests/unittests/models/test_gemma_llm.py +++ b/tests/unittests/models/test_gemma_llm.py @@ -12,11 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + from google.adk import models +from google.adk.memory.memory_entry import MemoryEntry from google.adk.models.gemma_llm import Gemma from google.adk.models.google_llm import Gemini from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse +from google.adk.tools.load_memory_tool import LoadMemoryResponse from google.genai import types from google.genai.types import Content from google.genai.types import Part @@ -190,6 +194,75 @@ async def test_preprocess_request_with_function_response(): assert llm_request.contents[0].parts[0].function_call is None +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response,want_json", + [ + pytest.param( + {"city": "München", "forecast": "東京は晴れ"}, + '{"city": "München", "forecast": "東京は晴れ"}', + id="non_ascii_text", + ), + pytest.param( + {"checked_in_at": datetime.datetime(2026, 10, 22, 9, 30)}, + '{"checked_in_at": "2026-10-22T09:30:00"}', + id="datetime", + ), + pytest.param( + {"thumbnail": b"\xff\xd8"}, + '{"thumbnail": "_9g="}', + id="bytes", + ), + pytest.param( + { + "result": LoadMemoryResponse( + memories=[ + MemoryEntry( + content=types.Content( + role="user", + parts=[types.Part(text="I live in Paris.")], + ), + author="user", + ) + ] + ) + }, + '{"result": {"memories": [{"content": {"parts": [{"text": "I live' + ' in Paris."}], "role": "user"}, "custom_metadata": {}, "author":' + ' "user"}]}}', + id="pydantic_model", + ), + ], +) +async def test_preprocess_request_with_function_response_converts_values( + response, want_json +): + """Tool results reach Gemma as readable JSON, whatever values they hold.""" + llm_request = LlmRequest( + model="gemma-3-1b-it", + contents=[ + types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + name="get_booking", response=response + ) + ) + ], + ) + ], + config=types.GenerateContentConfig(), + ) + + await Gemma()._preprocess_request(llm_request) + + assert ( + llm_request.contents[0].parts[0].text + == f"Invoking tool `get_booking` produced: `{want_json}`." + ) + + @pytest.mark.asyncio async def test_preprocess_request_with_function_call(): func_call_data = types.FunctionCall(name="get_current_time", args={}) From 2132e8a770ae76419f4849ba2adab43cb837705f Mon Sep 17 00:00:00 2001 From: Roydon Sequeira <136970995+roydonsequeira@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:59 +0530 Subject: [PATCH 2/3] test(models): cover None values in Gemma 3 tool results A None in the tool's own dicts and lists stays null in the text Gemma 3 reads, as it does in a Gemini request; exclude_none only drops Pydantic model fields that are None. Pin that with a test case, and correct the docstring, which described the dropped fields as unset. --- src/google/adk/models/gemma_llm.py | 9 +++++---- tests/unittests/models/test_gemma_llm.py | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/google/adk/models/gemma_llm.py b/src/google/adk/models/gemma_llm.py index 37b02cf3882..110b54307af 100644 --- a/src/google/adk/models/gemma_llm.py +++ b/src/google/adk/models/gemma_llm.py @@ -305,10 +305,11 @@ def _function_response_to_json(response: dict[str, Any] | None) -> str: The payload holds whatever the tool returned, which can include values the json module cannot encode, such as datetimes, bytes or Pydantic models. They - are converted the way a Gemini request carries a function response: unset - model fields are dropped, bytes are base64-encoded, and values Pydantic - cannot serialize fall back to their str(). Non-ASCII text is kept as is, so - the model reads the characters rather than escape sequences. + are converted the way a Gemini request carries a function response: Pydantic + model fields that are None are dropped (None values in dicts and lists stay + null), bytes are base64-encoded, and values Pydantic cannot serialize fall + back to their str(). Non-ASCII text is kept as is, so the model reads the + characters rather than escape sequences. Args: response: The payload of the function response. diff --git a/tests/unittests/models/test_gemma_llm.py b/tests/unittests/models/test_gemma_llm.py index 1b350e78810..a0bc8d0eb36 100644 --- a/tests/unittests/models/test_gemma_llm.py +++ b/tests/unittests/models/test_gemma_llm.py @@ -213,6 +213,11 @@ async def test_preprocess_request_with_function_response(): '{"thumbnail": "_9g="}', id="bytes", ), + pytest.param( + {"cancelled_at": None, "guests": [{"email": None}]}, + '{"cancelled_at": null, "guests": [{"email": null}]}', + id="none_values", + ), pytest.param( { "result": LoadMemoryResponse( From f5483395469a22d8ae060c5cc6d261dd61b1b740 Mon Sep 17 00:00:00 2001 From: Roydon Sequeira <136970995+roydonsequeira@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:34:11 +0530 Subject: [PATCH 3/3] chore: re-trigger CLA