Skip to content

Commit 25d666a

Browse files
aperepelharanrk
authored andcommitted
fix: handle Pydantic models in safe_json_serialize for tool tracing
safe_json_serialize silently replaced Pydantic BaseModel tool return values with "<not serializable>" in trace spans, because json.dumps cannot serialize Pydantic models natively. Tools returning a BaseModel (wrapped by ADK as {"result": <BaseModel>}) had their traced output dropped, degrading observability integrations (e.g. Langfuse, Phoenix) even though the tool itself ran fine. The json.dumps default handler now serializes BaseModel instances via model_dump(mode="json") before falling back to "<not serializable>", consistent with how serialize_content already handles Pydantic objects. Adds unit tests covering plain dicts, nested and top-level Pydantic models, and the non-serializable fallback. Closes #4629 Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 935041717
1 parent 59fe9b3 commit 25d666a

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

src/google/adk/telemetry/_serialization.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,24 @@
2626
def safe_json_serialize(obj: object) -> str:
2727
"""Convert any Python object to a JSON-serializable type or string.
2828
29+
Handles Pydantic `BaseModel` instances (common as tool return types) by
30+
calling `model_dump(mode="json")` before JSON encoding.
31+
2932
Args:
3033
obj: The object to serialize.
3134
3235
Returns:
3336
The JSON-serialized object string or `<not serializable>` if the object
3437
cannot be serialized.
3538
"""
39+
40+
def _default(o: object) -> object:
41+
if isinstance(o, BaseModel):
42+
return o.model_dump(mode="json")
43+
return "<not serializable>"
44+
3645
try:
37-
return json.dumps(
38-
obj, ensure_ascii=False, default=lambda o: "<not serializable>"
39-
)
46+
return json.dumps(obj, ensure_ascii=False, default=_default)
4047
except (TypeError, ValueError, OverflowError, RecursionError):
4148
return "<not serializable>"
4249

tests/unittests/telemetry/test_spans.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_INPUT_TOKENS
5858
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS
5959
from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
60+
from pydantic import BaseModel
6061
import pytest
6162

6263
try:
@@ -1787,3 +1788,57 @@ def test_build_llm_request_for_trace_excludes_live_http_clients():
17871788
json.dumps(result)
17881789
assert 'httpx_async_client' not in result['config'].get('http_options', {})
17891790
assert result['config']['temperature'] == 0.1
1791+
1792+
1793+
# ---------------------------------------------------------------------------
1794+
# safe_json_serialize tests
1795+
# ---------------------------------------------------------------------------
1796+
1797+
1798+
class _SampleToolResult(BaseModel):
1799+
query: str
1800+
total: int
1801+
items: list[str] = []
1802+
1803+
1804+
class _NestedModel(BaseModel):
1805+
inner: _SampleToolResult
1806+
1807+
1808+
def test_safe_json_serialize_plain_dict():
1809+
"""Plain dicts serialize normally."""
1810+
result = safe_json_serialize({'key': 'value', 'num': 42})
1811+
assert json.loads(result) == {'key': 'value', 'num': 42}
1812+
1813+
1814+
def test_safe_json_serialize_pydantic_model_in_dict():
1815+
"""Pydantic models nested in a dict are serialized via model_dump."""
1816+
model = _SampleToolResult(query='test', total=2, items=['a', 'b'])
1817+
result = safe_json_serialize({'result': model})
1818+
parsed = json.loads(result)
1819+
assert parsed == {
1820+
'result': {'query': 'test', 'total': 2, 'items': ['a', 'b']}
1821+
}
1822+
1823+
1824+
def test_safe_json_serialize_nested_pydantic_model():
1825+
"""Nested Pydantic models are fully serialized."""
1826+
inner = _SampleToolResult(query='q', total=0, items=[])
1827+
outer = _NestedModel(inner=inner)
1828+
result = safe_json_serialize({'result': outer})
1829+
parsed = json.loads(result)
1830+
assert parsed['result']['inner'] == {'query': 'q', 'total': 0, 'items': []}
1831+
1832+
1833+
def test_safe_json_serialize_top_level_pydantic_model():
1834+
"""A top-level Pydantic model (not wrapped in a dict) is serialized."""
1835+
model = _SampleToolResult(query='direct', total=1, items=['x'])
1836+
result = safe_json_serialize(model)
1837+
parsed = json.loads(result)
1838+
assert parsed == {'query': 'direct', 'total': 1, 'items': ['x']}
1839+
1840+
1841+
def test_safe_json_serialize_non_serializable_fallback():
1842+
"""Objects that are neither JSON-native nor Pydantic fall back gracefully."""
1843+
result = safe_json_serialize({'value': object()})
1844+
assert '<not serializable>' in result

0 commit comments

Comments
 (0)