Limit command stdout/stderr to last N lines in preamble (#16)

This PR limits command stdout/stderr output in the preamble to the last
N lines, reducing token waste from verbose build progress and download
noise. Command nodes like `cargo check` or `cargo clippy` can produce
300+ lines of output, but the useful content (errors, summaries) is
almost always at the tail. Compact and summary:medium fidelity levels
now keep the last 25 lines, while summary:high keeps the last 50 lines.
Truncated output displays an `(N lines omitted)` indicator consistent
with the existing omission pattern used for stages.

The implementation adds a `tail_lines` helper that skips leading lines
beyond the limit, prepends an omission notice, and applies the
appropriate indentation to each retained line. This is applied only to
inline stdout/stderr rendering—artifact pointer branches and other
fidelity levels (summary:low, truncate, full) are intentionally
untouched. The PR includes unit tests for the `tail_lines` helper itself
as well as integration tests verifying truncation behavior at compact
and summary:high fidelity, and confirming artifact pointers remain
untruncated.

### Fabro Details

<details>
<summary>Ran 10 stages in 18m 4s for $3.45</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $0.84 | 0 |
| simplify_opus | 0s | $0.95 | 0 |
| simplify_gemini | 0s | $0.81 | 0 |
| simplify_gpt | 0s | $0.85 | 0 |
| verify | 0s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **18m 4s** | **$3.45** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (13 nodes and 16
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gemini   [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"]
    simplify_gpt      [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", goal_gate=true, max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
brynary-fabro[bot] 2026-03-15 23:18:13 -04:00 • committed by GitHub
parent 2972e38bed
commit cb5b7bc3a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -6,6 +6,9 @@ use crate::context::Context;
use crate::graph::{is_llm_handler_type, Graph, Node};
use crate::outcome::Outcome;
const COMPACT_OUTPUT_MAX_LINES: usize = 25;
const SUMMARY_HIGH_OUTPUT_MAX_LINES: usize = 50;
/// Build a fidelity-appropriate preamble string for non-full context modes.
///
/// The preamble provides prior conversation context to the next LLM session,
@ -121,6 +124,26 @@ fn format_token_count(tokens: i64) -> String {
}
}
fn tail_lines(text: &str, max_lines: usize, indent: &str) -> String {
use std::fmt::Write;
let total = text.lines().count();
let omitted = total.saturating_sub(max_lines);
let mut out = String::new();
if omitted > 0 {
let _ = write!(out, "{indent}({omitted} lines omitted)");
}
for line in text.lines().skip(omitted) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(indent);
out.push_str(line);
}
out
}
/// Returns the set of context keys that are rendered inline under a stage's
/// handler-specific details, so they can be skipped in the trailing context section.
fn stage_rendered_keys(node_id: &str, outcome: &Outcome) -> HashSet<String> {
@ -164,7 +187,7 @@ fn render_compact_stage_details(
} else {
lines.push(" - Stdout:".to_string());
lines.push(" ```".to_string());
lines.push(format!(" {}", stdout.trim()));
lines.push(tail_lines(stdout.trim(), COMPACT_OUTPUT_MAX_LINES, " "));
lines.push(" ```".to_string());
}
}
@ -175,7 +198,7 @@ fn render_compact_stage_details(
} else {
lines.push(" - Stderr:".to_string());
lines.push(" ```".to_string());
lines.push(format!(" {}", stderr.trim()));
lines.push(tail_lines(stderr.trim(), COMPACT_OUTPUT_MAX_LINES, " "));
lines.push(" ```".to_string());
}
}
@ -237,7 +260,11 @@ fn render_summary_high_stage_section(
} else {
lines.push("- Stdout:".to_string());
lines.push(" ```".to_string());
lines.push(format!(" {}", stdout.trim()));
lines.push(tail_lines(
stdout.trim(),
SUMMARY_HIGH_OUTPUT_MAX_LINES,
" ",
));
lines.push(" ```".to_string());
}
}
@ -252,7 +279,11 @@ fn render_summary_high_stage_section(
} else {
lines.push("- Stderr:".to_string());
lines.push(" ```".to_string());
lines.push(format!(" {}", stderr.trim()));
lines.push(tail_lines(
stderr.trim(),
SUMMARY_HIGH_OUTPUT_MAX_LINES,
" ",
));
lines.push(" ```".to_string());
}
}
@ -2078,6 +2109,185 @@ mod tests {
);
}
// --- tail_lines ---
#[test]
fn tail_lines_returns_full_text_when_under_limit() {
let text = "line1\nline2\nline3";
let result = tail_lines(text, 5, "");
assert_eq!(result, text);
}
#[test]
fn tail_lines_returns_full_text_at_exact_limit() {
let text = "line1\nline2\nline3";
let result = tail_lines(text, 3, "");
assert_eq!(result, text);
}
#[test]
fn tail_lines_truncates_and_shows_omission() {
let text = "line1\nline2\nline3\nline4\nline5";
let result = tail_lines(text, 2, "");
assert_eq!(result, "(3 lines omitted)\nline4\nline5");
assert!(!result.contains("line1"));
assert!(!result.contains("line2"));
assert!(!result.contains("line3"));
}
#[test]
fn tail_lines_applies_indent_to_each_line() {
let result = tail_lines("a\nb\nc", 5, " ");
assert_eq!(result, " a\n b\n c");
}
#[test]
fn tail_lines_truncates_with_indent() {
let result = tail_lines("a\nb\nc\nd\ne", 2, ">> ");
assert_eq!(result, ">> (3 lines omitted)\n>> d\n>> e");
}
#[test]
fn compact_command_stage_truncates_long_stdout() {
let mut graph = Graph::new("test");
let mut build = Node::new("build");
build.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
build.attrs.insert(
"script".to_string(),
AttrValue::String("cargo check".to_string()),
);
graph.nodes.insert("build".to_string(), build);
let context = Context::new();
let completed_nodes = vec!["build".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
// Generate >25 lines of stdout
let long_stdout: String = (1..=30)
.map(|i| format!("output line {i}"))
.collect::<Vec<_>>()
.join("\n");
outcome.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!(long_stdout),
);
node_outcomes.insert("build".to_string(), outcome);
let preamble = build_preamble(
keys::Fidelity::Compact,
&context,
&graph,
&completed_nodes,
&node_outcomes,
);
assert!(
preamble.contains("(5 lines omitted)"),
"should show omission indicator for long stdout, got:\n{preamble}"
);
assert!(
preamble.contains("output line 30"),
"should keep last lines"
);
assert!(
!preamble.contains("output line 1\n"),
"should drop early lines"
);
}
#[test]
fn summary_high_command_stage_truncates_long_stdout() {
let mut graph = Graph::new("test");
let mut build = Node::new("build");
build.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
build.attrs.insert(
"script".to_string(),
AttrValue::String("cargo check".to_string()),
);
graph.nodes.insert("build".to_string(), build);
let context = Context::new();
let completed_nodes = vec!["build".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
// Generate >50 lines of stdout
let long_stdout: String = (1..=60)
.map(|i| format!("output line {i}"))
.collect::<Vec<_>>()
.join("\n");
outcome.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!(long_stdout),
);
node_outcomes.insert("build".to_string(), outcome);
let preamble = build_preamble(
keys::Fidelity::SummaryHigh,
&context,
&graph,
&completed_nodes,
&node_outcomes,
);
assert!(
preamble.contains("(10 lines omitted)"),
"should show omission indicator for long stdout, got:\n{preamble}"
);
assert!(
preamble.contains("output line 60"),
"should keep last lines"
);
assert!(
!preamble.contains("output line 1\n"),
"should drop early lines"
);
}
#[test]
fn summary_high_artifact_stdout_not_truncated() {
let mut graph = Graph::new("test");
let mut build = Node::new("build");
build.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
graph.nodes.insert("build".to_string(), build);
let context = Context::new();
let completed_nodes = vec!["build".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
// Artifact pointer — should NOT be truncated
outcome.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!("file:///tmp/artifacts/stdout.txt"),
);
node_outcomes.insert("build".to_string(), outcome);
let preamble = build_preamble(
keys::Fidelity::SummaryHigh,
&context,
&graph,
&completed_nodes,
&node_outcomes,
);
assert!(
!preamble.contains("lines omitted"),
"artifact pointers should not be truncated, got:\n{preamble}"
);
assert!(
preamble.contains("/tmp/artifacts/stdout.txt"),
"should show artifact path"
);
}
#[test]
fn build_preamble_no_parent_preamble_when_absent() {
let graph = Graph::new("test");