Skip to main content

agent_base/engine/
recovery.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::types::{AgentError, AgentResult, SessionId};
5
6/// Action taken by the runtime after a tool execution failure
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum ToolErrorAction {
9    /// Stop the current run with a failed outcome
10    Stop,
11    /// Feed the error back to the LLM and continue reasoning
12    Retry,
13}
14
15/// Recovery strategy after a tool execution failure
16///
17/// Defaults to [`StopOnError`], following the lightweight kernel design of
18/// conservative defaults and strategy injection.
19/// Upper-layer agents can inject custom strategies such as [`RetryOnError`].
20pub trait ToolErrorRecovery: Send + Sync {
21    fn on_error(
22        &self,
23        _session_id: &SessionId,
24        _tool_names: &[String],
25        _error: &AgentError,
26    ) -> AgentResult<ToolErrorAction>;
27}
28
29/// Default strategy: stop on tool failure.
30///
31/// This is the most conservative strategy. The kernel only reports the fact
32/// without making business recovery decisions for the upper layer.
33pub struct StopOnError;
34
35impl ToolErrorRecovery for StopOnError {
36    fn on_error(
37        &self,
38        _session_id: &SessionId,
39        _tool_names: &[String],
40        _error: &AgentError,
41    ) -> AgentResult<ToolErrorAction> {
42        Ok(ToolErrorAction::Stop)
43    }
44}
45
46/// Continue on tool failure, feeding the error back to the model
47///
48/// Suitable for scenarios where model self-healing is desired (e.g. code-agent, browser-agent).
49pub struct RetryOnError;
50
51impl ToolErrorRecovery for RetryOnError {
52    fn on_error(
53        &self,
54        _session_id: &SessionId,
55        _tool_names: &[String],
56        _error: &AgentError,
57    ) -> AgentResult<ToolErrorAction> {
58        Ok(ToolErrorAction::Retry)
59    }
60}
61
62/// Recovery strategy that tracks consecutive failures per tool and stops after a limit.
63///
64/// This prevents runaway retry loops where the model keeps calling the same failing
65/// tool (e.g., `execute_ssh_command` failing due to auth, but the model retries
66/// indefinitely). After `max_consecutive_failures` for the same tool name, the
67/// strategy switches from `Retry` to `Stop` with a summary message.
68///
69/// Failure counters are tracked per session and reset when a different tool
70/// succeeds or a new session starts.
71pub struct ConsecutiveFailureRecovery {
72    max_consecutive_failures: usize,
73    /// session_id -> (tool_name -> consecutive_failures)
74    failure_counts: Mutex<HashMap<u64, HashMap<String, usize>>>,
75}
76
77impl ConsecutiveFailureRecovery {
78    pub fn new(max_consecutive_failures: usize) -> Self {
79        Self {
80            max_consecutive_failures,
81            failure_counts: Mutex::new(HashMap::new()),
82        }
83    }
84
85    /// Reset failure count for a specific tool in a session.
86    /// Call this when a tool succeeds to avoid false positives.
87    pub fn reset_failures(&self, session_id: &SessionId, tool_name: &str) {
88        if let Ok(mut counts) = self.failure_counts.lock() {
89            if let Some(session_counts) = counts.get_mut(&session_id.id) {
90                session_counts.remove(tool_name);
91            }
92        }
93    }
94
95    /// Reset all failure counts for a session.
96    /// Call this when a new turn starts.
97    pub fn reset_session(&self, session_id: &SessionId) {
98        if let Ok(mut counts) = self.failure_counts.lock() {
99            counts.remove(&session_id.id);
100        }
101    }
102}
103
104impl ToolErrorRecovery for ConsecutiveFailureRecovery {
105    fn on_error(
106        &self,
107        session_id: &SessionId,
108        tool_names: &[String],
109        _error: &AgentError,
110    ) -> AgentResult<ToolErrorAction> {
111        let mut counts = self.failure_counts.lock().map_err(|e| {
112            AgentError::internal(format!("Failed to lock failure counts: {}", e))
113        })?;
114
115        let session_counts = counts.entry(session_id.id).or_insert_with(HashMap::new);
116
117        // Increment failure count for each failing tool
118        let mut max_failures = 0;
119        for name in tool_names {
120            let count = session_counts.entry(name.clone()).or_insert(0);
121            *count += 1;
122            if *count > max_failures {
123                max_failures = *count;
124            }
125        }
126
127        if max_failures >= self.max_consecutive_failures {
128            let failing_tools: Vec<String> = tool_names
129                .iter()
130                .filter(|name| {
131                    session_counts
132                        .get(*name)
133                        .map_or(false, |&c| c >= self.max_consecutive_failures)
134                })
135                .cloned()
136                .collect();
137
138            tracing::warn!(
139                session_id = session_id.id,
140                failing_tools = ?failing_tools,
141                max_consecutive_failures = self.max_consecutive_failures,
142                "ConsecutiveFailureRecovery: stopping after repeated failures"
143            );
144
145            // Clear counts for this session since we're stopping
146            counts.remove(&session_id.id);
147
148            return Ok(ToolErrorAction::Stop);
149        }
150
151        Ok(ToolErrorAction::Retry)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn stop_on_error_always_stops() {
161        let recovery = StopOnError;
162        let session_id = SessionId::new(1);
163        let names = vec!["tool_a".to_string()];
164        let error = AgentError::internal("test error");
165        assert_eq!(
166            recovery.on_error(&session_id, &names, &error).unwrap(),
167            ToolErrorAction::Stop
168        );
169    }
170
171    #[test]
172    fn retry_on_error_always_retries() {
173        let recovery = RetryOnError;
174        let session_id = SessionId::new(1);
175        let names = vec!["tool_a".to_string()];
176        let error = AgentError::internal("test error");
177        assert_eq!(
178            recovery.on_error(&session_id, &names, &error).unwrap(),
179            ToolErrorAction::Retry
180        );
181    }
182
183    #[test]
184    fn consecutive_failure_retries_then_stops() {
185        let recovery = ConsecutiveFailureRecovery::new(3);
186        let session_id = SessionId::new(1);
187        let names = vec!["tool_a".to_string()];
188        let error = AgentError::internal("test error");
189
190        // First two failures: retry
191        assert_eq!(
192            recovery.on_error(&session_id, &names, &error).unwrap(),
193            ToolErrorAction::Retry
194        );
195        assert_eq!(
196            recovery.on_error(&session_id, &names, &error).unwrap(),
197            ToolErrorAction::Retry
198        );
199
200        // Third failure: stop
201        assert_eq!(
202            recovery.on_error(&session_id, &names, &error).unwrap(),
203            ToolErrorAction::Stop
204        );
205    }
206
207    #[test]
208    fn consecutive_failure_resets_on_success() {
209        let recovery = ConsecutiveFailureRecovery::new(3);
210        let session_id = SessionId::new(1);
211        let names = vec!["tool_a".to_string()];
212        let error = AgentError::internal("test error");
213
214        // Two failures
215        recovery.on_error(&session_id, &names, &error).unwrap();
216        recovery.on_error(&session_id, &names, &error).unwrap();
217
218        // Reset on success
219        recovery.reset_failures(&session_id, "tool_a");
220
221        // Should retry again (counter reset)
222        assert_eq!(
223            recovery.on_error(&session_id, &names, &error).unwrap(),
224            ToolErrorAction::Retry
225        );
226    }
227
228    #[test]
229    fn consecutive_failure_per_session_isolation() {
230        let recovery = ConsecutiveFailureRecovery::new(2);
231        let session1 = SessionId::new(1);
232        let session2 = SessionId::new(2);
233        let names = vec!["tool_a".to_string()];
234        let error = AgentError::internal("test error");
235
236        // Session 1: two failures -> stop
237        recovery.on_error(&session1, &names, &error).unwrap();
238        assert_eq!(
239            recovery.on_error(&session1, &names, &error).unwrap(),
240            ToolErrorAction::Stop
241        );
242
243        // Session 2: should still retry (isolated)
244        assert_eq!(
245            recovery.on_error(&session2, &names, &error).unwrap(),
246            ToolErrorAction::Retry
247        );
248    }
249
250    #[test]
251    fn consecutive_failure_resets_session() {
252        let recovery = ConsecutiveFailureRecovery::new(2);
253        let session_id = SessionId::new(1);
254        let names = vec!["tool_a".to_string()];
255        let error = AgentError::internal("test error");
256
257        // Two failures -> stop
258        recovery.on_error(&session_id, &names, &error).unwrap();
259        assert_eq!(
260            recovery.on_error(&session_id, &names, &error).unwrap(),
261            ToolErrorAction::Stop
262        );
263
264        // Reset session
265        recovery.reset_session(&session_id);
266
267        // Should retry again
268        assert_eq!(
269            recovery.on_error(&session_id, &names, &error).unwrap(),
270            ToolErrorAction::Retry
271        );
272    }
273}