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");