From 7709d0a01c1b1e60c2e916ca0071fb61fb447bc6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Feb 2026 14:39:10 -0500 Subject: [PATCH] Improve summary:medium and summary:low preamble rendering summary:medium now uses compact handler-specific rendering (script command/stdout/stderr, codergen model/files) instead of dumping raw context_updates. summary:low adds minimal handler info (type, script command, or model name) to each stage line. Co-Authored-By: Claude Opus 4.6 --- crates/attractor/src/preamble.rs | 285 ++++++++++++++++++++++---- crates/attractor/tests/integration.rs | 5 +- 2 files changed, 253 insertions(+), 37 deletions(-) diff --git a/crates/attractor/src/preamble.rs b/crates/attractor/src/preamble.rs index 0ad9d39f3..98c9a682b 100644 --- a/crates/attractor/src/preamble.rs +++ b/crates/attractor/src/preamble.rs @@ -405,17 +405,11 @@ fn build_summary_preamble( append_filtered_context_table(&mut parts, context, &all_rendered_keys); } - _ => { - // Low and Medium share the same structure as before + SummaryDetail::Medium => { let stage_count = completed_nodes.len(); parts.push(format!("Completed {stage_count} stage(s) so far.")); - let recent_count = match detail { - SummaryDetail::Low => 2, - SummaryDetail::Medium => 5, - SummaryDetail::High => unreachable!(), - }; - + let recent_count = 5; let stages_to_show: Vec<&String> = if stage_count > recent_count { let skipped = stage_count - recent_count; parts.push(format!("\n({skipped} earlier stage(s) omitted)")); @@ -438,31 +432,83 @@ fn build_summary_preamble( } parts.push(line); - // For medium detail, include context updates from the outcome - if matches!(detail, SummaryDetail::Medium) - && !outcome.context_updates.is_empty() - { - let mut update_keys: Vec<&String> = - outcome.context_updates.keys().collect(); - update_keys.sort(); - for key in update_keys { - if let Some(val) = outcome.context_updates.get(key) { - parts.push(format!( - " - set {key} = {}", - format_value(val) - )); - } - } - } + let node = graph.nodes.get(*node_id); + let details = + render_compact_stage_details(node_id, node, outcome); + parts.extend(details); + + all_rendered_keys + .extend(stage_rendered_keys(node_id, outcome)); } else { parts.push(format!("- {node_id}: completed")); } } } - // Include context values for medium only (low excludes them) - if matches!(detail, SummaryDetail::Medium) { - append_filtered_context(&mut parts, context, &all_rendered_keys); + append_filtered_context(&mut parts, context, &all_rendered_keys); + } + SummaryDetail::Low => { + let stage_count = completed_nodes.len(); + parts.push(format!("Completed {stage_count} stage(s) so far.")); + + let recent_count = 2; + let stages_to_show: Vec<&String> = if stage_count > recent_count { + let skipped = stage_count - recent_count; + parts.push(format!("\n({skipped} earlier stage(s) omitted)")); + completed_nodes.iter().skip(skipped).collect() + } else { + completed_nodes.iter().collect() + }; + + if !stages_to_show.is_empty() { + parts.push(String::from("\nRecent stages:")); + for node_id in &stages_to_show { + if let Some(outcome) = node_outcomes.get(*node_id) { + let status = outcome.status.to_string(); + let mut line = format!("- {node_id}: {status}"); + if let Some(notes) = outcome.notes.as_deref() { + line.push_str(&format!(" ({notes})")); + } + if let Some(reason) = outcome.failure_reason.as_deref() { + line.push_str(&format!(" [reason: {reason}]")); + } + parts.push(line); + + let node = graph.nodes.get(*node_id); + let handler = + node.and_then(|n| n.handler_type()); + if let Some(h) = handler { + parts.push(format!(" - Handler: {h}")); + } + match handler { + Some("script") => { + if let Some(n) = node { + if let Some(cmd) = n + .attrs + .get("script") + .or_else(|| n.attrs.get("tool_command")) + .and_then(|v| v.as_str()) + { + parts.push(format!( + " - Script: `{cmd}`" + )); + } + } + } + Some("codergen") => { + if let Some(usage) = &outcome.usage { + parts.push(format!( + " - Model: {}", + usage.model + )); + } + } + _ => {} + } + } else { + parts.push(format!("- {node_id}: completed")); + } + } } } } @@ -917,7 +963,14 @@ mod tests { #[test] fn build_preamble_summary_low_shows_only_recent_stages() { - let graph = Graph::new("test"); + let mut graph = Graph::new("test"); + let mut step3 = Node::new("step3"); + step3.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + graph.nodes.insert("step3".to_string(), step3); + let context = Context::new(); let completed_nodes = vec![ "step1".to_string(), @@ -945,6 +998,101 @@ mod tests { assert!(preamble.contains("step3"), "should show recent stage"); assert!(preamble.contains("step4"), "should show most recent stage"); assert!(preamble.contains("omitted"), "should indicate omitted stages"); + // Handler type should appear for nodes with known handlers + assert!( + preamble.contains("Handler: script"), + "should show handler type for step3" + ); + } + + #[test] + fn summary_low_script_stage_shows_handler_and_command() { + let mut graph = Graph::new("test"); + let mut run_tests = Node::new("run_tests"); + run_tests.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + run_tests.attrs.insert( + "script".to_string(), + AttrValue::String("cargo test".to_string()), + ); + graph.nodes.insert("run_tests".to_string(), run_tests); + + let context = Context::new(); + let completed_nodes = vec!["run_tests".to_string()]; + let mut node_outcomes: HashMap = HashMap::new(); + let mut outcome = Outcome::fail("exit code 1"); + outcome.context_updates.insert( + "script.output".to_string(), + serde_json::json!("test failed"), + ); + node_outcomes.insert("run_tests".to_string(), outcome); + + let preamble = build_preamble( + "summary:low", + &context, + &graph, + &completed_nodes, + &node_outcomes, + ); + + assert!( + preamble.contains("Handler: script"), + "should show handler type" + ); + assert!( + preamble.contains("Script: `cargo test`"), + "should show script command" + ); + // Low mode should NOT include stdout/stderr + assert!( + !preamble.contains("Stdout:"), + "should not show stdout in low mode" + ); + } + + #[test] + fn summary_low_codergen_stage_shows_handler_and_model() { + let mut graph = Graph::new("test"); + let mut report = Node::new("report"); + report.attrs.insert( + "shape".to_string(), + AttrValue::String("box".to_string()), + ); + graph.nodes.insert("report".to_string(), report); + + let context = Context::new(); + let completed_nodes = vec!["report".to_string()]; + let mut node_outcomes: HashMap = HashMap::new(); + let mut outcome = Outcome::success(); + outcome.usage = Some(StageUsage { + model: "claude-sonnet-4-20250514".to_string(), + input_tokens: 1000, + output_tokens: 200, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + cost: None, + }); + node_outcomes.insert("report".to_string(), outcome); + + let preamble = build_preamble( + "summary:low", + &context, + &graph, + &completed_nodes, + &node_outcomes, + ); + + assert!( + preamble.contains("Handler: codergen"), + "should show handler type" + ); + assert!( + preamble.contains("Model: claude-sonnet-4-20250514"), + "should show model name" + ); } #[test] @@ -1033,16 +1181,31 @@ mod tests { } #[test] - fn build_preamble_summary_medium_includes_context_updates() { - let graph = Graph::new("test"); + fn build_preamble_summary_medium_uses_compact_handler_details() { + let mut graph = Graph::new("test"); + let mut run_tests = Node::new("run_tests"); + run_tests.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + run_tests.attrs.insert( + "script".to_string(), + AttrValue::String("make test".to_string()), + ); + graph.nodes.insert("run_tests".to_string(), run_tests); + let context = Context::new(); - let completed_nodes = vec!["work".to_string()]; + let completed_nodes = vec!["run_tests".to_string()]; let mut node_outcomes: HashMap = HashMap::new(); let mut outcome = Outcome::success(); + outcome.context_updates.insert( + "script.output".to_string(), + serde_json::json!("All tests passed\n"), + ); outcome .context_updates - .insert("result.score".to_string(), serde_json::json!(95)); - node_outcomes.insert("work".to_string(), outcome); + .insert("script.stderr".to_string(), serde_json::json!("")); + node_outcomes.insert("run_tests".to_string(), outcome); let preamble = build_preamble( "summary:medium", @@ -1053,8 +1216,60 @@ mod tests { ); assert!( - preamble.contains("result.score"), - "should include context updates from outcomes" + preamble.contains("Script: `make test`"), + "should show script command via compact renderer" + ); + assert!( + preamble.contains("All tests passed"), + "should show stdout via compact renderer" + ); + assert!( + !preamble.contains("set script.output"), + "should not dump raw context updates" + ); + } + + #[test] + fn summary_medium_codergen_stage_shows_compact_details() { + let mut graph = Graph::new("test"); + let mut report = Node::new("report"); + report.attrs.insert( + "shape".to_string(), + AttrValue::String("box".to_string()), + ); + graph.nodes.insert("report".to_string(), report); + + let context = Context::new(); + let completed_nodes = vec!["report".to_string()]; + let mut node_outcomes: HashMap = HashMap::new(); + let mut outcome = Outcome::success(); + outcome.usage = Some(StageUsage { + model: "claude-sonnet-4-20250514".to_string(), + input_tokens: 1500, + output_tokens: 300, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + cost: None, + }); + outcome.files_touched = vec!["src/lib.rs".to_string()]; + node_outcomes.insert("report".to_string(), outcome); + + let preamble = build_preamble( + "summary:medium", + &context, + &graph, + &completed_nodes, + &node_outcomes, + ); + + assert!( + preamble.contains("claude-sonnet-4-20250514"), + "should show model name" + ); + assert!( + preamble.contains("src/lib.rs"), + "should show files touched" ); } diff --git a/crates/attractor/tests/integration.rs b/crates/attractor/tests/integration.rs index 75e4a2ab7..96df17c35 100644 --- a/crates/attractor/tests/integration.rs +++ b/crates/attractor/tests/integration.rs @@ -6784,10 +6784,11 @@ async fn fidelity_prompt_summary_low() { async fn fidelity_prompt_summary_medium() { let prompt = run_fidelity_prompt_pipeline("summary:medium").await; - // summary:medium includes goal, stages, and context values + // summary:medium includes goal, stages, and compact handler details assert!(prompt.contains("Validate the build"), "summary:medium: should contain goal"); assert!(prompt.contains("run_tests"), "summary:medium: should mention run_tests"); - assert!(prompt.contains("## Context"), "summary:medium: should include context section"); + assert!(prompt.contains("Script:"), "summary:medium: should show script sub-item for run_tests"); + assert!(prompt.contains("Stdout:"), "summary:medium: should show stdout sub-item for run_tests"); // Original prompt at the end assert!(