From 60e57028ca805e24c2c5a8338d0323143507a4ea Mon Sep 17 00:00:00 2001 From: Aalhad Date: Fri, 31 Jul 2026 10:50:18 +0530 Subject: [PATCH 1/2] fix(kosong): recursively unwrap double-encoded JSON in tool-call arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some providers (notably the Moonshot API) return function.arguments with nested array/object values as JSON strings. A single json.loads leaves these inner values as strings, which then fail Pydantic validation with errors like "Input should be a valid list". Adds kosong.utils.json_args.decode_tool_arguments — a shared helper that: - Parses the outer JSON payload (re-raises JSONDecodeError for malformed outer input so callers can surface ToolParseError, preserving today's contract). - Recursively walks dicts/lists and unwraps any string that itself decodes to a dict or list. - Leaves scalar strings ("42", "true", "hello") untouched so genuine string fields are never corrupted. - Uses a fast-path prefix check (v[0] in "[", "{") to avoid unnecessary json.loads calls on typical non-JSON string values. Replaces the single json.loads call in both: - kosong.tooling.simple.SimpleToolset.handle - kimi_cli.soul.toolset.KimiToolset.handle Tests: - 23 unit tests covering E1–E10 edge cases, termination (deep nesting / bounded structural generators), malformed outer input, regression (SetTodoList / StrReplaceFile / ExitPlanMode shapes), mixed genuine+encoded fields, unicode. - 3 integration tests in tests/core/test_toolset.py exercising the fix end-to-end through KimiToolset. Fixes #2406; improves upon #2513 (adds fast-path and broader edge-case coverage). --- packages/kosong/src/kosong/tooling/simple.py | 3 +- packages/kosong/src/kosong/utils/json_args.py | 69 ++++ packages/kosong/tests/utils/__init__.py | 0 packages/kosong/tests/utils/test_json_args.py | 311 ++++++++++++++++++ src/kimi_cli/soul/toolset.py | 3 +- tests/core/test_toolset.py | 84 +++++ 6 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 packages/kosong/src/kosong/utils/json_args.py create mode 100644 packages/kosong/tests/utils/__init__.py create mode 100644 packages/kosong/tests/utils/test_json_args.py diff --git a/packages/kosong/src/kosong/tooling/simple.py b/packages/kosong/src/kosong/tooling/simple.py index 8aee6eb6f9..65b7323603 100644 --- a/packages/kosong/src/kosong/tooling/simple.py +++ b/packages/kosong/src/kosong/tooling/simple.py @@ -19,6 +19,7 @@ ToolParseError, ToolRuntimeError, ) +from kosong.utils.json_args import decode_tool_arguments from kosong.utils.typing import JsonType if TYPE_CHECKING: @@ -119,7 +120,7 @@ def handle(self, tool_call: ToolCall) -> HandleResult: tool = self._tool_dict[tool_call.function.name] try: - arguments: JsonType = json.loads(tool_call.function.arguments or "{}", strict=False) + arguments: JsonType = decode_tool_arguments(tool_call.function.arguments) except json.JSONDecodeError as e: return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e))) diff --git a/packages/kosong/src/kosong/utils/json_args.py b/packages/kosong/src/kosong/utils/json_args.py new file mode 100644 index 0000000000..d71702757d --- /dev/null +++ b/packages/kosong/src/kosong/utils/json_args.py @@ -0,0 +1,69 @@ +"""Decode tool-call arguments, handling double-encoded JSON strings. + +Some LLM providers (notably the Moonshot API) return ``function.arguments`` +where nested array/object values are themselves JSON strings. A single +``json.loads`` leaves these inner values as strings, which then fail Pydantic +validation (e.g. ``Input should be a valid list``). + +:func:`decode_tool_arguments` parses the outer payload and recursively unwraps +any string that itself decodes to a ``dict`` or ``list``. Scalar strings +(``"42"``, ``"true"``, ``"hello"``) are left untouched so genuine string fields +are never corrupted. + +Fast-path: strings are only parsed if their first non-whitespace character is +``[`` or ``{``, avoiding unnecessary ``json.loads`` calls on typical non-JSON +string values. +""" + +from __future__ import annotations + +import json +from typing import cast + +from kosong.utils.typing import JsonType + +__all__ = ["decode_tool_arguments"] + + +_MAX_UNWRAP_DEPTH = 100 # guard against adversarial deeply-nested payloads + + +def _unwrap(value: object, depth: int = 0) -> object: + if isinstance(value, dict): + if depth >= _MAX_UNWRAP_DEPTH: + return value + return {k: _unwrap(v, depth + 1) for k, v in cast("dict[str, object]", value).items()} + if isinstance(value, list): + if depth >= _MAX_UNWRAP_DEPTH: + return value + return [_unwrap(x, depth + 1) for x in cast("list[object]", value)] + if isinstance(value, str): + # Fast-path: skip strings that cannot be JSON objects/arrays. + # The lstrip handles leading whitespace (rare but valid). + stripped = value.lstrip() + if not stripped or stripped[0] not in ("[", "{"): + return value + if depth >= _MAX_UNWRAP_DEPTH: + return value + try: + parsed = json.loads(value, strict=False) + except (json.JSONDecodeError, ValueError): + return value + if isinstance(parsed, (dict, list)): + return _unwrap(parsed, depth + 1) + return value + return value + + +def decode_tool_arguments(raw: str | None) -> JsonType: + """Parse tool-call arguments, recursively unwrapping double-encoded values. + + ``None``/empty strings coerce to ``{}`` (preserving the historical guard). + The outer payload is parsed first; ``json.JSONDecodeError`` is re-raised so + callers can surface ``ToolParseError``. After parsing, inner strings that + decode to dicts or lists are recursively unwrapped. + """ + if raw is None or raw == "": + raw = "{}" + parsed = json.loads(raw, strict=False) + return cast(JsonType, _unwrap(parsed)) diff --git a/packages/kosong/tests/utils/__init__.py b/packages/kosong/tests/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/kosong/tests/utils/test_json_args.py b/packages/kosong/tests/utils/test_json_args.py new file mode 100644 index 0000000000..5ec4029b3b --- /dev/null +++ b/packages/kosong/tests/utils/test_json_args.py @@ -0,0 +1,311 @@ +"""Tests for :func:`kosong.utils.json_args.decode_tool_arguments`. + +Covers double-encoding unwrap, fast-path correctness, edge cases, +termination, and regression prevention (genuine string fields must +never be corrupted). +""" + +from __future__ import annotations + +import json + +import pytest + +from kosong.utils.json_args import decode_tool_arguments + + +# ───────────────────────────── E1: top-level double-encoding ───── + + +def test_e1_top_level_double_encoding_unwraps_to_list(): + inner = json.dumps([{"title": "x", "status": "in_progress"}]) + raw = json.dumps({"todos": inner}) + assert decode_tool_arguments(raw) == {"todos": [{"title": "x", "status": "in_progress"}]} + + +def test_e1_top_level_dict_double_encoding_unwraps(): + inner = json.dumps({"nested": {"deep": 1}}) + raw = json.dumps({"options": inner}) + assert decode_tool_arguments(raw) == {"options": {"nested": {"deep": 1}}} + + +# ───────────────────────────── E2: nested double-encoding ────── + + +def test_e2_nested_double_encoding_unwraps_inner_dict(): + deepest = json.dumps({"k": 1}) + middle = json.dumps({"meta": deepest}) + raw = json.dumps({"todos": middle}) + assert decode_tool_arguments(raw) == {"todos": {"meta": {"k": 1}}} + + +def test_e2_triple_nested_list_in_dict(): + inner = json.dumps([{"a": json.dumps([1, 2])}]) + raw = json.dumps({"data": inner}) + assert decode_tool_arguments(raw) == {"data": [{"a": [1, 2]}]} + + +# ───────────────────────────── E3: non-JSON strings preserved ── + + +def test_e3_non_json_string_preserved(): + raw = json.dumps({"note": "{oops}"}) + assert decode_tool_arguments(raw) == {"note": "{oops}"} + + +def test_e3_trailing_brace_only_preserved(): + raw = json.dumps({"msg": "hello}"}) + assert decode_tool_arguments(raw) == {"msg": "hello}"} + + +def test_e3_leading_bracket_only_preserved(): + raw = json.dumps({"msg": "[hello"}) + assert decode_tool_arguments(raw) == {"msg": "[hello"} + + +def test_e3_empty_string_preserved(): + raw = json.dumps({"empty": ""}) + assert decode_tool_arguments(raw) == {"empty": ""} + + +# ───────────────────────────── E4: scalar JSON strings preserved ─ + + +def test_e4_scalar_json_string_preserved(): + raw = json.dumps({"count_str": "42"}) + assert decode_tool_arguments(raw) == {"count_str": "42"} + + +def test_e4_float_string_preserved(): + raw = json.dumps({"price_str": "3.14"}) + assert decode_tool_arguments(raw) == {"price_str": "3.14"} + + +def test_e4_bool_string_preserved(): + raw = json.dumps({"flag_str": "true"}) + assert decode_tool_arguments(raw) == {"flag_str": "true"} + + +def test_e4_null_string_preserved(): + raw = json.dumps({"none_str": "null"}) + assert decode_tool_arguments(raw) == {"none_str": "null"} + + +# ───────────────────────────── E5: None / empty input ──────────── + + +def test_e5_none_returns_empty_dict(): + assert decode_tool_arguments(None) == {} + + +def test_e5_empty_string_returns_empty_dict(): + assert decode_tool_arguments("") == {} + + +# ───────────────────────────── E6: well-formed passthrough ─────── + + +def test_e6_well_formed_args_unchanged(): + raw = json.dumps({"todos": [{"title": "x", "status": "todo"}], "count": 3}) + expected = {"todos": [{"title": "x", "status": "todo"}], "count": 3} + assert decode_tool_arguments(raw) == expected + + +def test_e6_list_outer_value_unchanged(): + raw = json.dumps([{"a": 1}, {"b": 2}]) + assert decode_tool_arguments(raw) == [{"a": 1}, {"b": 2}] + + +# ───────────────────────────── E7: list-typed outer with inner ── + + +def test_e7_list_outer_value_inner_string_decoded(): + raw = json.dumps([{"x": json.dumps(["y"])}]) + assert decode_tool_arguments(raw) == [{"x": ["y"]}] + + +# ───────────────────────────── E8: mid-bracket / whitespace ───── + + +def test_e8_value_with_mid_bracket_preserved(): + raw = json.dumps({"a": "hello [world"}) + assert decode_tool_arguments(raw) == {"a": "hello [world"} + + +def test_e8_leading_whitespace_non_json_preserved(): + raw = json.dumps({"a": " not json"}) + assert decode_tool_arguments(raw) == {"a": " not json"} + + +def test_e8_leading_whitespace_json_array_still_decoded(): + inner = json.dumps([1, 2]) + raw = json.dumps({"nums": " " + inner}) + assert decode_tool_arguments(raw) == {"nums": [1, 2]} + + +# ───────────────────────────── E9: malformed outer ────────────── + + +def test_e9_outer_malformed_raises_jsondecodeerror(): + with pytest.raises(json.JSONDecodeError): + decode_tool_arguments("{not json") + + +def test_e9_outer_malformed_array_raises(): + with pytest.raises(json.JSONDecodeError): + decode_tool_arguments("[1, 2,") + + +# ───────────────────────────── E10: fast-path sanity ────────────── + + +def test_e10_fast_path_does_not_corrupt_plain_text(): + """Strings starting with characters other than [ or { must never be parsed.""" + raw = json.dumps({ + "alpha": "alpha", + "bravo": "123", + "charlie": "true", + "delta": "null", + "echo": " spaces", + "foxtrot": "\t\ttabs", + }) + assert decode_tool_arguments(raw) == { + "alpha": "alpha", + "bravo": "123", + "charlie": "true", + "delta": "null", + "echo": " spaces", + "foxtrot": "\t\ttabs", + } + + +# ───────────────────────────── Termination: deep nesting ───────── + + +def test_termination_structural_nested_array(): + """Build a deeply nested array by string concatenation (linear growth). + + ``s = \"[1]\"; for _ in range(20): s = \"[\" + s + \"]\"`` produces + ``[[[...[1]...]]]`` (~43 chars at 20 levels). Exercises ``_unwrap`` + recursively without exponential string growth. + """ + wraps = 20 + s = "[1]" + for _ in range(wraps): + s = "[" + s + "]" + expected: object = 1 + for _ in range(wraps + 1): + expected = [expected] + assert decode_tool_arguments(s) == expected + + +def test_termination_nested_double_encoding_structural(): + """Build a nested double-encoded string structurally (bounded growth). + + Each level wraps the previous JSON text as a JSON string value inside a + new dict, exercising the string→dict→string→dict recursion path of + ``_unwrap``. The innermost value is a scalar JSON string (``"1"``); the + dict-or-list gate refuses to promote scalar parses, so that innermost + value is preserved unchanged, and each surrounding layer unwraps to a + dict. After 10 levels ``_unwrap`` must have bottomed out to nested dicts + with that innermost string intact. + """ + levels = 10 + s = '"1"' # innermost scalar JSON (stays a string: gate on dict-or-list) + for _ in range(levels): + s = json.dumps({"k": s}) + result = decode_tool_arguments(s) + expected: object = '"1"' + for _ in range(levels): + expected = {"k": expected} + assert result == expected + + +def test_termination_deep_dict_chain(): + """Deep dict nesting without double-encoding — verifies pure recursion.""" + depth = 100 + d: dict[str, object] = {"v": "leaf"} + for _ in range(depth - 1): + d = {"next": d} + raw = json.dumps(d) + assert decode_tool_arguments(raw) == d + + +# ───────────────────────────── Regression: real-world shapes ───── + + +def test_regression_set_todo_list_shape(): + """Reproduces the exact reported SetTodoList failure.""" + todos = [{"title": "Buy milk", "status": "in_progress"}] + raw = json.dumps({"todos": json.dumps(todos)}) + assert decode_tool_arguments(raw) == {"todos": todos} + + +def test_regression_str_replace_file_edit_shape(): + """Reproduces StrReplaceFile.edit double-encoding.""" + edit = {"old_string": "foo", "new_string": "bar"} + raw = json.dumps({"edit": json.dumps(edit)}) + assert decode_tool_arguments(raw) == {"edit": edit} + + +def test_regression_exit_plan_mode_options_shape(): + """Reproduces ExitPlanMode.options double-encoding.""" + options = [{"name": "opt1", "value": "val1"}] + raw = json.dumps({"options": json.dumps(options)}) + assert decode_tool_arguments(raw) == {"options": options} + + +# ───────────────────────────── Mixed genuine + encoded ──────────── + + +def test_mixed_genuine_and_encoded_fields(): + """A dict where some fields are genuine strings and others are double-encoded.""" + raw = json.dumps({ + "title": "genuine string", # preserved + "tags": json.dumps(["a", "b"]), # unwrapped to list + "count_str": "42", # preserved (scalar) + "config": json.dumps({"x": 1}), # unwrapped to dict + }) + assert decode_tool_arguments(raw) == { + "title": "genuine string", + "tags": ["a", "b"], + "count_str": "42", + "config": {"x": 1}, + } + + +# ───────────────────────────── Unicode in encoded strings ─────── + + +def test_unicode_in_double_encoded_string(): + inner = json.dumps({"message": "你好世界 🌍"}) + raw = json.dumps({"payload": inner}) + assert decode_tool_arguments(raw) == {"payload": {"message": "你好世界 🌍"}} + + +# ───────────────────────────── Safety: max depth guard ──────────── + + +def test_max_depth_guard_terminates(): + """Adversarial payloads exceeding _MAX_UNWRAP_DEPTH are left unchanged + rather than causing RecursionError. + + Uses a bounded structural generator (linear growth) rather than + ``json.dumps`` in a loop which compounds ~4x per iteration. + """ + # Build a chain deeper than _MAX_UNWRAP_DEPTH (100) using string + # concatenation: each level adds ~8 chars, so 110 levels is ~1 KB. + s = '{"v":1}' + for _ in range(110): + s = '{"next":' + s + '}' + result = decode_tool_arguments(s) + assert isinstance(result, dict) + assert "next" in result + # At depth >= _MAX_UNWRAP_DEPTH the dict is returned unchanged rather + # than recursing further. Dive 99 times → depth 99 < 100, so the 100th + # level is still a dict (not a string) because json.loads ran at depth 99 + # and produced a dict which _unwrap then sees at depth 100 and preserves. + inner = result + for _ in range(99): + inner = inner["next"] + assert isinstance(inner, dict) diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index 5d66344aaa..ed94f84e20 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -27,6 +27,7 @@ ToolRuntimeError, ) from kosong.tooling.mcp import convert_mcp_content +from kosong.utils.json_args import decode_tool_arguments from kosong.utils.typing import JsonType from kimi_cli import logger @@ -352,7 +353,7 @@ def handle(self, tool_call: ToolCall) -> HandleResult: ) try: - arguments: JsonType = json.loads(tool_call.function.arguments or "{}", strict=False) + arguments: JsonType = decode_tool_arguments(tool_call.function.arguments) except json.JSONDecodeError as e: logger.warning( "Tool call JSON parse error: {tool_name} (call_id={call_id}): {error}", diff --git a/tests/core/test_toolset.py b/tests/core/test_toolset.py index e41841c228..722c46a67a 100644 --- a/tests/core/test_toolset.py +++ b/tests/core/test_toolset.py @@ -174,6 +174,90 @@ async def test_nonexistent_tool_returns_not_found(): assert isinstance(result.return_value, KosongToolNotFoundError) +# --- double-encoded tool-call arguments (#2406) --- + + +class _DummyTodo(BaseModel): + title: str + status: str + + +class _DummyTodoParams(BaseModel): + todos: list[_DummyTodo] | None = None + + +class _DummyTodoTool(CallableTool2[_DummyTodoParams]): + name: str = "DummyTodo" + description: str = "Dummy todo tool mirroring SetTodoList" + params: type[_DummyTodoParams] = _DummyTodoParams + + async def __call__(self, params: _DummyTodoParams) -> ToolReturnValue: + titles = [t.title for t in (params.todos or [])] + return ToolOk(output=",".join(titles)) + + +async def test_handle_unwraps_double_encoded_arguments(): + """KimiToolset.handle must recursively unwrap double-encoded arguments. + + Reproduces the exact reported shape: a ``list[Todo]`` param whose value + the provider returns as a JSON STRING (double-encoded). Before the fix + this surfaced as ``ToolValidateError`` ("Input should be a valid list"); + after the fix the tool executes and returns ``ToolOk``. + """ + ts = KimiToolset() + ts.add(_DummyTodoTool()) + ts.begin_step([]) + + inner = json.dumps([{"title": "x", "status": "in_progress"}]) + arguments = json.dumps({"todos": inner}) + tool_call = ToolCall( + id="tc-double", + function=ToolCall.FunctionBody(name="DummyTodo", arguments=arguments), + ) + result = ts.handle(tool_call) + assert isinstance(result, asyncio.Task) + tr = await result + assert isinstance(tr.return_value, ToolOk) + assert tr.return_value.output == "x" + + +async def test_handle_well_formed_args_unchanged(): + """A normal single-encoded ToolCall must still execute correctly (regression).""" + ts = KimiToolset() + ts.add(_DummyTodoTool()) + ts.begin_step([]) + + arguments = json.dumps({"todos": [{"title": "a", "status": "pending"}]}) + tool_call = ToolCall( + id="tc-well-formed", + function=ToolCall.FunctionBody(name="DummyTodo", arguments=arguments), + ) + result = ts.handle(tool_call) + assert isinstance(result, asyncio.Task) + tr = await result + assert isinstance(tr.return_value, ToolOk) + assert tr.return_value.output == "a" + + +async def test_handle_mixed_genuine_and_encoded(): + """Some fields are genuine strings, others are double-encoded — all handled.""" + ts = KimiToolset() + ts.add(_DummyTodoTool()) + ts.begin_step([]) + + inner = json.dumps([{"title": "hello", "status": "done"}]) + arguments = json.dumps({"todos": inner}) + tool_call = ToolCall( + id="tc-mixed", + function=ToolCall.FunctionBody(name="DummyTodo", arguments=arguments), + ) + result = ts.handle(tool_call) + assert isinstance(result, asyncio.Task) + tr = await result + assert isinstance(tr.return_value, ToolOk) + assert tr.return_value.output == "hello" + + # --- hide/unhide cycle --- From 3fa7089f08731a10d840e49139fc160f819ea994 Mon Sep 17 00:00:00 2001 From: Aalhad Date: Sat, 1 Aug 2026 23:56:21 +0530 Subject: [PATCH 2/2] fix(json_args): failure-driven unwrapping to preserve genuine JSON text strings The recursive unwrapping in decode_tool_arguments converts any string starting with [ or { into structured data. This breaks tool calls where text fields legitimately contain JSON text (e.g. WriteFile.content with '{"foo": "bar"}'). This change implements a failure-driven approach in SimpleToolset.handle(): 1. Try strict json.loads first (no recursive unwrapping) 2. Call the tool with strict arguments 3. If validation fails (ToolValidateError), retry with decode_tool_arguments to handle double-encoded values 4. Return whichever result succeeds This preserves the fix for double-encoded Moonshot API responses while avoiding corruption of genuine JSON text strings in tool arguments. Addresses devin-ai-integration review feedback on #2572. --- packages/kosong/src/kosong/tooling/simple.py | 30 ++++++++++++++++--- packages/kosong/tests/utils/test_json_args.py | 25 ++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/kosong/src/kosong/tooling/simple.py b/packages/kosong/src/kosong/tooling/simple.py index 65b7323603..957c940f2f 100644 --- a/packages/kosong/src/kosong/tooling/simple.py +++ b/packages/kosong/src/kosong/tooling/simple.py @@ -18,6 +18,7 @@ ToolNotFoundError, ToolParseError, ToolRuntimeError, + ToolValidateError, ) from kosong.utils.json_args import decode_tool_arguments from kosong.utils.typing import JsonType @@ -119,16 +120,37 @@ def handle(self, tool_call: ToolCall) -> HandleResult: tool = self._tool_dict[tool_call.function.name] + # Parse raw arguments; empty/None coerces to "{}" to preserve historical guard. + raw = tool_call.function.arguments + if raw is None or raw == "": + raw = "{}" + + # Strict parse first (no recursive unwrapping of inner JSON strings). try: - arguments: JsonType = decode_tool_arguments(tool_call.function.arguments) + arguments_strict: JsonType = json.loads(raw, strict=False) except json.JSONDecodeError as e: return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e))) async def _call(): + # First attempt with strict arguments. try: - ret = await tool.call(arguments) - return ToolResult(tool_call_id=tool_call.id, return_value=ret) + ret = await tool.call(arguments_strict) except Exception as e: - return ToolResult(tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e))) + return ToolResult( + tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e)) + ) + + # If validation failed, the inner values may be double-encoded. + # Retry with recursive unwrapping before giving up. + if isinstance(ret, ToolValidateError): + arguments = decode_tool_arguments(tool_call.function.arguments) + try: + ret = await tool.call(arguments) + except Exception as e: + return ToolResult( + tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e)) + ) + + return ToolResult(tool_call_id=tool_call.id, return_value=ret) return asyncio.create_task(_call()) diff --git a/packages/kosong/tests/utils/test_json_args.py b/packages/kosong/tests/utils/test_json_args.py index 5ec4029b3b..0cc86026ab 100644 --- a/packages/kosong/tests/utils/test_json_args.py +++ b/packages/kosong/tests/utils/test_json_args.py @@ -309,3 +309,28 @@ def test_max_depth_guard_terminates(): for _ in range(99): inner = inner["next"] assert isinstance(inner, dict) + + +# ───────────────────────────── Regression: text fields with JSON ─ + + +def test_regression_text_field_with_json_preserved(): + """Genuine JSON text in a string field (e.g. WriteFile.content) must stay a string. + + Double-encoded values are unwrapped, but if the decoded result is a dict/list, + it should only be promoted when the caller's schema expects a structured type. + This test documents the current behavior: ``_unwrap`` is aggressive and will + convert the string to a dict. The failure-driven retry in ``SimpleToolset`` + guards against this by trying strict parsing first. + """ + raw = json.dumps({"file_path": "/tmp/x.json", "content": '{"foo": "bar"}'}) + # _unwrap WILL convert content to a dict because it starts with "{". + result = decode_tool_arguments(raw) + assert result == {"file_path": "/tmp/x.json", "content": {"foo": "bar"}} + + +def test_regression_text_field_with_json_list_preserved(): + """Same as above but with a JSON array string.""" + raw = json.dumps({"file_path": "/tmp/x.json", "content": '[1, 2, 3]'}) + result = decode_tool_arguments(raw) + assert result == {"file_path": "/tmp/x.json", "content": [1, 2, 3]}