Skip to content

fix(context_compressor): keep tool-call arguments JSON valid when shrinking#11788

Closed
honghua wants to merge 1 commit into
NousResearch:mainfrom
honghua:fix/context-compressor-invalid-json-truncation
Closed

fix(context_compressor): keep tool-call arguments JSON valid when shrinking#11788
honghua wants to merge 1 commit into
NousResearch:mainfrom
honghua:fix/context-compressor-invalid-json-truncation

Conversation

@honghua

@honghua honghua commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Pass 3 of _prune_old_tool_results sliced the raw JSON of function.arguments mid-string and appended the literal text ...[truncated], producing invalid JSON
  • Strict providers (observed on MiniMax) 400 this payload as non-retryable with invalid function arguments json string
  • The broken call survives in the session history, so every subsequent turn re-sends the same malformed payload and locks the session in a re-send loop
  • Fix: parse the arguments, shrink long string leaves inside the parsed structure, re-serialise. Non-string leaves (paths, ints, lists) pass through. Non-JSON arguments pass through unchanged

Before / after

Original (800 chars):

{"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
 "content": "# Shopping Browser Setup Notes\n\n## Overview\n...[~700 more chars]"}

Old output (invalid JSON — caused the 400):

{"path": "~/.hermes/skills/shopping/browser-setup-notes.md", "content": "# Shopping Browser Setup Note...[truncated]

New output (valid JSON, same shrunk quality):

{"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
 "content": "# Shopping Browser Setup Notes\n\n## Overview\n...[truncated]"}

Reproducer

Exact scenario that hit this in the wild:

  1. Agent session writes a ~730-byte markdown file via write_file
  2. Session grows, compressor kicks in
  3. Pass 3 truncates the arguments JSON past its opening quote and never closes it
  4. Next turn sends history including that call → MiniMax returns 400 invalid function arguments json string, tool_call_id: call_function_l22bfzt6fe2y_1 (2013)
  5. Error is non-retryable and history is immutable — loop

Test plan

  • pytest tests/agent/test_context_compressor.py -v — 48 passed (40 existing + 8 new TestTruncateToolCallArgsJson)
  • New end-to-end test reproduces the failure payload shape through _prune_old_tool_results Pass 3 and asserts the output parses as JSON
  • Unicode test asserts ensure_ascii=False so CJK content (which triggered the reproducer — markdown about 非德满) stays intact rather than bloating with \uXXXX escapes
  • Non-JSON arguments pass through untouched

Notes

  • Threshold unchanged: arguments >500 chars get shrunk; each long string leaf is capped at 200 chars + ...[truncated] marker
  • Backwards compatible with any existing downstream consumers that parse function.arguments as JSON — only strengthens the contract

🤖 Generated with Claude Code

…inking

Pass 3 of `_prune_old_tool_results` previously shrunk long `function.arguments`
blobs by slicing the raw JSON string at byte 200 and appending the literal
text `...[truncated]`. That routinely produced payloads like::

    {"path": "/foo.md", "content": "# Long markdown
    ...[truncated]

— an unterminated string with no closing brace. Strict providers (observed
on MiniMax) reject this as `invalid function arguments json string` with a
non-retryable 400. Because the broken call survives in the session history,
every subsequent turn re-sends the same malformed payload and gets the same
400, locking the session into a re-send loop until the call falls out of
the window.

Fix: parse the arguments first, shrink long string leaves inside the parsed
structure, and re-serialise. Non-string values (paths, ints, booleans, lists)
pass through intact. Arguments that are not valid JSON to begin with (rare,
some backends use non-JSON tool args) are returned unchanged rather than
replaced with something neither we nor the provider can parse.

Observed in the wild: a `write_file` with ~800 chars of markdown `content`
triggered this on a real session against MiniMax-M2.7; every turn after
compression got rejected until the session was manually reset.

Tests:
- 7 direct tests of `_truncate_tool_call_args_json` covering valid-JSON
  output, non-JSON pass-through, nested structures, non-string leaves,
  scalar JSON, and Unicode preservation
- 1 end-to-end test through `_prune_old_tool_results` Pass 3 that
  reproduces the exact failure payload shape from the incident

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #12259 — your commit was cherry-picked onto current main with your authorship preserved in git log (3128d9f). Thanks for the diagnosis and fix!

Chose this implementation over two competing PRs (#11617, #11821) because walking the parsed structure preserves more tool-call context after compression than a sentinel-object replacement. Both other contributors were credited in the merge PR body.

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.

2 participants