From f67a92f332fd2978673402f964cc12cea84a4927 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Tue, 19 May 2026 16:35:29 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E6=94=AF=E6=8C=81json=5Frepair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/memory.md | 6 +- docs/mkdocs/zh/memory.md | 6 +- requirements-test.txt | 4 +- tests/utils/test_init.py | 6 + tests/utils/test_json_repair.py | 176 ++++++++++++++++++ .../core/_skills_tool_result_processor.py | 5 +- .../agents/core/_tools_processor.py | 25 ++- trpc_agent_sdk/models/_openai_model.py | 21 ++- .../models/openai_adapter/_hunyuan.py | 5 + trpc_agent_sdk/models/tool_prompt/_json.py | 5 +- trpc_agent_sdk/models/tool_prompt/_xml.py | 3 +- trpc_agent_sdk/teams/_team_agent.py | 30 +++ trpc_agent_sdk/tools/_agent_tool.py | 4 +- trpc_agent_sdk/utils/__init__.py | 4 + trpc_agent_sdk/utils/_json_repair.py | 45 +++++ 15 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 tests/utils/test_json_repair.py create mode 100644 trpc_agent_sdk/utils/_json_repair.py diff --git a/docs/mkdocs/en/memory.md b/docs/mkdocs/en/memory.md index 0c641681..a826c226 100644 --- a/docs/mkdocs/en/memory.md +++ b/docs/mkdocs/en/memory.md @@ -821,7 +821,7 @@ Suitable for scenarios requiring full control over data and infrastructure. **Example Code:** ```python from mem0 import AsyncMemory -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # Configure custom components config = { @@ -851,7 +851,7 @@ Suitable for rapid deployment and production environment usage. **Example Code:** ```python from mem0 import AsyncMemoryClient -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # Create platform client client = AsyncMemoryClient( @@ -887,7 +887,7 @@ pip install -e ".[mem0]" ```python from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # Step 1: Instantiate tools, pass in Mem0 client (choose self-hosted or platform mode) search_memory_tool = SearchMemoryTool(client=your_mem0_client) diff --git a/docs/mkdocs/zh/memory.md b/docs/mkdocs/zh/memory.md index 13597d82..0432784a 100644 --- a/docs/mkdocs/zh/memory.md +++ b/docs/mkdocs/zh/memory.md @@ -1283,7 +1283,7 @@ tRPC-Agent 通过 **工具(Tools)** 的方式集成 Mem0,为 Agent 提供 ```python from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # 步骤 1:实例化工具,传入 Mem0 客户端(选择自托管或平台模式) search_memory_tool = SearchMemoryTool(client=your_mem0_client) @@ -1479,7 +1479,7 @@ tRPC-Agent 支持 Mem0 的两种部署模式:自托管模式和平台模式 **示例代码:** ```python from mem0 import AsyncMemory -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # 配置自定义组件 config = { @@ -1509,7 +1509,7 @@ save_memory_tool = SaveMemoryTool(client=memory) **示例代码:** ```python from mem0 import AsyncMemoryClient -from trpc_agent_sdk.server.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool # 创建平台客户端 client = AsyncMemoryClient( diff --git a/requirements-test.txt b/requirements-test.txt index f4749529..123052a5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -43,9 +43,9 @@ nanobot-ai>=0.1.4.post5 wecom-aibot-sdk-python>=0.1.5 # Test Core Dependencies -a2a-sdk>=0.2.0 +a2a-sdk<1.0.0,>=0.3.22 protobuf>=5.29.5 -claude-agent-sdk>=0.1.3 +claude-agent-sdk>=0.1.3,<0.1.64 cloudpickle>=2.0.0 ag-ui-protocol>=0.1.8 aiofiles diff --git a/tests/utils/test_init.py b/tests/utils/test_init.py index 82053922..5ff77a51 100644 --- a/tests/utils/test_init.py +++ b/tests/utils/test_init.py @@ -15,6 +15,8 @@ from trpc_agent_sdk.utils._execute_cmd import CommandExecResult as _CER from trpc_agent_sdk.utils._execute_cmd import async_execute_command as _aec from trpc_agent_sdk.utils._hash_key import user_key as _uk +from trpc_agent_sdk.utils._json_repair import json_loads_repair as _jlr +from trpc_agent_sdk.utils._json_repair import json_repair_string as _jrs from trpc_agent_sdk.utils._registry_factory import BaseRegistryFactory as _BRF from trpc_agent_sdk.utils._singleton import SingletonBase as _SB from trpc_agent_sdk.utils._singleton import SingletonMeta as _SM @@ -29,6 +31,8 @@ def test_all_contains_expected_names(self): "CommandExecResult", "async_execute_command", "user_key", + "json_loads_repair", + "json_repair_string", "BaseRegistryFactory", "SingletonBase", "SingletonMeta", @@ -41,6 +45,8 @@ def test_reexported_objects_match_originals(self): assert utils_pkg.CommandExecResult is _CER assert utils_pkg.async_execute_command is _aec assert utils_pkg.user_key is _uk + assert utils_pkg.json_loads_repair is _jlr + assert utils_pkg.json_repair_string is _jrs assert utils_pkg.BaseRegistryFactory is _BRF assert utils_pkg.SingletonBase is _SB assert utils_pkg.SingletonMeta is _SM diff --git a/tests/utils/test_json_repair.py b/tests/utils/test_json_repair.py new file mode 100644 index 00000000..a8a0bd96 --- /dev/null +++ b/tests/utils/test_json_repair.py @@ -0,0 +1,176 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.utils._json_repair. + +Covers: +- json_loads_repair: parses valid JSON, repairs malformed JSON, decodes bytes, + forwards kwargs, raises JSONDecodeError when unrecoverable. +- json_repair_string: returns a valid JSON string for valid/malformed inputs, + decodes bytes, forwards kwargs, raises JSONDecodeError when unrecoverable. +""" + +import json + +import pytest + +from trpc_agent_sdk.utils import json_loads_repair +from trpc_agent_sdk.utils import json_repair_string + + +class TestJsonLoadsRepair: + """Test suite for json_loads_repair.""" + + def test_valid_json_object(self): + assert json_loads_repair('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"} + + def test_valid_json_array(self): + assert json_loads_repair("[1, 2, 3]") == [1, 2, 3] + + def test_valid_json_scalar(self): + assert json_loads_repair("true") is True + assert json_loads_repair("null") is None + assert json_loads_repair("123") == 123 + + def test_missing_comma_repaired(self): + result = json_loads_repair('{"city": "Beijing" "unit": "celsius"}') + assert result == {"city": "Beijing", "unit": "celsius"} + + def test_trailing_comma_repaired(self): + result = json_loads_repair('{"a": 1, "b": 2,}') + assert result == {"a": 1, "b": 2} + + def test_single_quoted_keys_repaired(self): + result = json_loads_repair("{'a': 'b'}") + assert result == {"a": "b"} + + def test_unclosed_object_repaired(self): + result = json_loads_repair('{"a": 1, "b": 2') + assert result == {"a": 1, "b": 2} + + def test_code_fence_wrapped_json_repaired(self): + payload = '```json\n{"a": 1}\n```' + result = json_loads_repair(payload) + assert result == {"a": 1} + + def test_bytes_input_decoded_and_parsed(self): + result = json_loads_repair(b'{"a": 1}') + assert result == {"a": 1} + + def test_bytearray_input_decoded_and_repaired(self): + result = json_loads_repair(bytearray(b'{"a": 1 "b": 2}')) + assert result == {"a": 1, "b": 2} + + def test_non_utf8_bytes_replaced_not_raised(self): + # decode(errors="replace") should keep us off the exception path for + # invalid utf-8 bytes; the repaired result is still a usable JSON value. + result = json_loads_repair(b'\xff{"a": 1}') + assert isinstance(result, (dict, list, str, int, float, bool)) or result is None + + def test_unicode_preserved(self): + assert json_loads_repair('{"name": "北京"}') == {"name": "北京"} + + def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + def _boom(*_args, **_kwargs): + raise RuntimeError("unrecoverable") + + monkeypatch.setattr(module.json_repair, "loads", _boom) + + with pytest.raises(json.JSONDecodeError): + json_loads_repair('{"oops"') + + def test_kwargs_forwarded_to_json_repair(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + captured = {} + + def _fake_loads(value, **kwargs): + captured["value"] = value + captured["kwargs"] = kwargs + return {"ok": True} + + monkeypatch.setattr(module.json_repair, "loads", _fake_loads) + + result = json_loads_repair('{"x": 1}', skip_json_loads=True, logging=False) + + assert result == {"ok": True} + assert captured["value"] == '{"x": 1}' + assert captured["kwargs"] == {"skip_json_loads": True, "logging": False} + + +class TestJsonRepairString: + """Test suite for json_repair_string.""" + + def test_valid_json_returns_canonical_string(self): + repaired = json_repair_string('{"a": 1, "b": "x"}') + assert json.loads(repaired) == {"a": 1, "b": "x"} + + def test_missing_comma_repaired(self): + repaired = json_repair_string('{"city": "Beijing" "unit": "celsius"}') + assert json.loads(repaired) == {"city": "Beijing", "unit": "celsius"} + + def test_single_quoted_keys_repaired(self): + repaired = json_repair_string("{'a': 'b'}") + assert json.loads(repaired) == {"a": "b"} + + def test_unclosed_object_repaired(self): + repaired = json_repair_string('{"a": 1, "b": 2') + assert json.loads(repaired) == {"a": 1, "b": 2} + + def test_array_repaired(self): + repaired = json_repair_string("[1, 2 3,]") + assert json.loads(repaired) == [1, 2, 3] + + def test_bytes_input_decoded(self): + repaired = json_repair_string(b'{"a": 1}') + assert json.loads(repaired) == {"a": 1} + + def test_bytearray_input_decoded_and_repaired(self): + repaired = json_repair_string(bytearray(b'{"a": 1 "b": 2}')) + assert json.loads(repaired) == {"a": 1, "b": 2} + + def test_ensure_ascii_passthrough_default_preserves_unicode(self): + repaired = json_repair_string('{"name": "北京"}', ensure_ascii=False) + assert "北京" in repaired + assert json.loads(repaired) == {"name": "北京"} + + def test_ensure_ascii_true_escapes_unicode(self): + repaired = json_repair_string('{"name": "北京"}', ensure_ascii=True) + assert "北京" not in repaired + assert json.loads(repaired) == {"name": "北京"} + + def test_return_type_is_string(self): + assert isinstance(json_repair_string('{"a": 1}'), str) + + def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + def _boom(*_args, **_kwargs): + raise RuntimeError("unrecoverable") + + monkeypatch.setattr(module.json_repair, "repair_json", _boom) + + with pytest.raises(json.JSONDecodeError): + json_repair_string('{"oops"') + + def test_kwargs_forwarded_to_json_repair(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + captured = {} + + def _fake_repair_json(value, **kwargs): + captured["value"] = value + captured["kwargs"] = kwargs + return '{"ok": true}' + + monkeypatch.setattr(module.json_repair, "repair_json", _fake_repair_json) + + repaired = json_repair_string('{"x": 1}', ensure_ascii=False, logging=False) + + assert repaired == '{"ok": true}' + assert captured["value"] == '{"x": 1}' + assert captured["kwargs"] == {"ensure_ascii": False, "logging": False} diff --git a/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py b/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py index d46a5707..d530e345 100644 --- a/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py +++ b/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py @@ -29,6 +29,7 @@ from trpc_agent_sdk.skills import loaded_scan_prefix from trpc_agent_sdk.skills import loaded_state_key from trpc_agent_sdk.skills import set_skill_config +from trpc_agent_sdk.utils import json_loads_repair _SKILLS_LOADED_CONTEXT_HEADER = "Loaded skill context:" _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 def _get_arg_value(self, args: Any, key: str) -> str: if isinstance(args, str): try: - args = json.loads(args) + args = json_loads_repair(args) except json.JSONDecodeError: return "" if isinstance(args, dict): @@ -325,7 +326,7 @@ def _get_docs_selection(self, ctx: InvocationContext, skill_name: str, repo: Bas if not isinstance(value, str): return [] try: - arr = json.loads(value) + arr = json_loads_repair(value) except json.JSONDecodeError: return [] if not isinstance(arr, list): diff --git a/trpc_agent_sdk/agents/core/_tools_processor.py b/trpc_agent_sdk/agents/core/_tools_processor.py index 348df782..e6d1a2e3 100644 --- a/trpc_agent_sdk/agents/core/_tools_processor.py +++ b/trpc_agent_sdk/agents/core/_tools_processor.py @@ -38,6 +38,7 @@ from trpc_agent_sdk.types import Content from trpc_agent_sdk.types import FunctionCall from trpc_agent_sdk.types import Part +from trpc_agent_sdk.utils import json_loads_repair # Type aliases for tool definitions ToolUnion: TypeAlias = Union[BaseTool, BaseToolSet] @@ -350,9 +351,29 @@ async def _execute_tool(self, tool_call: FunctionCall, tool: BaseTool, context: # Capture state before tool execution state_begin = dict(context.session.state) - # Parse arguments (FunctionCall uses 'args' field) + # Parse arguments (FunctionCall uses 'args' field). + # json_repair tolerates malformed JSON from models, but it can also + # silently turn plain text (e.g. "Beijing") into "" or wrap loose + # values into lists. Guard the result so downstream tools always + # receive a dict, falling back to {} when repair cannot recover a + # structured object. if isinstance(tool_call.args, str): - arguments = json.loads(tool_call.args) + try: + arguments = json_loads_repair(tool_call.args) + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to repair string tool args for %s: %s", + tool_call.name, + ex, + ) + arguments = {} + if not isinstance(arguments, dict): + logger.warning( + "Discarding non-dict repaired tool args for %s: %r", + tool_call.name, + arguments, + ) + arguments = {} else: arguments = tool_call.args or {} diff --git a/trpc_agent_sdk/models/_openai_model.py b/trpc_agent_sdk/models/_openai_model.py index 5fbc0592..2be72335 100644 --- a/trpc_agent_sdk/models/_openai_model.py +++ b/trpc_agent_sdk/models/_openai_model.py @@ -34,6 +34,7 @@ from trpc_agent_sdk.types import Part from trpc_agent_sdk.types import Schema from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair from . import _constants as const from ._llm_model import LLMModel @@ -801,9 +802,12 @@ def _create_complete_tool_calls(self, accumulated_tool_calls: list[dict]) -> Opt if has_name and has_arguments: try: - # Try to parse the arguments as JSON + # Streaming tool-call accumulator: keep STRICT json.loads here. + # Incomplete deltas (e.g. ``{"foo":``) must raise so the loop + # can wait for the next chunk; using a repair-style parser + # would prematurely emit half-formed tool calls. arguments_str: str = function_map[ToolKey.ARGUMENTS].strip() - if arguments_str: # Only parse non-empty arguments + if arguments_str: arguments = json.loads(arguments_str) else: arguments = {} @@ -946,11 +950,22 @@ def _process_tool_calls_from_message(self, message: dict) -> Optional[List[ToolC thought_sig = tool_call.get(ToolKey.THOUGHT_SIGNATURE) if not thought_sig and isinstance(tool_call.get(ToolKey.PROVIDER_SPECIFIC_FIELDS), dict): thought_sig = tool_call[ToolKey.PROVIDER_SPECIFIC_FIELDS].get(ToolKey.THOUGHT_SIGNATURE) + arguments = json_loads_repair(tool_call[ToolKey.FUNCTION][ToolKey.ARGUMENTS]) + if not isinstance(arguments, dict): + # json_repair can turn unrecoverable text (e.g. "NOT_JSON") + # into an empty string or list. Skip those so we never feed + # ToolCall a non-dict ``arguments`` value. + logger.warning( + "Skipping tool call with non-dict repaired arguments: %s -> %r", + tool_call, + arguments, + ) + continue tool_calls.append( ToolCall( id=tool_call[ToolKey.ID], name=tool_call[ToolKey.FUNCTION][ToolKey.NAME], - arguments=json.loads(tool_call[ToolKey.FUNCTION][ToolKey.ARGUMENTS]), + arguments=arguments, thought_signature=thought_sig, )) except (KeyError, json.JSONDecodeError, TypeError) as ex: diff --git a/trpc_agent_sdk/models/openai_adapter/_hunyuan.py b/trpc_agent_sdk/models/openai_adapter/_hunyuan.py index d7e1348e..4451a675 100644 --- a/trpc_agent_sdk/models/openai_adapter/_hunyuan.py +++ b/trpc_agent_sdk/models/openai_adapter/_hunyuan.py @@ -73,6 +73,11 @@ def _parse_hunyuan_tool_args(self, params_content: str) -> dict[str, Any]: return {"value": parsed_value} def _parse_arg_value(self, value: str) -> Any: + # Keep STRICT json.loads here. Hunyuan's tags often contain + # plain text (e.g. "Beijing", "2025-01-01"). json_repair would silently + # coerce such plain text into "" and skip the fallback branch below, + # corrupting tool arguments. Real JSON-shaped values still parse, and + # the fallback preserves the original string for everything else. try: return json.loads(value) except json.JSONDecodeError: diff --git a/trpc_agent_sdk/models/tool_prompt/_json.py b/trpc_agent_sdk/models/tool_prompt/_json.py index 2fb78e90..8462bdd9 100644 --- a/trpc_agent_sdk/models/tool_prompt/_json.py +++ b/trpc_agent_sdk/models/tool_prompt/_json.py @@ -15,6 +15,7 @@ from trpc_agent_sdk.types import FunctionCall from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair from ._base import ToolPrompt @@ -194,7 +195,7 @@ def _parse_json_function_call(self, json_str: str) -> Optional[FunctionCall]: FunctionCall object or None if parsing fails """ try: - data = json.loads(json_str.strip()) + data = json_loads_repair(json_str.strip()) if not isinstance(data, dict): return None @@ -208,7 +209,7 @@ def _parse_json_function_call(self, json_str: str) -> Optional[FunctionCall]: # Try to parse arguments as JSON string if isinstance(arguments, str): try: - arguments = json.loads(arguments) + arguments = json_loads_repair(arguments) except json.JSONDecodeError: arguments = {} else: diff --git a/trpc_agent_sdk/models/tool_prompt/_xml.py b/trpc_agent_sdk/models/tool_prompt/_xml.py index 16f3f732..06a66d72 100644 --- a/trpc_agent_sdk/models/tool_prompt/_xml.py +++ b/trpc_agent_sdk/models/tool_prompt/_xml.py @@ -13,6 +13,7 @@ from trpc_agent_sdk.types import FunctionCall from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair from ._base import ToolPrompt @@ -205,7 +206,7 @@ def _parse_single_invoke(self, invoke_content: str) -> Optional[FunctionCall]: param_value = param_value.strip() try: if param_value.startswith(("{", "[", '"')) or param_value in ("true", "false", "null"): - parameters[param_name] = json.loads(param_value) + parameters[param_name] = json_loads_repair(param_value) else: # Try to convert numbers if param_value.isdigit(): diff --git a/trpc_agent_sdk/teams/_team_agent.py b/trpc_agent_sdk/teams/_team_agent.py index 4b612ccf..c8733361 100644 --- a/trpc_agent_sdk/teams/_team_agent.py +++ b/trpc_agent_sdk/teams/_team_agent.py @@ -538,6 +538,22 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, # Check if custom tools were executed (non-delegation) custom_tool_executed = self._has_non_delegation_tool_calls(last_event) if custom_tool_executed: + # Honor skip_summarization on tool responses. The leader runs + # with disable_react_tool=True, so LlmAgent never gets to + # check skip_summarization itself. TeamAgent must do that + # check here, otherwise tools that declare "my output IS the + # final answer" (e.g. AgentTool(skip_summarization=True), + # StreamingProgressTool(skip_summarization=True)) would + # still be re-summarized by the leader. + if self._any_skip_summarization(all_leader_events): + logger.debug("TeamAgent: Tool returned skip_summarization=True, " + "exiting without leader follow-up") + if leader_text_response.strip(): + team_run_context.add_leader_message('model', leader_text_response) + if not is_member_mode: + yield self._create_state_update_event(ctx, team_run_context) + return + # Custom tool was executed, continue loop to let leader process result logger.debug("TeamAgent: Custom tool executed, continuing loop for leader to process result") @@ -874,6 +890,20 @@ def _find_member_by_name(self, name: str) -> Optional[BaseAgent]: return member return None + @staticmethod + def _any_skip_summarization(events: List[Event]) -> bool: + """Return True if any event carries actions.skip_summarization=True. + + Used to mirror :class:`LlmAgent`'s skip_summarization handling at the + TeamAgent layer. The leader runs with ``disable_react_tool=True``, so + LlmAgent itself never reaches its skip_summarization branch; the team + loop has to inspect the leader's emitted tool events directly. + """ + for event in events: + if event and event.actions and event.actions.skip_summarization: + return True + return False + def _has_non_delegation_tool_calls(self, event: Event) -> bool: """Check if event contains non-delegation and non-long-running tool calls. diff --git a/trpc_agent_sdk/tools/_agent_tool.py b/trpc_agent_sdk/tools/_agent_tool.py index 80fd5d1d..c45f09fa 100644 --- a/trpc_agent_sdk/tools/_agent_tool.py +++ b/trpc_agent_sdk/tools/_agent_tool.py @@ -61,6 +61,7 @@ from trpc_agent_sdk.types import Part from trpc_agent_sdk.types import Schema from trpc_agent_sdk.types import Type +from trpc_agent_sdk.utils import json_repair_string from ._base_tool import BaseTool from .utils import build_function_declaration @@ -203,7 +204,8 @@ async def _run_async_impl( return '' if isinstance(self.agent, LlmAgent) and self.agent.output_schema: merged_text = '\n'.join([p.text for p in last_event.content.parts if p.text]) - tool_result = self.agent.output_schema.model_validate_json(merged_text).model_dump(exclude_none=True) + repaired = json_repair_string(merged_text) + tool_result = self.agent.output_schema.model_validate_json(repaired).model_dump(exclude_none=True) else: tool_result = '\n'.join([p.text for p in last_event.content.parts if p.text]) return tool_result diff --git a/trpc_agent_sdk/utils/__init__.py b/trpc_agent_sdk/utils/__init__.py index 2234c0e2..aecc16ce 100644 --- a/trpc_agent_sdk/utils/__init__.py +++ b/trpc_agent_sdk/utils/__init__.py @@ -13,6 +13,8 @@ from ._execute_cmd import CommandExecResult from ._execute_cmd import async_execute_command from ._hash_key import user_key +from ._json_repair import json_loads_repair +from ._json_repair import json_repair_string from ._registry_factory import BaseRegistryFactory from ._singleton import SingletonBase from ._singleton import SingletonMeta @@ -23,6 +25,8 @@ "CommandExecResult", "async_execute_command", "user_key", + "json_loads_repair", + "json_repair_string", "BaseRegistryFactory", "SingletonBase", "SingletonMeta", diff --git a/trpc_agent_sdk/utils/_json_repair.py b/trpc_agent_sdk/utils/_json_repair.py new file mode 100644 index 00000000..68c4cbae --- /dev/null +++ b/trpc_agent_sdk/utils/_json_repair.py @@ -0,0 +1,45 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""JSON parsing helpers with json-repair fallback.""" + +from __future__ import annotations + +import json +from typing import Any + +import json_repair + + +def json_repair_string(value: str | bytes | bytearray, **kwargs: Any) -> str: + """Return a valid JSON string, repairing malformed JSON when needed. + + Use this when the downstream API expects JSON text, such as + ``BaseModel.model_validate_json`` or when a repaired JSON string needs to + be logged/transmitted. + """ + if isinstance(value, (bytes, bytearray)): + value = value.decode("utf-8", errors="replace") + + try: + return json_repair.repair_json(value, **kwargs) + except Exception as exc: # pylint: disable=broad-except + raise json.JSONDecodeError(str(exc), value, 0) from exc + + +def json_loads_repair(value: str | bytes | bytearray, **kwargs: Any) -> Any: + """Load JSON, falling back to ``json_repair`` for malformed LLM JSON. + + This should be used only on text that may come from model/provider output + or tool-call arguments. Persisted data/config paths should keep strict + ``json.loads`` so storage corruption remains visible. + """ + if isinstance(value, (bytes, bytearray)): + value = value.decode("utf-8", errors="replace") + + try: + return json_repair.loads(value, **kwargs) + except Exception as exc: # pylint: disable=broad-except + raise json.JSONDecodeError(str(exc), value, 0) from exc