Remove final_output tool and improve scout report handback

final_output removal:
- Remove final_output from tool definitions and dispatch
- Update system prompts to request summaries as regular text
- Remove final_output_called field from StreamingState
- Update auto_continue tests to remove final_output_called parameter
- Remove final_output test from tool_execution_test.rs
- Update planner and flock prompts to not reference final_output
- Keep backwards-compat code in feedback_extraction.rs and task_result.rs

Scout report handback:
- Change from file-based to delimiter-based report extraction
- Scout outputs report between ---SCOUT_REPORT_START/END--- markers
- Research tool extracts content between markers, strips ANSI codes
- Add comprehensive tests for extraction and ANSI stripping

657 tests pass.
This commit is contained in:
Dhanji R. Prasanna
2026-01-10 13:43:04 +11:00
parent cab2fb187a
commit 0aa1287ca6
9 changed files with 247 additions and 95 deletions

View File

@@ -22,7 +22,6 @@ pub struct StreamingState {
pub response_started: bool,
pub any_tool_executed: bool,
pub auto_summary_attempts: usize,
pub final_output_called: bool,
pub turn_accumulated_usage: Option<g3_providers::Usage>,
}
@@ -36,7 +35,6 @@ impl StreamingState {
response_started: false,
any_tool_executed: false,
auto_summary_attempts: 0,
final_output_called: false,
turn_accumulated_usage: None,
}
}

View File

@@ -10,12 +10,16 @@ use crate::ToolCall;
use super::executor::ToolContext;
/// Delimiter markers for scout report extraction
const REPORT_START_MARKER: &str = "---SCOUT_REPORT_START---";
const REPORT_END_MARKER: &str = "---SCOUT_REPORT_END---";
/// Execute the research tool by spawning a scout agent.
///
/// This tool:
/// 1. Spawns `g3 --agent scout` with the query
/// 2. Captures stdout and extracts the last line (file path to report)
/// 3. Reads the report file and returns its contents
/// 2. Captures stdout and extracts the report between delimiter markers
/// 3. Returns the report content directly
pub async fn execute_research<W: UiWriter>(
tool_call: &ToolCall,
ctx: &mut ToolContext<'_, W>,
@@ -46,20 +50,20 @@ pub async fn execute_research<W: UiWriter>(
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn scout agent: {}", e))?;
// Capture stdout to find the report file path
// Capture stdout to find the report content
let stdout = child.stdout.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture scout agent stdout"))?;
let mut reader = BufReader::new(stdout).lines();
let mut last_line = String::new();
let mut all_output = Vec::new();
// Print a header for the scout output
ctx.ui_writer.println("\n📡 Scout agent output:");
ctx.ui_writer.println("\n📡 Scout agent researching...");
// Stream all lines to UI, keeping track of the last one for the report path
// Collect all lines
while let Some(line) = reader.next_line().await? {
ctx.ui_writer.println(&format!(" {}", line));
last_line = line;
all_output.push(line);
}
// Wait for the process to complete
@@ -70,31 +74,167 @@ pub async fn execute_research<W: UiWriter>(
return Ok(format!("❌ Scout agent failed with exit code: {:?}", status.code()));
}
// The last line should be the path to the report file
let report_path = last_line.trim();
// Join all output and extract the report between markers
let full_output = all_output.join("\n");
if report_path.is_empty() {
return Ok("❌ Scout agent did not output a report file path".to_string());
extract_report(&full_output)
}
/// Extract the research report from scout output.
///
/// Looks for content between SCOUT_REPORT_START and SCOUT_REPORT_END markers.
/// Strips ANSI escape codes from the extracted content.
fn extract_report(output: &str) -> Result<String> {
// Strip ANSI codes from the entire output first
let clean_output = strip_ansi_codes(output);
// Find the start marker
let start_pos = clean_output.find(REPORT_START_MARKER)
.ok_or_else(|| anyhow::anyhow!(
"Scout agent did not output a properly formatted report. Expected {} marker.",
REPORT_START_MARKER
))?;
// Find the end marker
let end_pos = clean_output.find(REPORT_END_MARKER)
.ok_or_else(|| anyhow::anyhow!(
"Scout agent report is incomplete. Expected {} marker.",
REPORT_END_MARKER
))?;
if end_pos <= start_pos {
return Err(anyhow::anyhow!("Invalid report format: end marker before start marker"));
}
// Extract content between markers
let report_start = start_pos + REPORT_START_MARKER.len();
let report_content = clean_output[report_start..end_pos].trim();
if report_content.is_empty() {
return Ok("❌ Scout agent returned an empty report.".to_string());
}
Ok(format!("📋 Research Report:\n\n{}", report_content))
}
/// Strip ANSI escape codes from a string.
///
/// Handles common ANSI sequences like:
/// - CSI sequences: \x1b[...m (colors, styles)
/// - OSC sequences: \x1b]...\x07 (terminal titles, etc.)
fn strip_ansi_codes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Start of escape sequence
match chars.peek() {
Some('[') => {
// CSI sequence: \x1b[...X where X is a letter
chars.next(); // consume '['
while let Some(&next) = chars.peek() {
chars.next();
if next.is_ascii_alphabetic() {
break;
}
}
}
Some(']') => {
// OSC sequence: \x1b]...\x07
chars.next(); // consume ']'
while let Some(&next) = chars.peek() {
chars.next();
if next == '\x07' {
break;
}
}
}
_ => {
// Unknown escape, skip just the ESC
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_codes() {
// Simple color code
assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
// RGB color code (like the bug we saw)
assert_eq!(
strip_ansi_codes("\x1b[38;2;216;177;114mtmp/file.md\x1b[0m"),
"tmp/file.md"
);
// Multiple codes
assert_eq!(
strip_ansi_codes("\x1b[1m\x1b[32mbold green\x1b[0m normal"),
"bold green normal"
);
// No codes
assert_eq!(strip_ansi_codes("plain text"), "plain text");
// Empty string
assert_eq!(strip_ansi_codes(""), "");
}
// Expand tilde if present
let expanded_path = if report_path.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
std::path::PathBuf::from(home).join(&report_path[2..]) // Skip "~/"
} else {
std::path::PathBuf::from(report_path)
}
} else {
std::path::PathBuf::from(report_path)
};
#[test]
fn test_extract_report_success() {
let output = r#"Some preamble text
---SCOUT_REPORT_START---
# Research Brief
// Read the report file
match std::fs::read_to_string(&expanded_path) {
Ok(content) => {
Ok(format!("📋 Research Report:\n\n{}", content))
}
Err(e) => {
Ok(format!("❌ Failed to read report file '{}': {}", report_path, e))
}
This is the report content.
---SCOUT_REPORT_END---
Some trailing text"#;
let result = extract_report(output).unwrap();
assert!(result.contains("Research Brief"));
assert!(result.contains("This is the report content."));
assert!(!result.contains("preamble"));
assert!(!result.contains("trailing"));
}
#[test]
fn test_extract_report_with_ansi_codes() {
let output = "\x1b[32m---SCOUT_REPORT_START---\x1b[0m\n# Report\n\x1b[31m---SCOUT_REPORT_END---\x1b[0m";
let result = extract_report(output).unwrap();
assert!(result.contains("# Report"));
}
#[test]
fn test_extract_report_missing_start() {
let output = "No markers here";
let result = extract_report(output);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("SCOUT_REPORT_START"));
}
#[test]
fn test_extract_report_missing_end() {
let output = "---SCOUT_REPORT_START---\nContent but no end";
let result = extract_report(output);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("SCOUT_REPORT_END"));
}
#[test]
fn test_extract_report_empty_content() {
let output = "---SCOUT_REPORT_START---\n---SCOUT_REPORT_END---";
let result = extract_report(output).unwrap();
assert!(result.contains("empty report"));
}
}