From 9fe5652484338da9ad8e920a72c4693a5f1f102d Mon Sep 17 00:00:00 2001 From: Andrew Grande Date: Wed, 25 Feb 2026 12:47:18 -0800 Subject: [PATCH] fix: handle Pydantic models in _safe_json_serialize for tool tracing Tools returning Pydantic BaseModel instances have their trace output silently replaced with '' because json.dumps cannot serialize Pydantic models natively. Update the default handler to call model_dump(mode='json') on BaseModel instances before falling back, consistent with how other functions in the same module already handle Pydantic objects. Fixes #4629 --- src/google/adk/telemetry/tracing.py | 17 +++++--- tests/unittests/telemetry/test_spans.py | 55 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 707bc31396..1c19ba5252 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -109,19 +109,24 @@ def _safe_json_serialize(obj) -> str: """Convert any Python object to a JSON-serializable type or string. + Handles Pydantic BaseModel instances (common as tool return types) + by calling model_dump() before JSON encoding. + Args: obj: The object to serialize. Returns: - The JSON-serialized object string or if the object cannot be serialized. + The JSON-serialized object string or if the object + cannot be serialized. """ + def _default(o: Any) -> Any: + if isinstance(o, BaseModel): + return o.model_dump(mode='json') + return '' try: - # Try direct JSON serialization first - return json.dumps( - obj, ensure_ascii=False, default=lambda o: '' - ) - except (TypeError, OverflowError): + return json.dumps(obj, ensure_ascii=False, default=_default) + except (TypeError, OverflowError, ValueError): return '' diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index 3c061e42a3..929a926c52 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -1175,3 +1175,58 @@ async def test_generate_content_span_with_experimental_semconv( assert attributes[GEN_AI_AGENT_NAME] == invocation_context.agent.name assert GEN_AI_CONVERSATION_ID in attributes assert attributes[GEN_AI_CONVERSATION_ID] == invocation_context.session.id + + +# --------------------------------------------------------------------------- +# _safe_json_serialize tests +# --------------------------------------------------------------------------- + +from google.adk.telemetry.tracing import _safe_json_serialize +from pydantic import BaseModel as PydanticBaseModel + + +class _SampleToolResult(PydanticBaseModel): + query: str + total: int + items: list[str] = [] + + +class _NestedModel(PydanticBaseModel): + inner: _SampleToolResult + + +def test_safe_json_serialize_plain_dict(): + """Plain dicts serialize normally.""" + result = _safe_json_serialize({'key': 'value', 'num': 42}) + assert json.loads(result) == {'key': 'value', 'num': 42} + + +def test_safe_json_serialize_pydantic_model_in_dict(): + """Pydantic models nested in a dict are serialized via model_dump.""" + model = _SampleToolResult(query='test', total=2, items=['a', 'b']) + result = _safe_json_serialize({'result': model}) + parsed = json.loads(result) + assert parsed == {'result': {'query': 'test', 'total': 2, 'items': ['a', 'b']}} + + +def test_safe_json_serialize_nested_pydantic_model(): + """Nested Pydantic models are fully serialized.""" + inner = _SampleToolResult(query='q', total=0, items=[]) + outer = _NestedModel(inner=inner) + result = _safe_json_serialize({'result': outer}) + parsed = json.loads(result) + assert parsed['result']['inner'] == {'query': 'q', 'total': 0, 'items': []} + + +def test_safe_json_serialize_top_level_pydantic_model(): + """A top-level Pydantic model (not wrapped in a dict) is serialized.""" + model = _SampleToolResult(query='direct', total=1, items=['x']) + result = _safe_json_serialize(model) + parsed = json.loads(result) + assert parsed == {'query': 'direct', 'total': 1, 'items': ['x']} + + +def test_safe_json_serialize_non_serializable_fallback(): + """Objects that are neither JSON-native nor Pydantic fall back gracefully.""" + result = _safe_json_serialize({'value': object()}) + assert '' in result