Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/mkdocs/en/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions docs/mkdocs/zh/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/utils/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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
Expand Down
176 changes: 176 additions & 0 deletions tests/utils/test_json_repair.py
Original file line number Diff line number Diff line change
@@ -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}
5 changes: 3 additions & 2 deletions trpc_agent_sdk/agents/core/_skills_tool_result_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
25 changes: 23 additions & 2 deletions trpc_agent_sdk/agents/core/_tools_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 {}

Expand Down
21 changes: 18 additions & 3 deletions trpc_agent_sdk/models/_openai_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions trpc_agent_sdk/models/openai_adapter/_hunyuan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <arg_value> 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:
Expand Down
Loading
Loading