fix(agent): make in-band is_error tool-scoped and exit-code-aware - #54
Conversation
Refines the in-band is_error detection from altaidevorg#46 so that non-zero exit codes from exec/python_run are caught, and content tools (read_file) are no longer false-flagged when their payload begins with "Error:". altaidevorg#46 added utils::tool_output_looks_like_failure(text): a text-only "Error:"/"error:" prefix check applied to every tool's *finalized* output at the two reasoning-loop tool-result sites. Two real gaps: 1. Non-zero exit codes are missed. exec/python_run report failure by appending a trailing "Exit code: <N>" line, not by starting with "Error:". A failed `cargo build` (exit 1, output not starting with "Error:") was recorded as a success, so the model built on broken state and the doom-loop detector was blinded. 2. File content is false-flagged. The text-only check ran on every tool, so read_file returning a file/log that legitimately begins with "Error:" was misclassified as a failed tool call. Resolution (builds on altaidevorg#46, non-destructive): - utils: add tool_output_signals_failure(tool_name, raw_output) -- tool-scoped: trailing non-zero "Exit code:" for exec/python_run; "Error:" prefix for exactly the four tools whose whole Ok payload is a control message (edit_file/list_dir/glob_files/search_text, the complete set using that convention). Add tool_call_is_error. Keep tool_output_looks_like_failure (still used by channels/terminal.rs for UI result tinting). - agent: both tool-result sites compute is_error via tool_call_is_error on the RAW result, before finalize_tool_output truncates the tail exit marker away. - builtin: exec appends the non-zero "Exit code:" marker last, after the grep advisory and the 10 KB truncation, so it always survives as the final line for tail-anchored detection. Tests: 8 utils unit tests + 3 exec integration tests.
Independent review (rust + security), posted for transparencyTwo independent reviewer passes were run against Correctness (rust):
Security (signal integrity):
Addressed from review before opening:
Noted pre-existing (out of scope, flagged for follow-up): the |
There was a problem hiding this comment.
Code Review
This pull request improves tool execution failure detection by introducing tool-scoped and exit-aware checks (tool_call_is_error and tool_output_signals_failure), ensuring that non-zero exit codes are reliably detected and content-returning tools are not false-flagged. Feedback was provided regarding a potential panic in src/tools/builtin.rs where slicing a string at a fixed byte limit of 10,000 could violate UTF-8 character boundaries; a safer slicing approach using is_char_boundary was suggested.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if result.len() > 10000 { | ||
| Ok(format!( | ||
| result = format!( | ||
| "{}\n... (truncated, {} more chars)", | ||
| &result[..10000], | ||
| result.len() - 10000 | ||
| )) | ||
| } else { | ||
| Ok(result) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Slicing result directly with &result[..10000] can panic if the index 10000 does not fall on a valid UTF-8 character boundary (e.g., if the output contains multi-byte characters like emojis or non-ASCII text). To prevent potential panics, find the nearest valid character boundary at or below 10000 before slicing.
| if result.len() > 10000 { | |
| Ok(format!( | |
| result = format!( | |
| "{}\n... (truncated, {} more chars)", | |
| &result[..10000], | |
| result.len() - 10000 | |
| )) | |
| } else { | |
| Ok(result) | |
| ); | |
| } | |
| if result.len() > 10000 { | |
| let mut limit = 10000; | |
| while limit > 0 && !result.is_char_boundary(limit) { | |
| limit -= 1; | |
| } | |
| result = format!( | |
| "{}\n... (truncated, {} more chars)", | |
| &result[..limit], | |
| result.len() - limit | |
| ); | |
| } |
Addresses review feedback: the >10 KB exec truncation sliced result at a fixed byte index (&result[..10000]), which panics if byte 10000 falls inside a multi-byte UTF-8 sequence (Turkish text / emoji). Step back to the nearest char boundary at/below the cap, matching the is_char_boundary idiom already used by the other truncation sites in this file. Regression test emits 5000 x 3-byte chars so the cut lands mid-character.
|
Thanks — addressed the UTF-8 boundary panic in |
Resolve the conflict in src/agent/mod.rs: upstream advanced (it has since merged altaidevorg#48/altaidevorg#54/altaidevorg#55), and its now-merged altaidevorg#48 added normalize_command_for_matching at the same location where this branch added append_post_tool_output. The two are unrelated functions, so both are kept. All other changes auto-merged; the full lib test passes (369).
Summary
Refines the in-band
is_errordetection introduced in #46 so that:exec/python_runare detected (they were previously missed unless the output happened to begin withError:).read_fileare no longer false-flagged when their payload legitimately begins withError:.execExit code:marker survives truncation and the grep advisory, so the signal is reliable even on large or grep-like output.Why this overlaps with
main, and how it's resolvedThis change was developed in parallel with #46 (
8d7dafde), which independently added in-bandis_errorviautils::tool_output_looks_like_failure(text)— a text-only check (starts_with "Error:" / "error:") applied to every tool's finalized output at the two reasoning-loop tool-result sites insrc/agent/mod.rs.Because both touch the same call sites and the same helper area in
src/utils.rs, they overlap. Rather than duplicate the mechanism, this PR builds on #46 and closes two real gaps in the text-only heuristic:exec/python_runsignal failure by appending a trailingExit code: <N>line, not by starting withError:.cargo build(exit 1 whose output doesn't start withError:) was recorded as a success — the model builds on broken state, and the signature-based doom-loop detector reasons over a wrong outcome.exec/python_runare flagged when the last non-empty line isExit code: <non-zero>(tail-anchored).read_filereturning a log/file that begins withError:was misclassified as a failed call.is_erroron legitimate reads.Error:prefix rule is tool-scoped to exactly the four tools whose entireOkpayload is a control message:edit_file/list_dir/glob_files/search_text.Exit code:line truncated/pushed off the tail before the check ran.is_erroris computed on the rawOkpayload beforefinalize_tool_output, andexecnow appends the marker last (after the grep advisory and the internal 10 KB cap) so it is always the final line.utils::tool_output_looks_like_failurefrom #46 is kept — it still backschannels/terminal.rsUI result tinting (tool-agnostic, already-finalized text). The newtool_output_signals_failure/tool_call_is_errorare the precise, tool-aware path used for the agent's structuredis_error. The two concerns (UI tinting vs. agent semantics) stay cleanly separated.Why tool-scoping is precise, not arbitrary
The
{edit_file, list_dir, glob_files, search_text}set is the complete list of tools that emit theOk("Error: …")/Ok("Error reading dir: …")prefix convention intools/builtin.rs(verified by enumerating every in-bandOk(...)failure return).read_fileand the other content-returning tools are intentionally excluded because their payload is data, not a control message.Changes
src/utils.rs— addtool_output_signals_failure(tool_name, raw_output)(tool-scoped, exit-code-aware) andtool_call_is_error(tool_name, &Result<String,String>). 9 unit tests covering exit-code detection, tail-anchoring, scoped-prefix matching, theread_filenon-misclassification, exit-code edge cases (zero / garbage / empty), and the documented spoofed-marker residual.src/agent/mod.rs— both tool-result sites computeis_errorviatool_call_is_erroron the raw result, beforefinalize_tool_outputconsumes it.src/tools/builtin.rs—execappends the non-zeroExit code:marker last (survives the advisory + 10 KB truncation); a guard comment onpython_rundocuments the same marker-last invariant. 3 unix integration tests (large failing output, grep-like failing output, success).Verification
cargo test --lib utils::→ 15 passed (9 new).cargo test --lib exec_failure_tests::→ 3 passed.cargo clippy --release --all-targets→ no new warnings on the changed code.Notes / accepted residuals
Exit code: <non-zero>. The harness appends the authoritative marker after all command-controlled bytes, so this can only ever produce a spuriousis_error(at worst a wasted retry) — it can never mask a real failure (the dangerous direction is closed by construction).src/tools/builtin.rstruncatesexecoutput with&result[..10000], a raw byte slice that can panic on a multi-byte UTF-8 boundary. This predates the change (identical tomain; only relocated here) and is left for a separate follow-up that can switch it to the existingtruncate_utf8_safehelper without altering this PR's truncation-notice semantics.