mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
2972e38bed
commit
cb5b7bc3a7
1 changed files with 214 additions and 4 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue