Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions crates/tinytools/src/command_output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@

use crate::ToolResult;

/// Hint appended after the exit code for the exit statuses that almost always
/// mean "this exact command cannot succeed on retry here". Empty for every other
/// code so an ordinary application failure is never editorialised.
/// Hint appended after the exit code for the exit statuses whose meaning is a
/// shell convention rather than an application's own choice, so the agent can
/// tell "cannot succeed on retry" from "did not actually fail" without
/// guessing. Empty for every other code: an ordinary application failure is
/// never editorialised.
///
/// The `128 + N` codes matter because a host that wraps commands in
/// `set -o pipefail` reports the *pipeline's* signal death, so the most common
/// idiom in an agent's toolkit -- piping a large output into an early-closing
/// reader like `head` -- arrives as a failed command with a bare `141` and no
/// indication that the requested output was in fact delivered in full.
fn exit_code_hint(code: i32) -> &'static str {
match code {
127 => {
Expand All @@ -42,6 +50,31 @@ fn exit_code_hint(code: i32) -> &'static str {
restriction. This will not succeed on retry — report the blocker \
or request escalation instead of repeating the command"
}
141 => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high critique confident

Do not infer signals from exit codes alone

render_command_failure receives only an integer exit code, so it cannot distinguish a shell's 128 + signal result from an application that deliberately exits with the same value. For example, sh -c 'exit 141' reaches this arm without any SIGPIPE or early-closing reader, but the rendered message tells the agent to treat the output as usable rather than treating the command as failed. The same ambiguity applies to the newly added 137, 139, and 143 arms. Only emit these hints when the execution result preserves signal provenance, or remove the signal-specific hints from this formatter.

[RULE] ambiguous-exit-status ·

" — SIGPIPE: a reader closed the pipe before the writer finished, \
which is what `| head`, `| grep -q` and `| sed -n '1,Np'` do \
once they have what they asked for. Under `set -o pipefail` \
that surfaces as a failed pipeline. The output above is \
whatever the reader accepted, and is usually complete for what \
was requested — treat it as data, not as an error, and only \
re-run without the early-closing reader if you need the rest"
}
137 => {
" — SIGKILL: the process was killed rather than exiting, most often \
by the out-of-memory killer or a hard timeout. Retrying the \
same command unchanged will be killed again; reduce what it \
holds in memory, process the input in parts, or raise the limit"
}
143 => {
" — SIGTERM: the process was asked to stop before it finished, \
usually by a timeout or a shutdown. Any output above is \
partial. Re-run with a longer timeout or less work per call"
}
139 => {
" — SIGSEGV: the process crashed. This is a fault in the program or \
its input, not in how it was invoked, so the same command will \
crash again — change the input or use a different tool"
}
_ => "",
}
}
Expand Down
65 changes: 65 additions & 0 deletions crates/tinytools/src/command_output/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,68 @@ fn sandbox_negative_exit_code_maps_to_signal() {
assert_eq!(sandbox_exit_code(0), Some(0));
assert_eq!(sandbox_exit_code(7), Some(7));
}

/// A host that wraps commands in `set -o pipefail` turns the commonest idiom
/// in an agent's toolkit -- a large output piped into an early-closing reader
/// -- into a failed command. The code alone does not say the requested output
/// arrived, so the hint has to.
#[test]
fn exit_141_explains_that_sigpipe_is_usually_not_a_failure() {
let rendered = render_command_failure(Some(141), "first-five-lines", "");
assert!(rendered.contains("exit code 141"));
assert!(rendered.contains("SIGPIPE"));
assert!(
rendered.contains("head"),
"141 should name the idiom that causes it: {rendered}"
);
assert!(
rendered.contains("treat it as data, not as an error"),
"141 should say the output is usable: {rendered}"
);
assert!(
rendered.contains("first-five-lines"),
"the accepted output must still be shown: {rendered}"
);
}

/// 137 and 143 are the two ways a long agent run loses a process to the host
/// rather than to its own exit, and they need opposite responses from 141:
/// the output is gone or partial, and an unchanged retry repeats the kill.
#[test]
fn killed_and_terminated_codes_hint_at_the_host_not_the_command() {
let killed = render_command_failure(Some(137), "", "");
assert!(killed.contains("SIGKILL"));
assert!(
killed.contains("out-of-memory") && killed.contains("timeout"),
"137 should name both usual causes: {killed}"
);
let terminated = render_command_failure(Some(143), "half-the-output", "");
assert!(terminated.contains("SIGTERM"));
assert!(
terminated.contains("partial"),
"143 should warn the output is incomplete: {terminated}"
);
}

#[test]
fn exit_139_hints_a_crash_rather_than_a_bad_invocation() {
let rendered = render_command_failure(Some(139), "", "");
assert!(rendered.contains("SIGSEGV"));
assert!(
rendered.contains("not in how it was invoked"),
"139 should steer away from re-tuning the flags: {rendered}"
);
}

/// The signal hints must not leak into an ordinary application failure, and a
/// code that merely looks adjacent (140, 142, 138) is not a convention.
#[test]
fn adjacent_signal_codes_are_not_editorialised() {
for code in [138, 140, 142, 2] {
let rendered = render_command_failure(Some(code), "", "boom");
assert!(
!rendered.contains("SIG"),
"exit {code} is not a known convention: {rendered}"
);
}
}
Loading