Skip to content

fix(agent): close unclosed JSON tool-call args in LIFO order - #77395

Open
swissly wants to merge 2 commits into
NousResearch:mainfrom
swissly:fix/agent-lifo-json-close
Open

fix(agent): close unclosed JSON tool-call args in LIFO order#77395
swissly wants to merge 2 commits into
NousResearch:mainfrom
swissly:fix/agent-lifo-json-close

Conversation

@swissly

@swissly swissly commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

_repair_tool_call_arguments in agent/message_sanitization.py closed unclosed JSON structures by counting openers: it appended all } then all ]. For nested input like {"a": [1,2 this produced {"a": [1,2}] — invalid JSON — which then fell through to the {} last resort, silently discarding the model's arguments.

Root cause

# BEFORE — append all closers of one kind, then the other
open_curly = fixed.count('{') - fixed.count('}')
open_bracket = fixed.count('[') - fixed.count(']')
if open_curly > 0:  fixed += '}' * open_curly
if open_bracket > 0: fixed += ']' * open_bracket

Counting is order-insensitive. With {"a": [1,2 it appends } first, producing {"a": [1,2}], then json.loads fails → UNREPAIRABLE → {}.

Fix

Track the opening stack and append closers in LIFO order (last opened, first closed), so nested structures repair to valid JSON:

# AFTER
stack = []
for ch in fixed:
    if ch == "{": stack.append("}")
    elif ch == "[": stack.append("]")
    elif ch in ("}", "]"):
        if stack and stack[-1] == ch: stack.pop()
for closer in reversed(stack):
    fixed += closer
  • {"a": [1,2{"a": [1,2]} (was {})
  • {"a": [1, {"b": 2{"a": [1, {"b": 2}]}
  • Balanced JSON is untouched (verified).

Tests

Added 5 regression tests to tests/run_agent/test_repair_tool_call_arguments.py (single brace, single bracket, nested LIFO, nested object-in-array, balanced passthrough). Verified the LIFO regression test fails on the buggy code and passes with the fix.

Validation

  • pytest tests/run_agent/test_repair_tool_call_arguments.py → 8 passed
  • No behavioral change to the repair pipeline's other stages (trailing commas, control-char escape, excess-closer removal, {} last resort).

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 fixes a correctness bug in Hermes’ tool-call argument sanitization pipeline: when repairing truncated JSON, the previous “count openers then append closers” approach could generate invalid JSON for nested structures, causing _repair_tool_call_arguments to fall back to {} and silently discard the model’s intended tool arguments.

Changes:

  • Update _repair_tool_call_arguments to close unclosed {/[ structures using a delimiter stack and append missing closers in LIFO order.
  • Add regression tests covering single-delimiter truncation, nested truncation (the LIFO bug), and balanced JSON passthrough.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
agent/message_sanitization.py Fixes JSON truncation repair by closing missing delimiters in LIFO order to preserve nested validity.
tests/run_agent/test_repair_tool_call_arguments.py Adds regression tests to prevent reintroducing invalid closer ordering and to validate nested repair behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@isak-ialogics

Copy link
Copy Markdown
Contributor

The LIFO stack still treats {/[ characters inside JSON strings as structural delimiters. On this head, _repair_tool_call_arguments('{"a":"[","b":[1,2', "t") still returns {} instead of {"a":"[","b":[1,2]} because the literal [ adds a spurious stack entry. Suggested next action: make the delimiter scan string/escape-aware (track quoted-string state and backslash escapes), and add this case as a regression so nested truncation with delimiter-valued arguments is preserved rather than discarded.

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

swissly commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the sharp catch — confirmed and fixed. The string-blind stack treated {/[ inside quoted string values as structural delimiters, so '{"a":"[","b":[1,2' pushed a spurious ] for the literal [, left the string unterminated, and fell back to {}.

Fix (commit 4a9d49f883): the delimiter scan now tracks quoted-string state and backslash escapes — {/[ inside a string are literal characters, not structure. Closing is still LIFO.

Regression tests added (4):

  • bracket-in-string value ('{"a":"[","b":[1,2'{"a":"[","b":[1,2]})
  • brace-in-string value
  • backslash-escaped quote inside a string (does not close it)
  • delimiters after a closed string

Verified both core cases fail on the string-blind code and pass with the fix; full file 12/12 green.

_repair_tool_call_arguments closed unclosed structures by counting
openers: it appended all '}' then all ']'. For nested input like
'{"a": [1,2' this produced '{"a": [1,2}]' — invalid JSON, which then
fell through to the '{}' last resort, silently discarding the model's
arguments.

Track the opening stack and append closers in LIFO order (last opened,
first closed) so nested structures repair to valid JSON:
'{"a": [1,2' -> '{"a": [1,2]}'.

Found by behavioral testing on the tool-call repair pipeline
(2026-08-03). Applies the same class of fix to all nesting depths;
balanced JSON is untouched.
The LIFO stack treated {/[ inside quoted string values as structural
delimiters. '{"a":"[","b":[1,2' pushed ']' for the literal '[',
leaving the string unterminated and the repair falling back to '{}' —
silently discarding the model's arguments.

The scan now tracks quoted-string state and backslash escapes: delimiters
inside strings are literal characters, not structure. Adds 4 regression
tests (bracket-in-string, brace-in-string, escaped-quote, delimiters
after closed string); the two core cases fail on the string-blind code.
@swissly
swissly force-pushed the fix/agent-lifo-json-close branch from 4a9d49f to 1e61db1 Compare August 12, 2026 14:50
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.
@swissly

swissly commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Ping @teknium1 — this PR is mergeable/clean and ready for review (all review threads addressed, CI green). Open since 2026-08-03.

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 type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants