fix(execute_code): tolerate tool payloads with a trailing hint - #74100
fix(execute_code): tolerate tool payloads with a trailing hint#74100israellot wants to merge 2 commits into
Conversation
search_files appends "[Hint: Results truncated. Use offset=N ...]" after
its JSON object whenever results are truncated (tools/file_tools.py:1928).
The generated hermes_tools shim parsed responses with a strict
json.loads(), so any truncated search inside execute_code raised
JSONDecodeError("Extra data") even though the tool call itself succeeded
— the failure looked like the wrapper "choking on output".
Add a shared _parse_tool_result() to the sandbox shim's common helpers and
use it from both the UDS and file transports (the file transport had the
same latent bug). It raw_decode()s the first JSON value and keeps any
trailing text under "_hint" instead of discarding it. Truly malformed
payloads still raise.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused fix. The premise is confirmed on current main: tools/file_tools.py:1914-1920 appends the truncation hint after JSON, while generated execute-code stubs strictly parse it at tools/code_execution_tool.py:493-500 and :560-566.
Problems
- In the proposed
_parse_tool_result()(tools/code_execution_tool.py, PR diff), a double-encoded response with an outer trailing hint loses that hint: theisinstance(result, str)branch recursively returns the decoded inner payload and discards the already-captured outertrailingtext. Please preserve the suffix through that unwrap and coverjson.dumps(json.dumps({"ok": true})) + "\n\n[Hint: ...]". TestGeneratedToolResultParsing.test_both_transports_define_the_parserchecks generated source substrings.AGENTS.mdprohibits source-shape tests; the surroundingexec()tests can verify both generated transports behaviorally instead.
Suggested changes
- Carry outer trailing text into the final decoded dictionary after double-encoded unwrapping.
- Delete the source-inspection test and retain/add behavioral regression coverage.
Automated hermes-sweeper review.
| return _parse_tool_result(result) | ||
| except (json.JSONDecodeError, TypeError): | ||
| return result | ||
| if trailing and isinstance(result, dict) and "_hint" not in result: |
There was a problem hiding this comment.
This recursive return drops trailing captured from the outer response. Please preserve it when an outer double-encoded JSON string has a suffix, and add a regression case for that combination.
There was a problem hiding this comment.
Good catch — fixed in adc79ba.
The recursion discarded the outer trailing because it returned _parse_tool_result(result) directly, so a hint sitting after the outer JSON string was lost. The unwrap is now iterative and carries the suffix through it:
result, trailing = _decode(text)
while isinstance(result, str):
try:
inner, inner_trailing = _decode(result.strip())
except (json.JSONDecodeError, TypeError):
break
result = inner
trailing = inner_trailing or trailingAn inner hint wins when both levels carry one (it is the more specific attachment); otherwise the outer suffix survives. tools/code_execution_tool.py:436-455.
Two regression tests cover both placements — test_double_encoded_payload_keeps_outer_trailing_hint (tests/tools/test_code_execution.py:189) and test_double_encoded_payload_keeps_inner_trailing_hint (:203). Red-before-green on the outer case, with the source change stashed and the tests left in place:
$ git stash push -- tools/code_execution_tool.py
$ python -m pytest tests/tools/test_code_execution.py -q -k TestGeneratedToolResultParsing
8 passed, 1 failed
tests/tools/test_code_execution.py:201: in test_double_encoded_payload_keeps_outer_trailing_hint
self.assertEqual(result["_hint"], hint)
E KeyError: '_hint'
Green with the fix: scripts/run_tests.sh tests/tools/test_code_execution.py -q → 85 tests passed, 0 failed.
| src = generate_hermes_tools_module(["search_files"], transport=transport) | ||
| ns = {} | ||
| exec(compile(src, "hermes_tools", "exec"), ns) | ||
| self.assertIn("_parse_tool_result", ns) |
There was a problem hiding this comment.
Please avoid generated-source substring assertions here. The exec()-based tests below can exercise both transport parsers directly and comply with the repository's behavioral-test rule.
There was a problem hiding this comment.
Agreed — that assertion tested the generator's source text rather than behavior, and assertNotIn("result = json.loads(raw)", src) would have gone green on a rename while the bug came back. Removed in adc79ba.
The behavioral coverage it was standing in for is now real: every case in TestGeneratedToolResultParsing (tests/tools/test_code_execution.py:143) loops over ("uds", "file") and exercises the parser compiled out of the generated module, so both transports are asserted by execution rather than by substring. That closes the gap the source assertion was hedging against — a transport regressing to a strict json.loads now fails test_trailing_hint_is_preserved_not_raised on that transport.
Cases in the class, all per-transport: plain object roundtrip, trailing hint preserved, existing _hint not clobbered, double-encoded unwrap, double-encoded with an outer trailing hint, double-encoded with an inner trailing hint, non-JSON trailing on a JSON list, plain string payload returned as-is, and truly malformed input still raising JSONDecodeError.
scripts/run_tests.sh tests/tools/test_code_execution.py -q → 85 tests passed, 0 failed.
…coded payload Review feedback on NousResearch#74100: - The recursive unwrap for doubly-encoded payloads discarded any suffix captured at the outer level, so a hint after the outer JSON string was silently lost. Decode iteratively and carry the suffix through the unwrap, letting an inner hint win when both levels have one. - Drop the source-substring assertion in favour of behavioral coverage: every parser case now runs against both the uds and file transports, plus regression cases for outer- and inner-level trailing hints.
|
Both review findings addressed in adc79ba. 1. Recursive unwrap dropped the outer trailing hint ( 2. Source-substring assertion replaced with behavioral coverage ( Verification Red-before-green on the outer-hint case, source change stashed and tests left in place: Green with the fix: Two failures unrelated to this diff appear when the file is run in a single process under my local environment ( |
Interlock with #82243#82243 expands The fixes are complementary and touch the same template. #82243 should JSON-encode strings at the server boundary; this PR should retain ownership of tolerant client parsing for JSON plus trailing prose/double encoding. Please preserve the updated parser when rebasing either branch. #81622 owns the separate concatenated-frame case. |
|
Agreed on the split, and confirmed against both diffs. This PR keeps ownership of tolerant client-side parsing. The generated #82243 owning the server-boundary JSON encoding of strings is the right half. The two are complementary: the client stays tolerant of JSON with trailing prose and double encoding regardless, which is what keeps the in-tree deferrable A2A handlers working when they return plain or multiline text. On the rebase interlock: we overlap on exactly two files, Either way I will keep watch on the parser hunk and re-run the red-before-green check after any rebase. #81622 owning the separate concatenated-frame case matches my read of the three diffs. |
What & why
The
hermes_toolsshim generated forexecute_codesandboxes parses every RPC tool result with a strictjson.loads():Several tools append human-readable text after the JSON payload.
search_filesappends a truncation hint (tools/file_tools.py:1928):json.loads()raisesJSONDecodeError: Extra dataon that, so the sandbox call fails even though the tool call itself succeeded and the data is present. From insideexecute_codethe failure looks like the wrapper choking on large output, which sends you looking in the wrong place — the trigger is the appended hint, not the size.Both transports had it; only the UDS path is commonly hit today, so the file-transport (remote backend) case was latent.
Reachability today, and why this gets worse soon
The hint currently only fires when
context > 0. Withcontext=0thetruncatedflag can never becomeTrue, becausefetch_limitislimit + offsetwhile the flag is computed astotal > offset + limit(tools/file_operations.py:2293,:2421) — the extra row that would prove truncation is never fetched. Measured on currentmain:json.loads()limit=3, context=0limit=3, context=2JSONDecodeError: Extra datalimit=100, context=0#41439 (open, approved) fixes that off-by-one with an n+1 sentinel so
truncatedreports correctly in the defaultcontext=0path. Once it merges, every truncatedsearch_filesinsideexecute_codestarts raising — this fix removes that latent regression ahead of it.Fix
Add a shared
_parse_tool_result()to the shim's_COMMON_HELPERSand use it from both transports. Itraw_decode()s the first JSON value and preserves any trailing text under_hintrather than discarding it (_hintis only set when absent, so a tool's own key is never clobbered). The existing double-encoded-payload unwrap is kept, and genuinely malformed payloads still raiseJSONDecodeError.This is the same bug class and same fix shape as #33930, which handles the appended
[Tool loop warning: ...]suffix intools/delegate_tool.py. That PR fixes the delegate surface; this one fixes the sandbox shim. They touch different files and do not conflict.How to test
Automated (CI-parity wrapper):
150 passed. The 7 new tests in
TestGeneratedToolResultParsingexec()the generated shim and call the parser for real, rather than asserting on substrings of the generated source: plain payloads round-trip, a trailing hint is preserved under_hintinstead of raising, an existing_hintis not clobbered, double-encoded payloads still unwrap, JSON arrays with trailing text work, and malformed input still raises.Manual reproduction of the underlying failure on unpatched
main:End to end, through a real sandbox (
execute_coderunningsearch_files(..., limit=3, context=2)against that file). Before:After:
Windows footguns (change touches file I/O paths in the generated stub):
python scripts/check-windows-footguns.py tools/code_execution_tool.py tests/tools/test_code_execution.py # ✓ No Windows footguns found (2 file(s) scanned).Platforms tested
Linux (Ubuntu, Python 3.12) fully verified — full suite plus the manual repro above. Windows and macOS not manually tested; the change is pure stdlib
jsonstring parsing with no OS-dependent behaviour, andcheck-windows-footguns.pypasses on both touched files.Related
json.loads→raw_decodebug class for the[Tool loop warning: ...]suffix indelegate_tool.py. Different file, complementary; no conflict.search_filestruncation off-by-one. Independent, but merging it widens this bug's blast radius from acontext>0edge case to the default path, so landing this first (or together) avoids a regression.web_tools.py,browser_tool.py,agent/subdirectory_hints.py) append similar footers and would be addressed by that design change, not here.