Skip to content

Commit e07e85b

Browse files
fix(adk-middleware): "SecuritySchemeType is not JSON serializable" (#1332)
* fix(adk-middleware): "SecuritySchemeType is not JSON serializable" this solves the issue of the built-in `adk_request_credentials` function call failing with ``` Traceback (most recent call last): File "/Users/joar.wandborg/git/myproject/.venv/lib/python3.14/site-packages/ag_ui_adk/adk_agent.py", line 2132, in _run_adk_in_background async for ag_ui_event in event_translator.translate_lro_function_calls( ...<5 lines>... logger.debug(f"Event queued: {type(ag_ui_event).__name__} (thread {input.thread_id}, queue size after: {event_queue.qsize()})") File "/Users/joar.wandborg/git/myproject/.venv/lib/python3.14/site-packages/ag_ui_adk/event_translator.py", line 763, in translate_lro_function_calls args_str = json.dumps(long_running_function_call.args) if isinstance(long_running_function_call.args, dict) else str(long_running_function_call.args) ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/joar.wandborg/.local/share/uv/python/cpython-3.14.3-macos-aarch64-none/lib/python3.14/json/__init__.py", line 235, in dumps return _default_encoder.encode(obj) ~~~~~~~~~~~~~~~~~~~~~~~^^^^^ File "/Users/joar.wandborg/.local/share/uv/python/cpython-3.14.3-macos-aarch64-none/lib/python3.14/json/encoder.py", line 202, in encode chunks = self.iterencode(o, _one_shot=True) File "/Users/joar.wandborg/.local/share/uv/python/cpython-3.14.3-macos-aarch64-none/lib/python3.14/json/encoder.py", line 263, in iterencode return _iterencode(o, 0) File "/Users/joar.wandborg/.local/share/uv/python/cpython-3.14.3-macos-aarch64-none/lib/python3.14/json/encoder.py", line 182, in default raise TypeError(f'Object of type {o.__class__.__name__} ' f'is not JSON serializable') TypeError: Object of type SecuritySchemeType is not JSON serializable ``` * refactor(adk-middleware): introduce serialize_tool_args for JSON serialization Added a new utility function `serialize_tool_args` to handle JSON serialization of tool-call arguments, addressing issues with non-standard types like Pydantic models and Enums. Updated relevant code in `client_proxy_tool`, `event_translator`, and `converters` to utilize this new function, ensuring consistent serialization across the application. Added comprehensive tests for the new serialization functionality. --------- Co-authored-by: Mark <mark@contextable.com>
1 parent b903288 commit e07e85b

8 files changed

Lines changed: 195 additions & 11 deletions

File tree

integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121

2222
from .config import PredictStateMapping
23+
from .serialization import serialize_tool_args
2324

2425
logger = logging.getLogger(__name__)
2526

@@ -241,7 +242,7 @@ async def _execute_proxy_tool(self, args: Dict[str, Any], tool_context: Any) ->
241242
logger.debug(f"Emitted TOOL_CALL_START for {tool_call_id}")
242243

243244
# Emit TOOL_CALL_ARGS event
244-
args_json = json.dumps(args)
245+
args_json = serialize_tool_args(args)
245246
args_event = ToolCallArgsEvent(
246247
type=EventType.TOOL_CALL_ARGS,
247248
tool_call_id=tool_call_id,

integrations/adk-middleware/python/src/ag_ui_adk/event_translator.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from google.adk.events import Event as ADKEvent
2424

2525
from .config import PredictStateMapping, normalize_predict_state
26+
from .serialization import serialize_tool_args
2627

2728
import logging
2829
logger = logging.getLogger(__name__)
@@ -761,9 +762,7 @@ async def translate_lro_function_calls(self,adk_event: ADKEvent)-> AsyncGenerato
761762
parent_message_id=None
762763
)
763764
if hasattr(fc, 'args') and fc.args:
764-
# Convert args to string (JSON format)
765-
import json
766-
args_str = json.dumps(fc.args) if isinstance(fc.args, dict) else str(fc.args)
765+
args_str = serialize_tool_args(fc.args)
767766
yield ToolCallArgsEvent(
768767
type=EventType.TOOL_CALL_ARGS,
769768
tool_call_id=fc.id,
@@ -839,8 +838,7 @@ async def _translate_function_calls(
839838

840839
# Emit TOOL_CALL_ARGS if we have arguments
841840
if hasattr(func_call, 'args') and func_call.args:
842-
# Convert args to string (JSON format)
843-
args_str = json.dumps(func_call.args) if isinstance(func_call.args, dict) else str(func_call.args)
841+
args_str = serialize_tool_args(func_call.args)
844842

845843
yield ToolCallArgsEvent(
846844
type=EventType.TOOL_CALL_ARGS,
@@ -1198,7 +1196,7 @@ def _translate_function_calls_to_tool_calls(function_calls: List[Any]) -> List[T
11981196
type="function",
11991197
function=FunctionCall(
12001198
name=fc.name,
1201-
arguments=json.dumps(fc.args) if hasattr(fc, 'args') and fc.args else "{}"
1199+
arguments=serialize_tool_args(fc.args) if hasattr(fc, 'args') and fc.args else "{}"
12021200
)
12031201
)
12041202
tool_calls.append(tool_call)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Shared JSON serialization helpers for tool-call arguments.
2+
3+
Standard ``json.dumps`` fails when args dicts contain Pydantic models or
4+
Python ``Enum`` values (e.g. ``SecuritySchemeType``). The helper here uses
5+
Pydantic's ``TypeAdapter`` which knows how to serialize those types.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
from pydantic import TypeAdapter
13+
14+
_dict_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
15+
16+
17+
def serialize_tool_args(args: Any) -> str:
18+
"""Serialize tool-call *args* to a JSON string.
19+
20+
Handles dicts that may contain Pydantic models, Enums, or other
21+
non-stdlib-serializable values by delegating to Pydantic's
22+
``TypeAdapter.dump_json``.
23+
24+
Returns:
25+
A JSON-encoded string. For non-dict values the result is
26+
``str(args)``.
27+
"""
28+
if isinstance(args, dict):
29+
return _dict_adapter.dump_json(args).decode()
30+
return str(args)

integrations/adk-middleware/python/src/ag_ui_adk/utils/converters.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from google.adk.events import Event as ADKEvent
1616
from google.genai import types
1717

18+
from ..serialization import serialize_tool_args
19+
1820
logger = logging.getLogger(__name__)
1921

2022
def _get_text_value(item: Union[dict, TextInputContent]) -> Optional[str]:
@@ -234,7 +236,7 @@ def convert_adk_event_to_ag_ui_message(event: ADKEvent) -> Optional[Message]:
234236
type="function",
235237
function=FunctionCall(
236238
name=part.function_call.name,
237-
arguments=json.dumps(part.function_call.args) if hasattr(part.function_call, 'args') else "{}"
239+
arguments=serialize_tool_args(part.function_call.args) if hasattr(part.function_call, 'args') else "{}"
238240
)
239241
))
240242

integrations/adk-middleware/python/tests/test_event_translator_comprehensive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ async def test_translate_function_calls_basic(self, translator, mock_adk_event):
766766
assert events[0].tool_call_id == "call_123"
767767
assert events[0].tool_call_name == "test_function"
768768
assert events[1].tool_call_id == "call_123"
769-
assert events[1].delta == '{"param1": "value1"}'
769+
assert json.loads(events[1].delta) == {"param1": "value1"}
770770
assert events[2].tool_call_id == "call_123"
771771

772772
@pytest.mark.asyncio

integrations/adk-middleware/python/tests/test_lro_sse_id_remap.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""
1818

1919
import asyncio
20+
import json
2021
import os
2122
import uuid
2223
import warnings
@@ -276,6 +277,85 @@ async def test_lro_emitted_ids_cleared_on_reset(self, translator):
276277
translator.reset()
277278
assert translator.lro_emitted_ids_by_name == {}
278279

280+
@pytest.mark.asyncio
281+
async def test_lro_adk_request_credential_oauth2(self, translator):
282+
"""Regression (#1331): adk_request_credential with OAuth2 AuthConfig must serialize.
283+
284+
ADK emits a long-running function call named ``adk_request_credential``
285+
whose args dict contains an ``AuthConfig`` Pydantic model. The model
286+
in turn nests ``OAuth2`` which has a ``type_: SecuritySchemeType`` enum
287+
field. Before the fix, ``json.dumps`` raised:
288+
289+
TypeError: Object of type SecuritySchemeType is not JSON serializable
290+
"""
291+
from fastapi.openapi.models import OAuthFlowAuthorizationCode
292+
from google.adk.auth.auth_schemes import OAuth2, OAuthFlows, SecuritySchemeType
293+
from google.adk.auth import AuthConfig
294+
from google.adk.auth.auth_credential import (
295+
AuthCredential,
296+
AuthCredentialTypes,
297+
OAuth2Auth,
298+
)
299+
300+
auth_scheme = OAuth2(
301+
flows=OAuthFlows(
302+
authorizationCode=OAuthFlowAuthorizationCode(
303+
authorizationUrl="https://accounts.google.com/o/oauth2/auth",
304+
tokenUrl="https://oauth2.googleapis.com/token",
305+
scopes={
306+
"https://www.googleapis.com/auth/calendar": "Calendar access",
307+
},
308+
),
309+
),
310+
)
311+
raw_credential = AuthCredential(
312+
auth_type=AuthCredentialTypes.OAUTH2,
313+
oauth2=OAuth2Auth(
314+
client_id="123456.apps.googleusercontent.com",
315+
client_secret="GOCSPX-secret",
316+
),
317+
)
318+
auth_config = AuthConfig(
319+
auth_scheme=auth_scheme,
320+
raw_auth_credential=raw_credential,
321+
)
322+
323+
fc = MagicMock()
324+
fc.id = "adk-cred-123"
325+
fc.name = "adk_request_credential"
326+
fc.args = {
327+
"function_call_id": "adk-cred-123",
328+
"auth_config": auth_config,
329+
}
330+
part = MagicMock()
331+
part.function_call = fc
332+
part.text = None
333+
evt = MagicMock()
334+
evt.content = MagicMock()
335+
evt.content.parts = [part]
336+
evt.long_running_tool_ids = ["adk-cred-123"]
337+
338+
events = []
339+
async for e in translator.translate_lro_function_calls(evt):
340+
events.append(e)
341+
342+
assert len(events) == 3
343+
assert events[0].type == EventType.TOOL_CALL_START
344+
assert events[0].tool_call_name == "adk_request_credential"
345+
346+
args_event = events[1]
347+
assert args_event.type == EventType.TOOL_CALL_ARGS
348+
parsed = json.loads(args_event.delta)
349+
assert parsed["function_call_id"] == "adk-cred-123"
350+
351+
ac = parsed["auth_config"]
352+
assert ac["auth_scheme"]["type_"] == "oauth2"
353+
assert ac["raw_auth_credential"]["auth_type"] == "oauth2"
354+
assert ac["raw_auth_credential"]["oauth2"]["client_id"] == "123456.apps.googleusercontent.com"
355+
auth_code_flow = ac["auth_scheme"]["flows"]["authorizationCode"]
356+
assert auth_code_flow["authorizationUrl"] == "https://accounts.google.com/o/oauth2/auth"
357+
assert auth_code_flow["tokenUrl"] == "https://oauth2.googleapis.com/token"
358+
279359
@pytest.mark.asyncio
280360
async def test_parallel_same_name_lro_calls_all_emitted(self, translator):
281361
"""Multiple parallel LRO calls to the same tool should all be emitted (issue #1334)."""
@@ -1169,4 +1249,4 @@ def no_streaming_config(inp):
11691249
else:
11701250
print("No Google authentication — running unit tests only")
11711251
print("Set GOOGLE_API_KEY or configure Vertex AI to run integration tests")
1172-
pytest.main([__file__, "-v", "-s", "-k", "not Integration"])
1252+
pytest.main([__file__, "-v", "-s", "-k", "not Integration"])
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Tests for the shared serialize_tool_args helper.
2+
3+
Covers plain dicts, dicts containing Python Enums (the SecuritySchemeType
4+
scenario), dicts containing Pydantic models, non-dict values, and edge cases.
5+
"""
6+
7+
import enum
8+
import json
9+
10+
from pydantic import BaseModel
11+
12+
from ag_ui_adk.serialization import serialize_tool_args
13+
14+
15+
class FakeSecuritySchemeType(enum.Enum):
16+
oauth2 = "oauth2"
17+
apiKey = "apiKey"
18+
19+
20+
class NestedModel(BaseModel):
21+
url: str
22+
scheme_type: FakeSecuritySchemeType
23+
24+
25+
class TestSerializeToolArgs:
26+
27+
def test_plain_dict(self):
28+
args = {"city": "Seattle", "units": "metric"}
29+
result = serialize_tool_args(args)
30+
assert json.loads(result) == args
31+
32+
def test_dict_with_enum_value(self):
33+
"""Regression (#1331): SecuritySchemeType-like enums must not raise TypeError."""
34+
args = {
35+
"auth_type": FakeSecuritySchemeType.oauth2,
36+
"scopes": ["read", "write"],
37+
}
38+
result = serialize_tool_args(args)
39+
parsed = json.loads(result)
40+
assert parsed["auth_type"] == "oauth2"
41+
assert parsed["scopes"] == ["read", "write"]
42+
43+
def test_dict_with_pydantic_model_value(self):
44+
args = {
45+
"endpoint": NestedModel(
46+
url="https://example.com",
47+
scheme_type=FakeSecuritySchemeType.apiKey,
48+
)
49+
}
50+
result = serialize_tool_args(args)
51+
parsed = json.loads(result)
52+
assert parsed["endpoint"]["url"] == "https://example.com"
53+
assert parsed["endpoint"]["scheme_type"] == "apiKey"
54+
55+
def test_dict_with_nested_enum(self):
56+
args = {
57+
"config": {
58+
"type": FakeSecuritySchemeType.oauth2,
59+
"enabled": True,
60+
}
61+
}
62+
result = serialize_tool_args(args)
63+
parsed = json.loads(result)
64+
assert parsed["config"]["type"] == "oauth2"
65+
66+
def test_string_args_passthrough(self):
67+
assert serialize_tool_args("raw_string") == "raw_string"
68+
69+
def test_non_dict_non_string(self):
70+
assert serialize_tool_args(42) == "42"
71+
72+
def test_empty_dict(self):
73+
assert serialize_tool_args({}) == "{}"

integrations/adk-middleware/python/tests/test_utils_converters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def test_convert_assistant_event_with_function_call(self):
399399
assert tool_call.id == "call_123"
400400
assert tool_call.type == "function"
401401
assert tool_call.function.name == "get_weather"
402-
assert tool_call.function.arguments == '{"location": "Boston"}'
402+
assert json.loads(tool_call.function.arguments) == {"location": "Boston"}
403403

404404
def test_convert_assistant_event_with_text_and_function_call(self):
405405
"""Test converting assistant event with both text and function call."""

0 commit comments

Comments
 (0)