pub(in crate::agent) const MAX_APPROACH_PIVOTS: usize = 2;
pub(in crate::agent) fn should_pivot_approach(
pivots_used: usize,
tool_attempts: usize,
unrecovered_errors: usize,
total_successful_tool_calls: usize,
) -> bool {
if pivots_used >= MAX_APPROACH_PIVOTS || tool_attempts == 0 {
return false;
}
unrecovered_errors > 0 || total_successful_tool_calls == 0
}
pub(in crate::agent) fn build_failure_record(
attempt_number: usize,
tool_calls: &[String],
errors: &[(String, bool)],
mutation_count: usize,
) -> String {
const MAX_LISTED_CALLS: usize = 10;
const MAX_LISTED_ERRORS: usize = 5;
const MAX_ERROR_LINE_CHARS: usize = 300;
let mut record = format!("Failed approach record (attempt #{attempt_number}):\n");
record.push_str("Tool calls tried:\n");
for call in tool_calls.iter().take(MAX_LISTED_CALLS) {
record.push_str("- ");
record.push_str(crate::utils::truncate_str(call, MAX_ERROR_LINE_CHARS).as_ref());
record.push('\n');
}
if tool_calls.len() > MAX_LISTED_CALLS {
record.push_str(&format!(
"- … and {} more\n",
tool_calls.len() - MAX_LISTED_CALLS
));
}
let mut seen = std::collections::HashSet::new();
let unrecovered: Vec<&str> = errors
.iter()
.filter(|(_, recovered)| !recovered)
.map(|(error, _)| error.as_str())
.filter(|error| seen.insert(*error))
.collect();
if !unrecovered.is_empty() {
record.push_str("Unrecovered errors (verbatim):\n");
for error in unrecovered.iter().take(MAX_LISTED_ERRORS) {
record.push_str("- ");
record.push_str(crate::utils::truncate_str(error, MAX_ERROR_LINE_CHARS).as_ref());
record.push('\n');
}
if unrecovered.len() > MAX_LISTED_ERRORS {
record.push_str(&format!(
"- … and {} more distinct errors\n",
unrecovered.len() - MAX_LISTED_ERRORS
));
}
}
if mutation_count > 0 {
record.push_str(&format!(
"Already performed: {} state-changing action{} — do not blindly repeat them.\n",
mutation_count,
if mutation_count == 1 { "" } else { "s" }
));
}
record
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pivots_when_errors_and_attempts_remain() {
assert!(should_pivot_approach(0, 4, 3, 1));
assert!(should_pivot_approach(1, 4, 3, 1));
assert!(should_pivot_approach(0, 2, 0, 0));
}
#[test]
fn no_pivot_when_exhausted_or_nothing_to_reference() {
assert!(!should_pivot_approach(MAX_APPROACH_PIVOTS, 4, 3, 1));
assert!(!should_pivot_approach(0, 0, 0, 0));
assert!(!should_pivot_approach(0, 6, 0, 6));
}
#[test]
fn failure_record_is_deterministic_and_verbatim() {
let tool_calls = vec![
"terminal(`npm run deploy`)".to_string(),
"terminal(`npm run deploy --force`)".to_string(),
];
let errors = vec![
(
"Error: EACCES permission denied /usr/lib".to_string(),
false,
),
("transient timeout".to_string(), true), (
"Error: EACCES permission denied /usr/lib".to_string(),
false,
), ];
let record = build_failure_record(2, &tool_calls, &errors, 1);
assert!(record.contains("attempt #2"), "record: {record}");
assert!(record.contains("npm run deploy"));
assert!(
record.contains("Error: EACCES permission denied /usr/lib"),
"error lines must be verbatim"
);
assert_eq!(
record.matches("EACCES").count(),
1,
"duplicate errors collapse to one"
);
assert!(
!record.contains("transient timeout"),
"recovered errors are not part of the failure reference"
);
assert!(
record.contains("1 state-changing action"),
"must warn the next approach that the world already changed"
);
assert_eq!(record, build_failure_record(2, &tool_calls, &errors, 1));
}
#[test]
fn failure_record_bounds_runaway_inputs() {
let tool_calls: Vec<String> = (0..50).map(|i| format!("terminal(cmd-{i})")).collect();
let errors: Vec<(String, bool)> = (0..50)
.map(|i| (format!("Error: failure {i} {}", "x".repeat(500)), false))
.collect();
let record = build_failure_record(1, &tool_calls, &errors, 0);
assert!(
record.len() < 4000,
"record must stay compact, got {} chars",
record.len()
);
}
}