diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index ad7bc63b81e..ae1e375e00e 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -50,6 +50,7 @@ from pydantic import BaseModel from pydantic import Field from pydantic import PrivateAttr +from pydantic_core import to_jsonable_python from typing_extensions import NotRequired from typing_extensions import override from typing_extensions import Required @@ -1292,6 +1293,14 @@ def completion( def _safe_json_serialize(obj: object) -> str: """Convert any Python object to a JSON-serializable type or string. + A tool result can hold values the json module cannot encode, such as + datetimes, Decimals, UUIDs, enums, sets, bytes or Pydantic models (an + ``output_schema`` agent run through ``AgentTool`` returns dates and Decimals + this way). Those are converted with ``pydantic_core.to_jsonable_python``, as + the Gemini and Gemma paths do, so the model still gets JSON rather than the + Python repr of the whole payload. Only a payload Pydantic cannot serialize + either falls back to ``str()``. + Args: obj: The object to serialize. @@ -1302,6 +1311,13 @@ def _safe_json_serialize(obj: object) -> str: try: # Try direct JSON serialization first return json.dumps(obj, ensure_ascii=False) + except (TypeError, ValueError, OverflowError, RecursionError): + pass + try: + jsonable = to_jsonable_python( + obj, by_alias=False, exclude_none=True, bytes_mode="base64" + ) + return json.dumps(jsonable, ensure_ascii=False) except (TypeError, ValueError, OverflowError, RecursionError): return str(obj) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 18176f313e4..f63b750d2e6 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -14,6 +14,9 @@ import base64 import contextlib +import datetime +import decimal +import enum import json import logging import os @@ -25,6 +28,7 @@ from unittest.mock import MagicMock from unittest.mock import Mock from unittest.mock import patch +import uuid import warnings from google.adk.agents.context_cache_config import ContextCacheConfig @@ -1224,6 +1228,54 @@ def test_safe_json_serialize_circular_dict_falls_back_to_str(): assert isinstance(_safe_json_serialize(obj), str) +def test_safe_json_serialize_converts_values_json_cannot_encode(): + class _Status(enum.Enum): + PAID = "paid" + + class _Line(BaseModel): + sku: str + note: str | None = None + + payload = { + "due_date": datetime.date(2026, 12, 1), + "total": decimal.Decimal("12.50"), + "status": _Status.PAID, + "id": uuid.UUID("12345678-1234-5678-1234-567812345678"), + "lines": [_Line(sku="A-1")], + "missing": None, + } + + assert json.loads(_safe_json_serialize(payload)) == { + "due_date": "2026-12-01", + "total": "12.50", + "status": "paid", + "id": "12345678-1234-5678-1234-567812345678", + "lines": [{"sku": "A-1"}], + "missing": None, + } + + +@pytest.mark.asyncio +async def test_content_to_message_param_function_response_with_date_is_json(): + part = types.Part.from_function_response( + name="extract_invoice", + response={ + "due_date": datetime.date(2026, 12, 1), + "total": decimal.Decimal("12.50"), + }, + ) + part.function_response.id = "tool_call_1" + content = types.Content(role="user", parts=[part]) + + message = await _content_to_message_param(content) + + assert message["role"] == "tool" + assert json.loads(message["content"]) == { + "due_date": "2026-12-01", + "total": "12.50", + } + + def test_safe_json_serialize_circular_list_falls_back_to_str(): obj = [] obj.append(obj)