fix(sanitization): close truncated tool-call args by nesting, not by count - #68612
fix(sanitization): close truncated tool-call args by nesting, not by count#68612mohamedelrefaiy wants to merge 5 commits into
Conversation
…count
`_repair_tool_call_arguments` had two defects that made it drop arguments it
could have recovered, and one that let it crash.
1. Structures were closed by delimiter *count*: every missing `}` was appended
before every missing `]`, regardless of actual nesting. A payload whose
innermost open structure is an array got its closers in the wrong order,
stayed invalid, and fell through to the `"{}"` last resort -- discarding
the model's arguments. `{"items": [1, 2, 3` produced `{"items": [1, 2, 3}]`.
2. The same counting ran over the raw string, so `{` and `}` inside string
*values* skewed the deficit in either direction. Tool calls carrying code
are the common case.
Both are replaced by a string-aware scan that pushes open delimiters onto a
stack and closes them innermost-first, mirroring the in-string tracking
`_escape_invalid_chars_in_json_strings` already does in this module.
3. Two of the four `json.loads` guards caught only `json.JSONDecodeError`.
Since CPython 3.11, integer conversion past `sys.get_int_max_str_digits()`
raises a bare `ValueError` from inside `json.loads`, so a long digit run
escaped a function whose callers rely on it never raising. The other two
sites already catch `ValueError`; widen these to match.
Truncation that lands *inside* a string value is deliberately left
unrepairable. Closing the quote would yield well-formed JSON carrying a
silently incomplete value, which the caller would execute as though complete
-- the failure mode reported in NousResearch#62948. Those payloads keep flowing to the
partial-stream/truncation path, and the existing test asserting that is
unchanged.
Refs NousResearch#35151, NousResearch#62948
There was a problem hiding this comment.
Pull request overview
This PR improves Hermes’s tool-call argument sanitization by making JSON “closing” repairs nesting-aware (rather than delimiter-count-based) and by ensuring _repair_tool_call_arguments never raises when json.loads triggers a ValueError (e.g., from overly long numeric literals). This lives in the agent’s message/tool-call repair path, so it directly reduces cases where otherwise-repairable tool arguments degrade to the "{}" last resort.
Changes:
- Replace count-based “close unclosed JSON” logic with a string/escape-aware stack that appends closers in correct nesting order and ignores delimiters inside string values.
- Widen two
json.loads(...)exception handlers to catch(json.JSONDecodeError, ValueError)so the sanitizer preserves its “never-raises” contract under Python’s max-int-digit guard. - Add targeted regression tests covering nesting-aware closing, delimiters-in-strings, dangling commas before appended closers, and long-digit-run
ValueErrorbehavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
agent/message_sanitization.py |
Introduces _close_unclosed_json_structures() and uses it in _repair_tool_call_arguments; widens exception handling to include ValueError for json.loads. |
tests/run_agent/test_repair_tool_call_arguments.py |
Adds new test cases validating correct nesting closure, delimiter handling inside strings, intentional non-repair when truncated mid-string, and “never-raises” behavior for long numeric literals. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused repair and regression coverage. The delimiter-count premise is present on current main at agent/message_sanitization.py:228-233, and the nesting-aware replacement is appropriately scoped.
Problems
- The
ValueErrorfix is bypassed in streaming:agent/chat_completion_helpers.py:3373-3375catches onlyJSONDecodeErrorbefore calling_repair_tool_call_arguments. A >4300-digit literal therefore raises before this PR's helper guards run. - The executor's fail-closed parser has the same gap at
agent/tool_executor.py:102-107; it catchesJSONDecodeErrorandTypeError, notValueError.
Suggested changes
- Catch
ValueErrorat both parsing boundaries and add an actual streaming-path regression test, rather than testing only the helper. The existing real-path truncated-call test is attests/run_agent/test_run_agent.py:8294-8313.
Automated hermes-sweeper review.
| @@ -251,7 +300,7 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str: | |||
| tool_name, raw_stripped[:80], fixed[:80], | |||
There was a problem hiding this comment.
This makes the helper safe when called, but streaming parses first at agent/chat_completion_helpers.py:3373-3375 and catches only JSONDecodeError; a long numeric literal still raises ValueError before reaching here. Please widen that boundary and add a real streaming-path regression test.
There was a problem hiding this comment.
You're right, and thanks for catching it. Guarding the helper alone was not enough, since both callers parse before they ever reach it. Fixed in 312b349:
agent/chat_completion_helpers.py:2943now catchesValueErrorinstead ofJSONDecodeErrorbefore calling_repair_tool_call_arguments.agent/tool_executor.py:105now catches(ValueError, TypeError).
json.JSONDecodeError subclasses ValueError, so both boundaries still catch everything they caught before.
Three regression tests exercise the real paths rather than the helper, in tests/run_agent/test_run_agent.py:
test_long_numeric_literal_does_not_escape_streaming_buildertest_concurrent_long_numeric_literal_rejected_without_crashtest_nested_array_truncation_is_repaired_in_streaming_path
The first two fail without the boundary change — the streaming builder aborts with Exceeds the limit (4300 digits) for integer string conversion before the repair helper runs, which is exactly the bypass you described.
Review feedback on NousResearch#68612: widening the guards inside `_repair_tool_call_arguments` was not enough, because two callers validate the arguments *before* the helper ever runs. - `agent/chat_completion_helpers.py` gated the repair call on `except json.JSONDecodeError`, so a >4300-digit literal raised out of the streaming response builder and killed the turn before repair was attempted ("Streaming failed before delivery: Exceeds the limit (4300 digits)"). - `agent/tool_executor._parse_tool_arguments` caught `(json.JSONDecodeError, TypeError)`, so the same payload propagated instead of returning the structured error that keeps the parser fail-closed. Both now catch `ValueError`. `json.JSONDecodeError` subclasses `ValueError`, so malformed-JSON coverage is unchanged. Adds the streaming-path regression tests the review asked for, alongside the helper-level ones: a digit run-on no longer escapes the streaming builder, a nested-array truncation is repaired end-to-end through that path, and the concurrent executor rejects the oversized literal while its sibling call still runs. The two ValueError tests fail without this commit.
Closing by nesting order is only safe if the pass in front of it also knows
where strings start and end. It did not: repair pass 1 used
`re.sub(r',\s*([}\]])', r'\1', ...)`, which rewrites a comma inside a string
*value* when a `]` or `}` follows it.
On its own that was mostly latent -- the corrupted payload still failed to
parse and degraded to `"{}"`. Paired with a nesting-aware closer it parses
cleanly, so the corrupted value now reaches the tool:
{"sep": ", ]", "files": ["a" -> {"sep": "]", "files": ["a"]}
A dropped argument became a silently wrong one. Pass 1 now reuses the same
string/escape state machine as the closer, so both passes agree on what a
string is.
Two further breaks of the never-raises contract, found while auditing the
rest of the pipeline:
- `json.loads` recurses once per nesting level, so a repetition-loop payload
(`"[" * 100000` -- a known local-model failure mode) raised `RecursionError`
straight out of the function. `RecursionError` derives from `Exception`,
not `ValueError`, so none of the existing guards caught it.
- Pass 0 re-serialised with a default `json.dumps`, which turns the `inf` that
`json.loads` produces for `1e999` back into a bare `Infinity` token. That is
not valid JSON, so the "repair" could emit output a strict consumer rejects.
With `allow_nan=False` the payload falls through to the later passes and the
spec-legal original text is returned instead.
Each of the four new tests fails without the corresponding change.
Two fail-open paths where the repair handed back a payload that parses,
turning input JSON rightly rejects into an executed tool call.
1. `str.isspace()` and a bare `str.rstrip()` are Unicode-aware: they also match
U+001C-U+001F, U+0085, U+00A0 and U+2028, none of which JSON permits outside
a string (RFC 8259 s2). Skipping them as whitespace deleted them, so
`{"command":"echo X",<U+001C>}` repaired to a valid call. The pre-existing
`re.sub(r',\s*([}\]])', ...)` had the same hole, since `\s` on a str pattern
is Unicode-aware too. All whitespace tests in the repair path now use an
explicit `_JSON_WHITESPACE = " \t\n\r"`.
2. A dangling comma is no longer trimmed before appending closers. `{"a": [1,
2,` ends on a comma, which is the model promising another element that never
arrived; trimming it and closing presented a short list as a complete one.
Leaving the comma makes the result unparseable, so it falls through to `"{}"`
and the caller's truncation path -- matching the behaviour before this branch.
A *stray* trailing comma in otherwise-complete JSON (`{"a": [1, 2,]}`) is a
separate case and is still repaired by `_strip_trailing_commas`.
Together these make the branch strictly more conservative than before it about
what it will execute, while keeping every structural recovery it added: the
nesting-order cases, delimiters inside string values, and the never-raises
guarantees are unchanged and still covered.
Pass 3 trims a closer the model emitted one too many of. It decided using
`str.count`, the same technique this branch replaced in the other two passes
and for the same reason: a delimiter inside a string *value* is not structure.
`{"s":"{","x":[1]}}` counted its braces as balanced, so the genuinely excess
`}` was never dropped and a payload one character from valid degraded to `"{}"`.
Trimming stays deliberately narrow. Only a closer that is the payload's LAST
token is removed; an excess closer in the middle (`{"a": [1]]}`) is left alone,
because the trim works from the tail and would otherwise take a legitimate
closer with it and mangle the payload. Those inputs remain unparseable and
fall through to `"{}"` exactly as before -- reachability there is a separate
problem, noted in the helper's docstring rather than half-fixed here.
Also narrows an overstatement in b733d61/73e8f09: `allow_nan=False` constrains
pass 0's guarded re-serialisation only. It is not an end-to-end policy on
non-finite numbers -- `{"n":1e999}`, `NaN` and `Infinity` still reach tools via
pass 3's plain `json.loads` and `tool_executor._parse_tool_arguments`, both of
which accept those Python extensions. That gap predates this branch and needs
a finite-number policy at every parse boundary; it is left for a follow-up
rather than widened into this PR.
|
Both problems are addressed, plus three more that surfaced while auditing the rest of the pipeline. Four commits pushed. The Three further fixes, all found by testing the passes against each other:
Net effect: the branch is now stricter than main on inputs it used to execute, while recovering the shapes it used to silently drop. Two gaps I did not fix here, both pre-existing and verified against an unmodified checkout:
Happy to send either as its own PR if you want them. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two PRs address the delimiter-count failure in tool-call argument repair: #42510 narrowly replaces fixed-order closing with a string-aware LIFO stack, while #68612 incorporates that core fix and extends it across related sanitization passes, exception boundaries, and real streaming/executor paths. The latter also deliberately leaves truncation inside strings or after dangling commas unrepairable to avoid executing silently incomplete arguments.
Related pull requests
- #42510 [closed]
duplicate— (+61/-11) — leave closed as superseded by #68612: its focused string-aware LIFO delimiter repair and regression tests correctly expose the nesting bug, so it remains relevant as the narrower reference implementation. The maintainer-bot close verdict rejects that reconstruction direction under the standing policy; reopening is unnecessary because #68612 contains the same core change and has a later contributor keep-open review. - #68612
related— (+397/-23) — keep open with a salvage path: retain the nesting-aware closer, string-aware comma/excess-closer handling, fail-closed treatment of incomplete values, and real-path regressions. The contributor keep_open review identified uncaught ValueError boundaries at agent/chat_completion_helpers.py:3373-3375 and agent/tool_executor.py:102-107; the diff addresses both boundaries and adds streaming/executor tests, while also supplying broader safeguards that should be reviewed independently of the core LIFO fix.
Duplicates
#42510 and #68612 substantially duplicate the string-aware LIFO replacement for count-based delimiter closing; #68612 is the broader successor, so #42510 can remain closed as superseded by #68612.
Suggested consolidation
Keep open with a salvage path for #68612: preserve the focused LIFO repair and its end-to-end regression coverage, then review the additional sanitizer and exception-boundary changes as separable parts because they materially expand scope and risk. Leave #42510 closed as superseded by #68612; do not reopen it over the maintainer-bot policy verdict.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup42510 ["PRs duplicating each other"]
P42510["PR #42510 (closed)"]
P68612["PR #68612 (open)"]
end
class P42510 closed
class P68612 open
class P68612 target
click P42510 "https://github.com/NousResearch/hermes-agent/pull/42510"
click P68612 "https://github.com/NousResearch/hermes-agent/pull/68612"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 29 kB of PR diffs, 7 kB of issue/PR text, 10 kB of discussion (7 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
…62640) Ports the tool-call repair observability layer onto current main as a fresh, scoped PR. Supersedes NousResearch#62640 (5434 commits stale, never merged). - agent/tool_repair_stats.py: thread-safe ring-buffer singleton, per-model and per-pattern counts, 21 tests. Final review version (no dead set_current_model). - Instrumentation in the 3 repair paths: message_sanitization (_stat), agent_runtime_helpers (truncated_args), model_tools (bare-string/object wrap). Lazy-imported + defensive no-op so the module can be absent. - Operator output surface: new 'hermes repair-stats' CLI command wires summary() to a real call site (fixes Teknium finding NousResearch#4 — summary was dead code in the original PR). - 21 new tests + existing sanitize/coerce regression pass. Steps: Step 0 overlap check done — NousResearch#77395 (LIFO close) already merged upstream (functional part), NousResearch#34132/NousResearch#68612 are the repair logic itself not observability. This is the only stats/observability PR.
What
_repair_tool_call_argumentsdecided where JSON structures open and close by counting delimiters. Counting cannot see nesting order, and counts every{or]inside a string value as structure. Both make the repair emit invalid JSON for payloads it could otherwise recover, which then degrade to the"{}"last resort — dropping the model's arguments entirely.All three passes that reasoned about delimiters now share one string- and escape-aware scan:
re.sub(r',\s*([}\]])', ...)str.countdeficit, all}then all]str.countdeficitTwo ways the function could break its documented never-raises contract are also fixed. Since CPython 3.11,
json.loadsraises a bareValueErroron an integer literal pastsys.get_int_max_str_digits(); two of four guards caught onlyJSONDecodeError, and — as review correctly pointed out — the two callers pre-validate withjson.loadsbefore ever reaching the helper, so guarding the helper alone was not enough. Both call sites now catch it.json.loadsalso recurses per nesting level, so"[" * 100000(a repetition-loop payload from exactly the local models this function serves) raisedRecursionErrorstraight out; that is now contained.What this deliberately refuses to repair
Repaired arguments may be executed, so anything that would invent a value fails closed to
"{}"and routes to the existing truncation path instead.{"path":"x.txt","content":"hel— closing the quote yields well-formed JSON carrying a silently incomplete value. The existing test asserting this (test_unrepairable_partial_returns_empty_object) andTestStreamingApiCall::test_truncated_tool_call_args_no_finish_reason_routes_to_stubare unchanged.{"a": [1, 2,— the distinction is whether the last value is complete.[1, 2has a complete final value and only needs its container closed.[1, 2,does not: the comma is the model promising a next value that never arrived, so completing it would present a short list as a whole one. This is narrower than the previous behaviour on that input, and intentionally so.{"a": [1]]}— trimming works from the tail and would remove a legitimate closer. Unchanged from before.str.isspace(), barestr.rstrip()and a regex\son astrpattern all additionally match U+001C–U+001F, U+0085, U+00A0 and U+2028. Treating those as skippable deleted them and returned a payload that parses, so{"command":"echo X",\x1c}became an executable call. All whitespace tests in the repair path now use an explicit_JSON_WHITESPACE.Behaviour
{"items": [1, 2, 3{}{"items": [1, 2, 3]}{"edits": [{"line": 1{}{"edits": [{"line": 1}]}{"a": {"b": [{"c": [1, 2{}{"a": {"b": [{"c": [1, 2]}]}}{"sep": ", ]", "files": ["a"{}{"sep": ", ]", "files": ["a"]}{"s":"{","x":[1]}}{}{"s":"{","x":[1]}{"n":+9×5000ValueError{}"[" × 100000RecursionError{}{"command":"echo X",\x1c}{}{"a": [1, 2,{"a": [1, 2]}{}{"path":"x.txt","content":"hel{}{}Tests
17 cases added to
tests/run_agent/test_repair_tool_call_arguments.pyand 3 totests/run_agent/test_run_agent.pycovering the caller boundaries. Each fails without its corresponding change.tests/run_agent: 2131 passed, 2 failed — bothtest_provider_parityfailures reproduce on an unmodified checkout of this commit's parent.ruffclean.Related
Refs #35151, #62948. Those report the symptom: arguments replaced with
{}. The mitigation on main — treating a{}result as truncated rather than forwarding it — stopped the silent execution but left the repair itself wrong, so recoverable payloads still fail. This fixes the repair underneath that mitigation; the two compose. Noteagent/conversation_loop.py:1107assigns the repair result directly with no{}check, unlike thechat_completion_helpers.pysite; that path is history normalisation rather than execution, so it is not addressed here.Non-finite numbers (
1e999,NaN,Infinity) still reach tools through pass 3's plainjson.loadsandtool_executor._parse_tool_arguments, and lone surrogate escapes still pass through a module that carries_sanitize_surrogatesfor that hazard. Both predate this change and need a policy applied at every parse boundary rather than inside the repair passes; happy to follow up with those separately.