Skip to content

fix(kosong): recursively unwrap double-encoded JSON in tool-call arguments - #2572

Open
aalhadxx wants to merge 2 commits into
MoonshotAI:mainfrom
aalhadxx:fix/decode-double-encoded-tool-args
Open

fix(kosong): recursively unwrap double-encoded JSON in tool-call arguments#2572
aalhadxx wants to merge 2 commits into
MoonshotAI:mainfrom
aalhadxx:fix/decode-double-encoded-tool-args

Conversation

@aalhadxx

@aalhadxx aalhadxx commented Jul 31, 2026

Copy link
Copy Markdown

Problem

Tool calls with array or object parameters (e.g. SetTodoList, ExitPlanMode, StrReplaceFile) fail with Pydantic validation errors when using providers that double-encode nested values:

The Moonshot API returns "function.arguments" where inner array/object values are themselves JSON strings. A single json.loads leaves these as strings, which then fail validation.

A prior fix was attempted in #2513 (Devin found 1 potential issue; author has not responded). This PR supersedes it with a cleaner implementation and broader test coverage.

Solution

Introduce — a shared helper consumed by both and .

Key improvements over #2513:

  • Fast-path prefix check: strings are only parsed if their first non-whitespace char is or , avoiding unnecessary calls on typical non-JSON string values.
  • Outer malformed guard: is re-raised for malformed outer input so callers can surface (preserves today's contract).
  • Scalar protection: strings that parse to scalars (, , ) are left untouched so genuine string fields are never corrupted.
  • Recursion terminates: only re-enters when a string parses to a dict/list; non-string leaves return immediately.

Files changed

File Change
New shared helper
Replace json.loads with helper
Replace json.loads with helper
23 unit tests (E1–E10, termination, regression, unicode)
3 integration tests through KimiToolset

Tests

Unit tests cover:

  • E1–E2: top-level and nested double-encoding unwrap
  • E3: non-JSON strings preserved (malformed braces, empty strings)
  • E4: scalar JSON strings preserved (int, float, bool, null)
  • E5: None / empty input coerces to
  • E6: well-formed single-encoded args unchanged (regression)
  • E7: list-typed outer value with inner string decoded
  • E8: mid-bracket strings and leading-whitespace handling
  • E9: malformed outer input re-raises JSONDecodeError
  • E10: fast-path sanity — plain-text strings never parsed
  • Termination: deep nested arrays (20 levels) and double-encoded chains (10 levels)
  • Regression: SetTodoList, StrReplaceFile.edit, ExitPlanMode.options shapes
  • Mixed genuine + encoded fields in the same dict
  • Unicode in double-encoded strings

Integration tests verify the fix end-to-end through with a dummy tool mirroring 's parameter.

Fixes #2406
Supersedes / improves upon #2513


Open in Devin Review

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +28 to +46
def _unwrap(value: object) -> object:
if isinstance(value, dict):
return {k: _unwrap(v) for k, v in cast("dict[str, object]", value).items()}
if isinstance(value, list):
return [_unwrap(x) 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
try:
parsed = json.loads(value, strict=False)
except (json.JSONDecodeError, ValueError):
return value
if isinstance(parsed, (dict, list)):
return _unwrap(parsed)
return value
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tool calls whose text argument contains JSON break, e.g. writing a JSON file

Any text value that begins with a curly brace or square bracket is silently converted into structured data (json.loads at packages/kosong/src/kosong/utils/json_args.py:40) before the tool receives it, so ordinary operations such as writing or editing a JSON file now fail.

Impact: Writing or replacing content in JSON/JSONL files (and any other tool argument whose text happens to start with { or [) errors out instead of working.

Mechanism: unconditional recursive unwrap ignores the declared parameter type

decode_tool_arguments calls _unwrap (packages/kosong/src/kosong/utils/json_args.py:28-46) on every string in the payload, regardless of what type the tool's schema declares. For example WriteFile declares content: str (src/kimi_cli/tools/file/write.py:29). A call like {"path": "pkg.json", "content": "{\"a\": 1}"} is now decoded to {"path": ..., "content": {"a": 1}}, and Pydantic rejects it with "Input should be a valid string". The same applies to StrReplaceFile's old_string/new_string and to any text argument holding a JSON/array-looking snippet. The previous single json.loads preserved these values.

Additionally, _unwrap keeps recursing into the parsed result, so text nested inside genuinely structured arguments is corrupted the same way.

Prompt for agents
decode_tool_arguments in packages/kosong/src/kosong/utils/json_args.py unwraps ANY string value that parses as a JSON object/array, without consulting the tool's parameter schema. This corrupts genuine string parameters that legitimately hold JSON text — most notably WriteFile.content (src/kimi_cli/tools/file/write.py) and StrReplaceFile old_string/new_string — turning them into dicts/lists so Pydantic validation fails with 'Input should be a valid string'. Consider a schema-aware or failure-driven approach instead: e.g. validate the arguments as-is first and only attempt the double-encoding unwrap when validation fails, or only unwrap a field when the tool's JSON schema declares that field as array/object. Restricting unwrapping to the top level (not recursing into already-structured data) would also reduce blast radius.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…ments

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 MoonshotAI#2406; improves upon MoonshotAI#2513 (adds fast-path and broader edge-case coverage).
…xt 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 MoonshotAI#2572.
@aalhadxx

aalhadxx commented Aug 1, 2026

Copy link
Copy Markdown
Author

@devin-ai-integration good catch — you are absolutely right. Unconditionally unwrapping every string is too aggressive.

Pushed a fix that makes the unwrapping failure-driven:

  1. now tries strict first (no recursive unwrapping)
  2. Calls the tool with those arguments
  3. If validation fails (), it retries with (recursive unwrapping)
  4. Returns whichever attempt succeeds

This means genuine JSON text in or strings stays untouched unless the strict parse actually fails validation — which is exactly the case for double-encoded Moonshot API responses.

Also added regression tests documenting the case. Let me know if you see any other issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Tool call arguments double-encoding breaks array/dict parameters (Moonshot API)

1 participant