diff --git a/src/google/adk/models/gemma_llm.py b/src/google/adk/models/gemma_llm.py index a74b7d83743..110b54307af 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,34 @@ 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: 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. + + 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..a0bc8d0eb36 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,80 @@ 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( + {"cancelled_at": None, "guests": [{"email": None}]}, + '{"cancelled_at": null, "guests": [{"email": null}]}', + id="none_values", + ), + 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={})