Skip to content

Commit 3128d9f

Browse files
honghuaclaude
authored andcommitted
fix(context_compressor): keep tool-call arguments JSON valid when shrinking
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>
1 parent b73ebfe commit 3128d9f

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

agent/context_compressor.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,52 @@
6363
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
6464

6565

66+
def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
67+
"""Shrink long string values inside a tool-call arguments JSON blob while
68+
preserving JSON validity.
69+
70+
The ``function.arguments`` field on a tool call is a JSON-encoded string
71+
passed through to the LLM provider; downstream providers strictly
72+
validate it and return a non-retryable 400 when it is not well-formed.
73+
An earlier implementation sliced the raw JSON at a fixed byte offset and
74+
appended ``...[truncated]`` — which routinely produced strings like::
75+
76+
{"path": "/foo/bar", "content": "# long markdown
77+
...[truncated]
78+
79+
i.e. an unterminated string and a missing closing brace. MiniMax, for
80+
example, rejects this with ``invalid function arguments json string``
81+
and the session gets stuck re-sending the same broken history on every
82+
turn. See issue #11762 for the observed loop.
83+
84+
This helper parses the arguments, shrinks long string leaves inside the
85+
parsed structure, and re-serialises. Non-string values (paths, ints,
86+
booleans) are preserved intact. If the arguments are not valid JSON
87+
to begin with — some model backends use non-JSON tool arguments — the
88+
original string is returned unchanged rather than replaced with
89+
something neither we nor the backend can parse.
90+
"""
91+
try:
92+
parsed = json.loads(args)
93+
except (ValueError, TypeError):
94+
return args
95+
96+
def _shrink(obj: Any) -> Any:
97+
if isinstance(obj, str):
98+
if len(obj) > head_chars:
99+
return obj[:head_chars] + "...[truncated]"
100+
return obj
101+
if isinstance(obj, dict):
102+
return {k: _shrink(v) for k, v in obj.items()}
103+
if isinstance(obj, list):
104+
return [_shrink(v) for v in obj]
105+
return obj
106+
107+
shrunken = _shrink(parsed)
108+
# ensure_ascii=False preserves CJK/emoji instead of bloating with \uXXXX
109+
return json.dumps(shrunken, ensure_ascii=False)
110+
111+
66112
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
67113
"""Create an informative 1-line summary of a tool call + result.
68114
@@ -449,6 +495,11 @@ def _prune_old_tool_results(
449495
# Pass 3: Truncate large tool_call arguments in assistant messages
450496
# outside the protected tail. write_file with 50KB content, for
451497
# example, survives pruning entirely without this.
498+
#
499+
# The shrinking is done inside the parsed JSON structure so the
500+
# result remains valid JSON — otherwise downstream providers 400
501+
# on every subsequent turn until the broken call falls out of
502+
# the window. See ``_truncate_tool_call_args_json`` docstring.
452503
for i in range(prune_boundary):
453504
msg = result[i]
454505
if msg.get("role") != "assistant" or not msg.get("tool_calls"):
@@ -459,8 +510,10 @@ def _prune_old_tool_results(
459510
if isinstance(tc, dict):
460511
args = tc.get("function", {}).get("arguments", "")
461512
if len(args) > 500:
462-
tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}}
463-
modified = True
513+
new_args = _truncate_tool_call_args_json(args)
514+
if new_args != args:
515+
tc = {**tc, "function": {**tc["function"], "arguments": new_args}}
516+
modified = True
464517
new_tcs.append(tc)
465518
if modified:
466519
result[i] = {**msg, "tool_calls": new_tcs}

tests/agent/test_context_compressor.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,3 +781,127 @@ def test_prune_without_token_budget_uses_message_count(self, budget_compressor):
781781
# Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4)
782782
# so it might or might not be pruned depending on boundary
783783
assert isinstance(pruned, int)
784+
785+
786+
class TestTruncateToolCallArgsJson:
787+
"""Regression tests for #11762.
788+
789+
The previous implementation produced invalid JSON by slicing
790+
``function.arguments`` mid-string, which caused non-retryable 400s from
791+
strict providers (observed on MiniMax) and stuck long sessions in a
792+
re-send loop. The helper here must always emit parseable JSON whose
793+
shape matches the original — shrunken, not corrupted.
794+
"""
795+
796+
def _helper(self):
797+
from agent.context_compressor import _truncate_tool_call_args_json
798+
return _truncate_tool_call_args_json
799+
800+
def test_shrunken_args_remain_valid_json(self):
801+
import json as _json
802+
shrink = self._helper()
803+
original = _json.dumps({
804+
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
805+
"content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400,
806+
})
807+
assert len(original) > 500
808+
shrunk = shrink(original)
809+
parsed = _json.loads(shrunk) # must not raise
810+
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
811+
assert parsed["content"].endswith("...[truncated]")
812+
assert len(shrunk) < len(original)
813+
814+
def test_non_json_arguments_pass_through(self):
815+
shrink = self._helper()
816+
not_json = "this is not json at all, " * 50
817+
assert shrink(not_json) == not_json
818+
819+
def test_short_string_leaves_unchanged(self):
820+
import json as _json
821+
shrink = self._helper()
822+
payload = _json.dumps({"command": "ls -la", "cwd": "/tmp"})
823+
assert _json.loads(shrink(payload)) == {"command": "ls -la", "cwd": "/tmp"}
824+
825+
def test_nested_structures_are_walked(self):
826+
import json as _json
827+
shrink = self._helper()
828+
payload = _json.dumps({
829+
"messages": [
830+
{"role": "user", "content": "x" * 500},
831+
{"role": "assistant", "content": "ok"},
832+
],
833+
"meta": {"note": "y" * 500},
834+
})
835+
parsed = _json.loads(shrink(payload))
836+
assert parsed["messages"][0]["content"].endswith("...[truncated]")
837+
assert parsed["messages"][1]["content"] == "ok"
838+
assert parsed["meta"]["note"].endswith("...[truncated]")
839+
840+
def test_non_string_leaves_preserved(self):
841+
import json as _json
842+
shrink = self._helper()
843+
payload = _json.dumps({
844+
"retries": 3,
845+
"enabled": True,
846+
"timeout": None,
847+
"items": [1, 2, 3],
848+
"note": "z" * 500,
849+
})
850+
parsed = _json.loads(shrink(payload))
851+
assert parsed["retries"] == 3
852+
assert parsed["enabled"] is True
853+
assert parsed["timeout"] is None
854+
assert parsed["items"] == [1, 2, 3]
855+
assert parsed["note"].endswith("...[truncated]")
856+
857+
def test_scalar_json_string_gets_shrunk(self):
858+
import json as _json
859+
shrink = self._helper()
860+
payload = _json.dumps("q" * 500)
861+
parsed = _json.loads(shrink(payload))
862+
assert isinstance(parsed, str)
863+
assert parsed.endswith("...[truncated]")
864+
865+
def test_unicode_preserved(self):
866+
import json as _json
867+
shrink = self._helper()
868+
payload = _json.dumps({"content": "非德满" + ("a" * 500)})
869+
out = shrink(payload)
870+
# ensure_ascii=False keeps CJK intact rather than emitting \uXXXX
871+
assert "非德满" in out
872+
873+
def test_pass3_emits_valid_json_for_downstream_provider(self):
874+
"""End-to-end: Pass 3 must never produce the exact failure payload
875+
that caused the 400 loop (unterminated string, missing brace)."""
876+
import json as _json
877+
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
878+
c = ContextCompressor(
879+
model="test/model",
880+
threshold_percent=0.85,
881+
protect_first_n=1,
882+
protect_last_n=1,
883+
quiet_mode=True,
884+
)
885+
huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400
886+
args_payload = _json.dumps({
887+
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
888+
"content": huge_content,
889+
})
890+
assert len(args_payload) > 500 # triggers the Pass-3 shrink
891+
messages = [
892+
{"role": "user", "content": "please write two files"},
893+
{"role": "assistant", "content": None, "tool_calls": [
894+
{"id": "call_1", "type": "function",
895+
"function": {"name": "write_file", "arguments": args_payload}},
896+
]},
897+
{"role": "tool", "tool_call_id": "call_1",
898+
"content": '{"bytes_written": 727}'},
899+
{"role": "user", "content": "ok"},
900+
{"role": "assistant", "content": "done"},
901+
]
902+
result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)
903+
shrunk = result[1]["tool_calls"][0]["function"]["arguments"]
904+
# Must parse — otherwise downstream provider returns 400
905+
parsed = _json.loads(shrunk)
906+
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
907+
assert parsed["content"].endswith("...[truncated]")

0 commit comments

Comments
 (0)