Skip to content

Commit 3362430

Browse files
committed
feature: 支持json_repair
1 parent 971af87 commit 3362430

13 files changed

Lines changed: 321 additions & 13 deletions

File tree

requirements-test.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ nanobot-ai>=0.1.4.post5
4343
wecom-aibot-sdk-python>=0.1.5
4444

4545
# Test Core Dependencies
46-
a2a-sdk>=0.2.0
46+
a2a-sdk<1.0.0,>=0.3.22
4747
protobuf>=5.29.5
48-
claude-agent-sdk>=0.1.3
48+
claude-agent-sdk>=0.1.3,<0.1.64
4949
cloudpickle>=2.0.0
5050
ag-ui-protocol>=0.1.8
5151
aiofiles

tests/utils/test_init.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from trpc_agent_sdk.utils._execute_cmd import CommandExecResult as _CER
1616
from trpc_agent_sdk.utils._execute_cmd import async_execute_command as _aec
1717
from trpc_agent_sdk.utils._hash_key import user_key as _uk
18+
from trpc_agent_sdk.utils._json_repair import json_loads_repair as _jlr
19+
from trpc_agent_sdk.utils._json_repair import json_repair_string as _jrs
1820
from trpc_agent_sdk.utils._registry_factory import BaseRegistryFactory as _BRF
1921
from trpc_agent_sdk.utils._singleton import SingletonBase as _SB
2022
from trpc_agent_sdk.utils._singleton import SingletonMeta as _SM
@@ -29,6 +31,8 @@ def test_all_contains_expected_names(self):
2931
"CommandExecResult",
3032
"async_execute_command",
3133
"user_key",
34+
"json_loads_repair",
35+
"json_repair_string",
3236
"BaseRegistryFactory",
3337
"SingletonBase",
3438
"SingletonMeta",
@@ -41,6 +45,8 @@ def test_reexported_objects_match_originals(self):
4145
assert utils_pkg.CommandExecResult is _CER
4246
assert utils_pkg.async_execute_command is _aec
4347
assert utils_pkg.user_key is _uk
48+
assert utils_pkg.json_loads_repair is _jlr
49+
assert utils_pkg.json_repair_string is _jrs
4450
assert utils_pkg.BaseRegistryFactory is _BRF
4551
assert utils_pkg.SingletonBase is _SB
4652
assert utils_pkg.SingletonMeta is _SM

tests/utils/test_json_repair.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Unit tests for trpc_agent_sdk.utils._json_repair.
7+
8+
Covers:
9+
- json_loads_repair: parses valid JSON, repairs malformed JSON, decodes bytes,
10+
forwards kwargs, raises JSONDecodeError when unrecoverable.
11+
- json_repair_string: returns a valid JSON string for valid/malformed inputs,
12+
decodes bytes, forwards kwargs, raises JSONDecodeError when unrecoverable.
13+
"""
14+
15+
import json
16+
17+
import pytest
18+
19+
from trpc_agent_sdk.utils import json_loads_repair
20+
from trpc_agent_sdk.utils import json_repair_string
21+
22+
23+
class TestJsonLoadsRepair:
24+
"""Test suite for json_loads_repair."""
25+
26+
def test_valid_json_object(self):
27+
assert json_loads_repair('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"}
28+
29+
def test_valid_json_array(self):
30+
assert json_loads_repair("[1, 2, 3]") == [1, 2, 3]
31+
32+
def test_valid_json_scalar(self):
33+
assert json_loads_repair("true") is True
34+
assert json_loads_repair("null") is None
35+
assert json_loads_repair("123") == 123
36+
37+
def test_missing_comma_repaired(self):
38+
result = json_loads_repair('{"city": "Beijing" "unit": "celsius"}')
39+
assert result == {"city": "Beijing", "unit": "celsius"}
40+
41+
def test_trailing_comma_repaired(self):
42+
result = json_loads_repair('{"a": 1, "b": 2,}')
43+
assert result == {"a": 1, "b": 2}
44+
45+
def test_single_quoted_keys_repaired(self):
46+
result = json_loads_repair("{'a': 'b'}")
47+
assert result == {"a": "b"}
48+
49+
def test_unclosed_object_repaired(self):
50+
result = json_loads_repair('{"a": 1, "b": 2')
51+
assert result == {"a": 1, "b": 2}
52+
53+
def test_code_fence_wrapped_json_repaired(self):
54+
payload = '```json\n{"a": 1}\n```'
55+
result = json_loads_repair(payload)
56+
assert result == {"a": 1}
57+
58+
def test_bytes_input_decoded_and_parsed(self):
59+
result = json_loads_repair(b'{"a": 1}')
60+
assert result == {"a": 1}
61+
62+
def test_bytearray_input_decoded_and_repaired(self):
63+
result = json_loads_repair(bytearray(b'{"a": 1 "b": 2}'))
64+
assert result == {"a": 1, "b": 2}
65+
66+
def test_non_utf8_bytes_replaced_not_raised(self):
67+
# decode(errors="replace") should keep us off the exception path for
68+
# invalid utf-8 bytes; the repaired result is still a usable JSON value.
69+
result = json_loads_repair(b'\xff{"a": 1}')
70+
assert isinstance(result, (dict, list, str, int, float, bool)) or result is None
71+
72+
def test_unicode_preserved(self):
73+
assert json_loads_repair('{"name": "北京"}') == {"name": "北京"}
74+
75+
def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch):
76+
import trpc_agent_sdk.utils._json_repair as module
77+
78+
def _boom(*_args, **_kwargs):
79+
raise RuntimeError("unrecoverable")
80+
81+
monkeypatch.setattr(module.json_repair, "loads", _boom)
82+
83+
with pytest.raises(json.JSONDecodeError):
84+
json_loads_repair('{"oops"')
85+
86+
def test_kwargs_forwarded_to_json_repair(self, monkeypatch):
87+
import trpc_agent_sdk.utils._json_repair as module
88+
89+
captured = {}
90+
91+
def _fake_loads(value, **kwargs):
92+
captured["value"] = value
93+
captured["kwargs"] = kwargs
94+
return {"ok": True}
95+
96+
monkeypatch.setattr(module.json_repair, "loads", _fake_loads)
97+
98+
result = json_loads_repair('{"x": 1}', skip_json_loads=True, logging=False)
99+
100+
assert result == {"ok": True}
101+
assert captured["value"] == '{"x": 1}'
102+
assert captured["kwargs"] == {"skip_json_loads": True, "logging": False}
103+
104+
105+
class TestJsonRepairString:
106+
"""Test suite for json_repair_string."""
107+
108+
def test_valid_json_returns_canonical_string(self):
109+
repaired = json_repair_string('{"a": 1, "b": "x"}')
110+
assert json.loads(repaired) == {"a": 1, "b": "x"}
111+
112+
def test_missing_comma_repaired(self):
113+
repaired = json_repair_string('{"city": "Beijing" "unit": "celsius"}')
114+
assert json.loads(repaired) == {"city": "Beijing", "unit": "celsius"}
115+
116+
def test_single_quoted_keys_repaired(self):
117+
repaired = json_repair_string("{'a': 'b'}")
118+
assert json.loads(repaired) == {"a": "b"}
119+
120+
def test_unclosed_object_repaired(self):
121+
repaired = json_repair_string('{"a": 1, "b": 2')
122+
assert json.loads(repaired) == {"a": 1, "b": 2}
123+
124+
def test_array_repaired(self):
125+
repaired = json_repair_string("[1, 2 3,]")
126+
assert json.loads(repaired) == [1, 2, 3]
127+
128+
def test_bytes_input_decoded(self):
129+
repaired = json_repair_string(b'{"a": 1}')
130+
assert json.loads(repaired) == {"a": 1}
131+
132+
def test_bytearray_input_decoded_and_repaired(self):
133+
repaired = json_repair_string(bytearray(b'{"a": 1 "b": 2}'))
134+
assert json.loads(repaired) == {"a": 1, "b": 2}
135+
136+
def test_ensure_ascii_passthrough_default_preserves_unicode(self):
137+
repaired = json_repair_string('{"name": "北京"}', ensure_ascii=False)
138+
assert "北京" in repaired
139+
assert json.loads(repaired) == {"name": "北京"}
140+
141+
def test_ensure_ascii_true_escapes_unicode(self):
142+
repaired = json_repair_string('{"name": "北京"}', ensure_ascii=True)
143+
assert "北京" not in repaired
144+
assert json.loads(repaired) == {"name": "北京"}
145+
146+
def test_return_type_is_string(self):
147+
assert isinstance(json_repair_string('{"a": 1}'), str)
148+
149+
def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch):
150+
import trpc_agent_sdk.utils._json_repair as module
151+
152+
def _boom(*_args, **_kwargs):
153+
raise RuntimeError("unrecoverable")
154+
155+
monkeypatch.setattr(module.json_repair, "repair_json", _boom)
156+
157+
with pytest.raises(json.JSONDecodeError):
158+
json_repair_string('{"oops"')
159+
160+
def test_kwargs_forwarded_to_json_repair(self, monkeypatch):
161+
import trpc_agent_sdk.utils._json_repair as module
162+
163+
captured = {}
164+
165+
def _fake_repair_json(value, **kwargs):
166+
captured["value"] = value
167+
captured["kwargs"] = kwargs
168+
return '{"ok": true}'
169+
170+
monkeypatch.setattr(module.json_repair, "repair_json", _fake_repair_json)
171+
172+
repaired = json_repair_string('{"x": 1}', ensure_ascii=False, logging=False)
173+
174+
assert repaired == '{"ok": true}'
175+
assert captured["value"] == '{"x": 1}'
176+
assert captured["kwargs"] == {"ensure_ascii": False, "logging": False}

trpc_agent_sdk/agents/core/_skills_tool_result_processor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from trpc_agent_sdk.skills import loaded_scan_prefix
3030
from trpc_agent_sdk.skills import loaded_state_key
3131
from trpc_agent_sdk.skills import set_skill_config
32+
from trpc_agent_sdk.utils import json_loads_repair
3233

3334
_SKILLS_LOADED_CONTEXT_HEADER = "Loaded skill context:"
3435
_SESSION_SUMMARY_PREFIX = "Here is a brief summary of your previous interactions:"
@@ -180,7 +181,7 @@ def _skill_name_from_tool_response(self, function_response: Any, tool_calls: dic
180181
def _get_arg_value(self, args: Any, key: str) -> str:
181182
if isinstance(args, str):
182183
try:
183-
args = json.loads(args)
184+
args = json_loads_repair(args)
184185
except json.JSONDecodeError:
185186
return ""
186187
if isinstance(args, dict):
@@ -325,7 +326,7 @@ def _get_docs_selection(self, ctx: InvocationContext, skill_name: str, repo: Bas
325326
if not isinstance(value, str):
326327
return []
327328
try:
328-
arr = json.loads(value)
329+
arr = json_loads_repair(value)
329330
except json.JSONDecodeError:
330331
return []
331332
if not isinstance(arr, list):

trpc_agent_sdk/agents/core/_tools_processor.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from trpc_agent_sdk.types import Content
3939
from trpc_agent_sdk.types import FunctionCall
4040
from trpc_agent_sdk.types import Part
41+
from trpc_agent_sdk.utils import json_loads_repair
4142

4243
# Type aliases for tool definitions
4344
ToolUnion: TypeAlias = Union[BaseTool, BaseToolSet]
@@ -350,9 +351,29 @@ async def _execute_tool(self, tool_call: FunctionCall, tool: BaseTool, context:
350351
# Capture state before tool execution
351352
state_begin = dict(context.session.state)
352353

353-
# Parse arguments (FunctionCall uses 'args' field)
354+
# Parse arguments (FunctionCall uses 'args' field).
355+
# json_repair tolerates malformed JSON from models, but it can also
356+
# silently turn plain text (e.g. "Beijing") into "" or wrap loose
357+
# values into lists. Guard the result so downstream tools always
358+
# receive a dict, falling back to {} when repair cannot recover a
359+
# structured object.
354360
if isinstance(tool_call.args, str):
355-
arguments = json.loads(tool_call.args)
361+
try:
362+
arguments = json_loads_repair(tool_call.args)
363+
except Exception as ex: # pylint: disable=broad-except
364+
logger.warning(
365+
"Failed to repair string tool args for %s: %s",
366+
tool_call.name,
367+
ex,
368+
)
369+
arguments = {}
370+
if not isinstance(arguments, dict):
371+
logger.warning(
372+
"Discarding non-dict repaired tool args for %s: %r",
373+
tool_call.name,
374+
arguments,
375+
)
376+
arguments = {}
356377
else:
357378
arguments = tool_call.args or {}
358379

trpc_agent_sdk/models/_openai_model.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from trpc_agent_sdk.types import Part
3535
from trpc_agent_sdk.types import Schema
3636
from trpc_agent_sdk.types import Tool
37+
from trpc_agent_sdk.utils import json_loads_repair
3738

3839
from . import _constants as const
3940
from ._llm_model import LLMModel
@@ -801,9 +802,12 @@ def _create_complete_tool_calls(self, accumulated_tool_calls: list[dict]) -> Opt
801802

802803
if has_name and has_arguments:
803804
try:
804-
# Try to parse the arguments as JSON
805+
# Streaming tool-call accumulator: keep STRICT json.loads here.
806+
# Incomplete deltas (e.g. ``{"foo":``) must raise so the loop
807+
# can wait for the next chunk; using a repair-style parser
808+
# would prematurely emit half-formed tool calls.
805809
arguments_str: str = function_map[ToolKey.ARGUMENTS].strip()
806-
if arguments_str: # Only parse non-empty arguments
810+
if arguments_str:
807811
arguments = json.loads(arguments_str)
808812
else:
809813
arguments = {}
@@ -946,11 +950,22 @@ def _process_tool_calls_from_message(self, message: dict) -> Optional[List[ToolC
946950
thought_sig = tool_call.get(ToolKey.THOUGHT_SIGNATURE)
947951
if not thought_sig and isinstance(tool_call.get(ToolKey.PROVIDER_SPECIFIC_FIELDS), dict):
948952
thought_sig = tool_call[ToolKey.PROVIDER_SPECIFIC_FIELDS].get(ToolKey.THOUGHT_SIGNATURE)
953+
arguments = json_loads_repair(tool_call[ToolKey.FUNCTION][ToolKey.ARGUMENTS])
954+
if not isinstance(arguments, dict):
955+
# json_repair can turn unrecoverable text (e.g. "NOT_JSON")
956+
# into an empty string or list. Skip those so we never feed
957+
# ToolCall a non-dict ``arguments`` value.
958+
logger.warning(
959+
"Skipping tool call with non-dict repaired arguments: %s -> %r",
960+
tool_call,
961+
arguments,
962+
)
963+
continue
949964
tool_calls.append(
950965
ToolCall(
951966
id=tool_call[ToolKey.ID],
952967
name=tool_call[ToolKey.FUNCTION][ToolKey.NAME],
953-
arguments=json.loads(tool_call[ToolKey.FUNCTION][ToolKey.ARGUMENTS]),
968+
arguments=arguments,
954969
thought_signature=thought_sig,
955970
))
956971
except (KeyError, json.JSONDecodeError, TypeError) as ex:

trpc_agent_sdk/models/openai_adapter/_hunyuan.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def _parse_hunyuan_tool_args(self, params_content: str) -> dict[str, Any]:
7373
return {"value": parsed_value}
7474

7575
def _parse_arg_value(self, value: str) -> Any:
76+
# Keep STRICT json.loads here. Hunyuan's <arg_value> tags often contain
77+
# plain text (e.g. "Beijing", "2025-01-01"). json_repair would silently
78+
# coerce such plain text into "" and skip the fallback branch below,
79+
# corrupting tool arguments. Real JSON-shaped values still parse, and
80+
# the fallback preserves the original string for everything else.
7681
try:
7782
return json.loads(value)
7883
except json.JSONDecodeError:

trpc_agent_sdk/models/tool_prompt/_json.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from trpc_agent_sdk.types import FunctionCall
1717
from trpc_agent_sdk.types import Tool
18+
from trpc_agent_sdk.utils import json_loads_repair
1819

1920
from ._base import ToolPrompt
2021

@@ -194,7 +195,7 @@ def _parse_json_function_call(self, json_str: str) -> Optional[FunctionCall]:
194195
FunctionCall object or None if parsing fails
195196
"""
196197
try:
197-
data = json.loads(json_str.strip())
198+
data = json_loads_repair(json_str.strip())
198199

199200
if not isinstance(data, dict):
200201
return None
@@ -208,7 +209,7 @@ def _parse_json_function_call(self, json_str: str) -> Optional[FunctionCall]:
208209
# Try to parse arguments as JSON string
209210
if isinstance(arguments, str):
210211
try:
211-
arguments = json.loads(arguments)
212+
arguments = json_loads_repair(arguments)
212213
except json.JSONDecodeError:
213214
arguments = {}
214215
else:

trpc_agent_sdk/models/tool_prompt/_xml.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from trpc_agent_sdk.types import FunctionCall
1515
from trpc_agent_sdk.types import Tool
16+
from trpc_agent_sdk.utils import json_loads_repair
1617

1718
from ._base import ToolPrompt
1819

@@ -205,7 +206,7 @@ def _parse_single_invoke(self, invoke_content: str) -> Optional[FunctionCall]:
205206
param_value = param_value.strip()
206207
try:
207208
if param_value.startswith(("{", "[", '"')) or param_value in ("true", "false", "null"):
208-
parameters[param_name] = json.loads(param_value)
209+
parameters[param_name] = json_loads_repair(param_value)
209210
else:
210211
# Try to convert numbers
211212
if param_value.isdigit():

0 commit comments

Comments
 (0)