From cb5b7bc3a7c250f3a3f81295b89cd7cf2293f45b Mon Sep 17 00:00:00 2001 From: "brynary-fabro[bot]" <265161896+brynary-fabro[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:18:13 -0400 Subject: [PATCH] Limit command stdout/stderr to last N lines in preamble (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
Ran 10 stages in 18m 4s for $3.45 | 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** |
Ran ImplementAndSimplify.fabro (13 nodes and 16 edges) ```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 } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- lib/crates/fabro-workflows/src/preamble.rs | 218 ++++++++++++++++++++- 1 file changed, 214 insertions(+), 4 deletions(-) diff --git a/lib/crates/fabro-workflows/src/preamble.rs b/lib/crates/fabro-workflows/src/preamble.rs index 7ad8796ad..8a4661f64 100644 --- a/lib/crates/fabro-workflows/src/preamble.rs +++ b/lib/crates/fabro-workflows/src/preamble.rs @@ -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 { @@ -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 = 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::>() + .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 = 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::>() + .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 = 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");