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