Files
tkmind_go/crates/goose/src/agents/retry.rs
T
Rodolfo Olivieri adc0e221fc feat: gate telemetry behind Cargo feature flags (#8119)
Signed-off-by: Rodolfo Olivieri <rolivier@redhat.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
2026-03-26 16:33:43 +00:00

510 lines
16 KiB
Rust

use anyhow::Result;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use crate::subprocess::SubprocessExt;
use crate::agents::types::SessionConfig;
use crate::agents::types::{
RetryConfig, SuccessCheck, DEFAULT_ON_FAILURE_TIMEOUT_SECONDS, DEFAULT_RETRY_TIMEOUT_SECONDS,
};
use crate::config::Config;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::tool_monitor::RepetitionInspector;
/// Result of a retry logic evaluation
#[derive(Debug, Clone, PartialEq)]
pub enum RetryResult {
/// No retry configuration or session available, retry logic skipped
Skipped,
/// Maximum retry attempts reached, cannot retry further
MaxAttemptsReached,
/// Success checks passed, no retry needed
SuccessChecksPassed,
/// Retry is needed and will be performed
Retried,
}
/// Environment variable for configuring retry timeout globally
const GOOSE_RECIPE_RETRY_TIMEOUT_SECONDS: &str = "GOOSE_RECIPE_RETRY_TIMEOUT_SECONDS";
/// Environment variable for configuring on_failure timeout globally
const GOOSE_RECIPE_ON_FAILURE_TIMEOUT_SECONDS: &str = "GOOSE_RECIPE_ON_FAILURE_TIMEOUT_SECONDS";
/// Manages retry state and operations for agent execution
#[derive(Debug)]
pub struct RetryManager {
/// Current number of retry attempts
attempts: Arc<Mutex<u32>>,
/// Optional repetition inspector for reset operations
repetition_inspector: Option<Arc<Mutex<Option<RepetitionInspector>>>>,
}
impl Default for RetryManager {
fn default() -> Self {
Self::new()
}
}
impl RetryManager {
/// Create a new retry manager
pub fn new() -> Self {
Self {
attempts: Arc::new(Mutex::new(0)),
repetition_inspector: None,
}
}
/// Create a new retry manager with repetition inspector
pub fn with_repetition_inspector(
repetition_inspector: Arc<Mutex<Option<RepetitionInspector>>>,
) -> Self {
Self {
attempts: Arc::new(Mutex::new(0)),
repetition_inspector: Some(repetition_inspector),
}
}
/// Reset the retry attempts counter to 0
pub async fn reset_attempts(&self) {
let mut attempts = self.attempts.lock().await;
*attempts = 0;
// Reset repetition inspector if available
if let Some(inspector) = &self.repetition_inspector {
if let Some(inspector) = inspector.lock().await.as_mut() {
inspector.reset();
}
}
}
/// Increment the retry attempts counter and return the new value
pub async fn increment_attempts(&self) -> u32 {
let mut attempts = self.attempts.lock().await;
*attempts += 1;
*attempts
}
/// Get the current retry attempts count
pub async fn get_attempts(&self) -> u32 {
*self.attempts.lock().await
}
/// Reset status for retry: clear message history and final output tool state
async fn reset_status_for_retry(
messages: &mut Conversation,
initial_messages: &[Message],
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
) {
*messages = Conversation::new_unvalidated(initial_messages.to_vec());
info!("Reset message history to initial state for retry");
let mut guard = final_output_tool.lock().await;
if let Some(fot) = guard.as_mut() {
fot.final_output = None;
info!("Cleared final output tool state for retry");
}
}
pub async fn handle_retry_logic(
&self,
messages: &mut Conversation,
session_config: &SessionConfig,
initial_messages: &[Message],
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
) -> Result<RetryResult> {
let Some(retry_config) = &session_config.retry_config else {
return Ok(RetryResult::Skipped);
};
let success = execute_success_checks(&retry_config.checks, retry_config).await?;
if success {
info!("All success checks passed, no retry needed");
return Ok(RetryResult::SuccessChecksPassed);
}
let current_attempts = self.get_attempts().await;
if current_attempts >= retry_config.max_retries {
let error_msg = Message::assistant().with_text(format!(
"Maximum retry attempts ({}) exceeded. Unable to complete the task successfully.",
retry_config.max_retries
));
messages.push(error_msg);
warn!(
"Maximum retry attempts ({}) exceeded",
retry_config.max_retries
);
#[cfg(feature = "telemetry")]
crate::posthog::emit_error(
"retry_max_exceeded",
&format!("Max retries ({}) exceeded", retry_config.max_retries),
);
return Ok(RetryResult::MaxAttemptsReached);
}
if let Some(on_failure_cmd) = &retry_config.on_failure {
info!("Executing on_failure command: {}", on_failure_cmd);
execute_on_failure_command(on_failure_cmd, retry_config).await?;
}
Self::reset_status_for_retry(messages, initial_messages, final_output_tool).await;
let new_attempts = self.increment_attempts().await;
info!("Incrementing retry attempts to {}", new_attempts);
Ok(RetryResult::Retried)
}
}
/// Get the configured timeout duration for retry operations
/// retry_config.timeout_seconds -> env var -> default
fn get_retry_timeout(retry_config: &RetryConfig) -> Duration {
let timeout_seconds = retry_config
.timeout_seconds
.or_else(|| {
let config = Config::global();
config.get_param(GOOSE_RECIPE_RETRY_TIMEOUT_SECONDS).ok()
})
.unwrap_or(DEFAULT_RETRY_TIMEOUT_SECONDS);
Duration::from_secs(timeout_seconds)
}
/// Get the configured timeout duration for on_failure operations
/// retry_config.on_failure_timeout_seconds -> env var -> default
fn get_on_failure_timeout(retry_config: &RetryConfig) -> Duration {
let timeout_seconds = retry_config
.on_failure_timeout_seconds
.or_else(|| {
let config = Config::global();
config
.get_param(GOOSE_RECIPE_ON_FAILURE_TIMEOUT_SECONDS)
.ok()
})
.unwrap_or(DEFAULT_ON_FAILURE_TIMEOUT_SECONDS);
Duration::from_secs(timeout_seconds)
}
/// Execute all success checks and return true if all pass
pub async fn execute_success_checks(
checks: &[SuccessCheck],
retry_config: &RetryConfig,
) -> Result<bool> {
let timeout = get_retry_timeout(retry_config);
for check in checks {
match check {
SuccessCheck::Shell { command } => {
let result = execute_shell_command(command, timeout).await?;
if !result.status.success() {
warn!(
"Success check failed: command '{}' exited with status {}, stderr: {}",
command,
result.status,
String::from_utf8_lossy(&result.stderr)
);
return Ok(false);
}
info!(
"Success check passed: command '{}' completed successfully",
command
);
}
}
}
Ok(true)
}
/// Execute a shell command with cross-platform compatibility and mandatory timeout
pub async fn execute_shell_command(
command: &str,
timeout: std::time::Duration,
) -> Result<std::process::Output> {
debug!(
"Executing shell command with timeout {:?}: {}",
timeout, command
);
let future = async {
let mut cmd = if cfg!(target_os = "windows") {
let mut cmd = Command::new("cmd");
cmd.args(["/C", command]);
cmd.env("GOOSE_TERMINAL", "1");
cmd.env("AGENT", "goose");
cmd
} else {
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
cmd.env("GOOSE_TERMINAL", "1");
cmd.env("AGENT", "goose");
cmd
};
cmd.set_no_window();
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true)
.output()
.await?;
debug!(
"Shell command completed with status: {}, stdout: {}, stderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(output)
};
match tokio::time::timeout(timeout, future).await {
Ok(result) => result,
Err(_) => {
let error_msg = format!("Shell command timed out after {:?}: {}", timeout, command);
warn!("{}", error_msg);
Err(anyhow::anyhow!("{}", error_msg))
}
}
}
/// Execute an on_failure command and return an error if it fails
pub async fn execute_on_failure_command(command: &str, retry_config: &RetryConfig) -> Result<()> {
let timeout = get_on_failure_timeout(retry_config);
info!(
"Executing on_failure command with timeout {:?}: {}",
timeout, command
);
let output = match execute_shell_command(command, timeout).await {
Ok(output) => output,
Err(e) => {
if e.to_string().contains("timed out") {
let error_msg = format!(
"On_failure command timed out after {:?}: {}",
timeout, command
);
warn!("{}", error_msg);
return Err(anyhow::anyhow!(error_msg));
} else {
warn!("On_failure command execution error: {}", e);
return Err(e);
}
}
};
if !output.status.success() {
let error_msg = format!(
"On_failure command failed: command '{}' exited with status {}, stderr: {}",
command,
output.status,
String::from_utf8_lossy(&output.stderr)
);
warn!("{}", error_msg);
return Err(anyhow::anyhow!(error_msg));
} else {
info!("On_failure command completed successfully: {}", command);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::types::SuccessCheck;
fn create_test_retry_config() -> RetryConfig {
RetryConfig {
max_retries: 3,
checks: vec![],
on_failure: None,
timeout_seconds: Some(60),
on_failure_timeout_seconds: Some(120),
}
}
#[test]
fn test_retry_result_enum() {
assert_ne!(RetryResult::Skipped, RetryResult::MaxAttemptsReached);
assert_ne!(RetryResult::Skipped, RetryResult::SuccessChecksPassed);
assert_ne!(RetryResult::Skipped, RetryResult::Retried);
assert_ne!(
RetryResult::MaxAttemptsReached,
RetryResult::SuccessChecksPassed
);
assert_ne!(RetryResult::MaxAttemptsReached, RetryResult::Retried);
assert_ne!(RetryResult::SuccessChecksPassed, RetryResult::Retried);
let result = RetryResult::Retried;
let cloned = result.clone();
assert_eq!(result, cloned);
let debug_str = format!("{:?}", RetryResult::MaxAttemptsReached);
assert!(debug_str.contains("MaxAttemptsReached"));
}
#[tokio::test]
async fn test_execute_success_checks_all_pass() {
let checks = vec![
SuccessCheck::Shell {
command: "echo 'test'".to_string(),
},
SuccessCheck::Shell {
command: "true".to_string(),
},
];
let retry_config = create_test_retry_config();
let result = execute_success_checks(&checks, &retry_config).await;
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn test_execute_success_checks_one_fails() {
let checks = vec![
SuccessCheck::Shell {
command: "echo 'test'".to_string(),
},
SuccessCheck::Shell {
command: "false".to_string(),
},
];
let retry_config = create_test_retry_config();
let result = execute_success_checks(&checks, &retry_config).await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_execute_shell_command_success() {
let result = execute_shell_command("echo 'hello world'", Duration::from_secs(30)).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("hello world"));
}
#[tokio::test]
async fn test_execute_shell_command_failure() {
let result = execute_shell_command("false", Duration::from_secs(30)).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.status.success());
}
#[tokio::test]
async fn test_execute_on_failure_command_success() {
let retry_config = create_test_retry_config();
let result = execute_on_failure_command("echo 'cleanup'", &retry_config).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_on_failure_command_failure() {
let retry_config = create_test_retry_config();
let result = execute_on_failure_command("false", &retry_config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_shell_command_timeout() {
let timeout = std::time::Duration::from_millis(100);
let result = if cfg!(target_os = "windows") {
execute_shell_command("timeout /t 1", timeout).await
} else {
execute_shell_command("sleep 1", timeout).await
};
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_retry_timeout_uses_config_default() {
let retry_config = RetryConfig {
max_retries: 1,
checks: vec![],
on_failure: None,
timeout_seconds: None,
on_failure_timeout_seconds: None,
};
let timeout = get_retry_timeout(&retry_config);
assert_eq!(timeout, Duration::from_secs(DEFAULT_RETRY_TIMEOUT_SECONDS));
}
#[tokio::test]
async fn test_get_retry_timeout_uses_retry_config() {
let retry_config = RetryConfig {
max_retries: 1,
checks: vec![],
on_failure: None,
timeout_seconds: Some(120),
on_failure_timeout_seconds: None,
};
let timeout = get_retry_timeout(&retry_config);
assert_eq!(timeout, Duration::from_secs(120));
}
#[tokio::test]
async fn test_get_on_failure_timeout_uses_config_default() {
let retry_config = RetryConfig {
max_retries: 1,
checks: vec![],
on_failure: None,
timeout_seconds: None,
on_failure_timeout_seconds: None,
};
let timeout = get_on_failure_timeout(&retry_config);
assert_eq!(
timeout,
Duration::from_secs(DEFAULT_ON_FAILURE_TIMEOUT_SECONDS)
);
}
#[tokio::test]
async fn test_get_on_failure_timeout_uses_retry_config() {
let retry_config = RetryConfig {
max_retries: 1,
checks: vec![],
on_failure: None,
timeout_seconds: None,
on_failure_timeout_seconds: Some(900),
};
let timeout = get_on_failure_timeout(&retry_config);
assert_eq!(timeout, Duration::from_secs(900));
}
#[tokio::test]
async fn test_on_failure_timeout_different_from_retry_timeout() {
let retry_config = RetryConfig {
max_retries: 1,
checks: vec![],
on_failure: None,
timeout_seconds: Some(60),
on_failure_timeout_seconds: Some(300),
};
let retry_timeout = get_retry_timeout(&retry_config);
let on_failure_timeout = get_on_failure_timeout(&retry_config);
assert_eq!(retry_timeout, Duration::from_secs(60));
assert_eq!(on_failure_timeout, Duration::from_secs(300));
assert_ne!(retry_timeout, on_failure_timeout);
}
}