Skip to content

fix(sanitization): close truncated tool-call args by nesting, not by count - #68612

Open
mohamedelrefaiy wants to merge 5 commits into
NousResearch:mainfrom
mohamedelrefaiy:fix/tool-call-arg-repair-truncation
Open

fix(sanitization): close truncated tool-call args by nesting, not by count#68612
mohamedelrefaiy wants to merge 5 commits into
NousResearch:mainfrom
mohamedelrefaiy:fix/tool-call-arg-repair-truncation

Conversation

@mohamedelrefaiy

@mohamedelrefaiy mohamedelrefaiy commented Jul 21, 2026

Copy link
Copy Markdown

What

_repair_tool_call_arguments decided 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.

_repair_tool_call_arguments('{"items": [1, 2, 3')
# before: '{"items": [1, 2, 3}]'   -> still invalid -> "{}"
#  after: '{"items": [1, 2, 3]}'

All three passes that reasoned about delimiters now share one string- and escape-aware scan:

pass was now
strip trailing commas re.sub(r',\s*([}\]])', ...) string-aware scan
close unclosed structures str.count deficit, all } then all ] LIFO stack, innermost first
trim excess closers str.count deficit string-aware, trailing token only

Two ways the function could break its documented never-raises contract are also fixed. Since CPython 3.11, json.loads raises a bare ValueError on an integer literal past sys.get_int_max_str_digits(); two of four guards caught only JSONDecodeError, and — as review correctly pointed out — the two callers pre-validate with json.loads before ever reaching the helper, so guarding the helper alone was not enough. Both call sites now catch it. json.loads also recurses per nesting level, so "[" * 100000 (a repetition-loop payload from exactly the local models this function serves) raised RecursionError straight 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.

  • Truncation inside a string value. {"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) and TestStreamingApiCall::test_truncated_tool_call_args_no_finish_reason_routes_to_stub are unchanged.
  • Truncation immediately after a comma. {"a": [1, 2, — the distinction is whether the last value is complete. [1, 2 has 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.
  • An excess closer that is not the last token. {"a": [1]]} — trimming works from the tail and would remove a legitimate closer. Unchanged from before.
  • JSON-illegal characters posing as whitespace. JSON permits only space, tab, LF and CR between tokens (RFC 8259 §2). str.isspace(), bare str.rstrip() and a regex \s on a str pattern 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

input before after
{"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×5000 raises ValueError {}
"[" × 100000 raises RecursionError {}
{"command":"echo X",\x1c} executed {}
{"a": [1, 2, {"a": [1, 2]} {}
{"path":"x.txt","content":"hel {} {}
balanced input unchanged unchanged

Tests

17 cases added to tests/run_agent/test_repair_tool_call_arguments.py and 3 to tests/run_agent/test_run_agent.py covering the caller boundaries. Each fails without its corresponding change. tests/run_agent: 2131 passed, 2 failed — both test_provider_parity failures reproduce on an unmodified checkout of this commit's parent. ruff clean.

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. Note agent/conversation_loop.py:1107 assigns the repair result directly with no {} check, unlike the chat_completion_helpers.py site; 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 plain json.loads and tool_executor._parse_tool_arguments, and lone surrogate escapes still pass through a module that carries _sanitize_surrogates for 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.

…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
Copilot AI review requested due to automatic review settings July 21, 2026 12:58

Copilot AI 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.

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 ValueError behavior.

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.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists labels Jul 21, 2026

@teknium1 teknium1 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.

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 ValueError fix is bypassed in streaming: agent/chat_completion_helpers.py:3373-3375 catches only JSONDecodeError before 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 catches JSONDecodeError and TypeError, not ValueError.

Suggested changes

  • Catch ValueError at 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 at tests/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],

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.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:2943 now catches ValueError instead of JSONDecodeError before calling _repair_tool_call_arguments.
  • agent/tool_executor.py:105 now 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_builder
  • test_concurrent_long_numeric_literal_rejected_without_crash
  • test_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.
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
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.
@mohamedelrefaiy

Copy link
Copy Markdown
Author

Both problems are addressed, plus three more that surfaced while auditing the rest of the pipeline. Four commits pushed.

The ValueError bypass you flagged (312b349). Guarding the helper was not enough because both callers parse first. agent/chat_completion_helpers.py:2943 now catches ValueError; agent/tool_executor.py:105 now catches (ValueError, TypeError). JSONDecodeError subclasses ValueError, so coverage is a strict superset. Three tests in tests/run_agent/test_run_agent.py exercise the streaming and executor paths rather than the helper, two of which fail without the change.

Three further fixes, all found by testing the passes against each other:

  • 73e8f09 — the trailing-comma strip used re.sub(r',\s*([}\]])', ...), which has no notion of string boundaries and rewrote a comma inside a string value. On its own that degraded to "{}"; paired with a nesting-aware closer it parsed cleanly and delivered a corrupted value: {"sep": ", ]", "files": ["a" became {"sep": "]", ...}. Same commit adds RecursionError to the never-raises guards — json.loads recurses per nesting level, so "[" * 100000 raised straight out of a function documented never to raise.
  • 0d649bestr.isspace() and bare str.rstrip() are Unicode-aware and match U+001C–U+001F, U+0085, U+00A0 and U+2028, none of which JSON permits outside a string. Treating them as skippable deleted them and returned a payload that parses, so {"command":"echo X",\x1c} became an executable call. Note the pre-existing \s regex had the same hole. Also stops trimming a dangling comma before appending closers, since {"a": [1, 2, is the model promising a value that never arrived — completing it presents a short list as a whole one. That input now fails closed, which is narrower than before and deliberate.
  • 7998eb4 — the excess-closer trim still used str.count, the same technique this PR replaces elsewhere and for the same reason. {"s":"{","x":[1]}} counted as balanced, so a payload one character from valid degraded to "{}". Trimming only removes a closer that is the payload's last token; an excess closer mid-payload ({"a": [1]]}) is left alone rather than taking a legitimate closer with it.

Net effect: the branch is now stricter than main on inputs it used to execute, while recovering the shapes it used to silently drop. tests/run_agent: 2131 passed, 2 failed, both reproducing on an unmodified checkout of the base commit.

Two gaps I did not fix here, both pre-existing and verified against an unmodified checkout:

  1. Non-finite numbers still reach tools. {"c":"echo X","timeout":1e999,} survives as 1e999 and parses to float('inf'); NaN, Infinity and -Infinity behave the same. The allow_nan=False in 73e8f09 constrains only pass 0's re-serialisation — pass 3's plain json.loads and _parse_tool_arguments both accept these Python extensions. A real fix needs a finite-number policy at every parse boundary.
  2. Lone surrogate escapes pass through: {"path":"\uD800",} yields a Python string carrying a lone surrogate, which can fail at a UTF-8 boundary. Worth noting because this module already carries _sanitize_surrogates for that hazard, and a valid surrogate pair is preserved as two units that the later sanitizer replaces independently.

Happy to send either as its own PR if you want them.

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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"
Loading

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.

swissly added a commit to swissly/hermes-agent that referenced this pull request Aug 12, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants