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    /// Called when a tool executes successfully.
29    ///
30    /// Default no-op. Strategies that track consecutive failures (e.g.
31    /// [`ConsecutiveFailureRecovery`]) override this to reset that tool's counter so
32    /// the "consecutive" semantics hold: a success breaks the failure streak.
33    fn on_success(&self, _session_id: &SessionId, _tool_name: &str) {}
34}
35
36/// Default strategy: stop on tool failure.
37///
38/// This is the most conservative strategy. The kernel only reports the fact
39/// without making business recovery decisions for the upper layer.
40pub struct StopOnError;
41
42impl ToolErrorRecovery for StopOnError {
43    fn on_error(
44        &self,
45        _session_id: &SessionId,
46        _tool_names: &[String],
47        _error: &AgentError,
48    ) -> AgentResult<ToolErrorAction> {
49        Ok(ToolErrorAction::Stop)
50    }
51}
52
53/// Continue on tool failure, feeding the error back to the model
54///
55/// Suitable for scenarios where model self-healing is desired (e.g. code-agent, browser-agent).
56pub struct RetryOnError;
57
58impl ToolErrorRecovery for RetryOnError {
59    fn on_error(
60        &self,
61        _session_id: &SessionId,
62        _tool_names: &[String],
63        _error: &AgentError,
64    ) -> AgentResult<ToolErrorAction> {
65        Ok(ToolErrorAction::Retry)
66    }
67}
68
69/// Recovery strategy that tracks consecutive failures per tool and stops after a limit.
70///
71/// This prevents runaway retry loops where the model keeps calling the same failing
72/// tool (e.g., `execute_ssh_command` failing due to auth, but the model retries
73/// indefinitely). After `max_consecutive_failures` for the same tool name, the
74/// strategy switches from `Retry` to `Stop` with a summary message.
75///
76/// Failure counters are tracked per session and reset when a different tool
77/// succeeds or a new session starts.
78pub struct ConsecutiveFailureRecovery {
79    max_consecutive_failures: usize,
80    /// session_id -> (tool_name -> consecutive_failures)
81    failure_counts: Mutex<HashMap<u64, HashMap<String, usize>>>,
82}
83
84impl ConsecutiveFailureRecovery {
85    pub fn new(max_consecutive_failures: usize) -> Self {
86        Self {
87            max_consecutive_failures,
88            failure_counts: Mutex::new(HashMap::new()),
89        }
90    }
91
92    /// Reset failure count for a specific tool in a session.
93    /// Call this when a tool succeeds to avoid false positives.
94    pub fn reset_failures(&self, session_id: &SessionId, tool_name: &str) {
95        if let Ok(mut counts) = self.failure_counts.lock()
96            && let Some(session_counts) = counts.get_mut(&session_id.id)
97        {
98            session_counts.remove(tool_name);
99        }
100    }
101
102    /// Reset all failure counts for a session.
103    /// Call this when a new turn starts.
104    pub fn reset_session(&self, session_id: &SessionId) {
105        if let Ok(mut counts) = self.failure_counts.lock() {
106            counts.remove(&session_id.id);
107        }
108    }
109}
110
111impl ToolErrorRecovery for ConsecutiveFailureRecovery {
112    fn on_error(
113        &self,
114        session_id: &SessionId,
115        tool_names: &[String],
116        _error: &AgentError,
117    ) -> AgentResult<ToolErrorAction> {
118        let mut counts = self
119            .failure_counts
120            .lock()
121            .map_err(|e| AgentError::internal(format!("Failed to lock failure counts: {}", e)))?;
122
123        let session_counts = counts.entry(session_id.id).or_insert_with(HashMap::new);
124
125        // Increment failure count for each failing tool
126        let mut max_failures = 0;
127        for name in tool_names {
128            let count = session_counts.entry(name.clone()).or_insert(0);
129            *count += 1;
130            if *count > max_failures {
131                max_failures = *count;
132            }
133        }
134
135        if max_failures >= self.max_consecutive_failures {
136            let failing_tools: Vec<String> = tool_names
137                .iter()
138                .filter(|name| {
139                    session_counts
140                        .get(*name)
141                        .is_some_and(|&c| c >= self.max_consecutive_failures)
142                })
143                .cloned()
144                .collect();
145
146            tracing::warn!(
147                session_id = session_id.id,
148                failing_tools = ?failing_tools,
149                max_consecutive_failures = self.max_consecutive_failures,
150                "ConsecutiveFailureRecovery: stopping after repeated failures"
151            );
152
153            // Clear counts for this session since we're stopping
154            counts.remove(&session_id.id);
155
156            return Ok(ToolErrorAction::Stop);
157        }
158
159        Ok(ToolErrorAction::Retry)
160    }
161
162    fn on_success(&self, session_id: &SessionId, tool_name: &str) {
163        self.reset_failures(session_id, tool_name);
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn stop_on_error_always_stops() {
173        let recovery = StopOnError;
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::Stop
180        );
181    }
182
183    #[test]
184    fn retry_on_error_always_retries() {
185        let recovery = RetryOnError;
186        let session_id = SessionId::new(1);
187        let names = vec!["tool_a".to_string()];
188        let error = AgentError::internal("test error");
189        assert_eq!(
190            recovery.on_error(&session_id, &names, &error).unwrap(),
191            ToolErrorAction::Retry
192        );
193    }
194
195    #[test]
196    fn consecutive_failure_retries_then_stops() {
197        let recovery = ConsecutiveFailureRecovery::new(3);
198        let session_id = SessionId::new(1);
199        let names = vec!["tool_a".to_string()];
200        let error = AgentError::internal("test error");
201
202        // First two failures: retry
203        assert_eq!(
204            recovery.on_error(&session_id, &names, &error).unwrap(),
205            ToolErrorAction::Retry
206        );
207        assert_eq!(
208            recovery.on_error(&session_id, &names, &error).unwrap(),
209            ToolErrorAction::Retry
210        );
211
212        // Third failure: stop
213        assert_eq!(
214            recovery.on_error(&session_id, &names, &error).unwrap(),
215            ToolErrorAction::Stop
216        );
217    }
218
219    #[test]
220    fn consecutive_failure_resets_on_success() {
221        let recovery = ConsecutiveFailureRecovery::new(3);
222        let session_id = SessionId::new(1);
223        let names = vec!["tool_a".to_string()];
224        let error = AgentError::internal("test error");
225
226        // Two failures
227        recovery.on_error(&session_id, &names, &error).unwrap();
228        recovery.on_error(&session_id, &names, &error).unwrap();
229
230        // Reset on success
231        recovery.reset_failures(&session_id, "tool_a");
232
233        // Should retry again (counter reset)
234        assert_eq!(
235            recovery.on_error(&session_id, &names, &error).unwrap(),
236            ToolErrorAction::Retry
237        );
238    }
239
240    #[test]
241    fn consecutive_failure_on_success_resets_via_trait() {
242        let recovery = ConsecutiveFailureRecovery::new(2);
243        let session_id = SessionId::new(1);
244        let names = vec!["tool_a".to_string()];
245        let error = AgentError::internal("test error");
246
247        // One failure (below the threshold of 2)
248        assert_eq!(
249            recovery.on_error(&session_id, &names, &error).unwrap(),
250            ToolErrorAction::Retry
251        );
252
253        // A successful execution breaks the streak (this is what the react loop calls).
254        recovery.on_success(&session_id, "tool_a");
255
256        // The next failure starts a fresh streak: retry again, not stop.
257        assert_eq!(
258            recovery.on_error(&session_id, &names, &error).unwrap(),
259            ToolErrorAction::Retry
260        );
261    }
262
263    #[test]
264    fn consecutive_failure_per_session_isolation() {
265        let recovery = ConsecutiveFailureRecovery::new(2);
266        let session1 = SessionId::new(1);
267        let session2 = SessionId::new(2);
268        let names = vec!["tool_a".to_string()];
269        let error = AgentError::internal("test error");
270
271        // Session 1: two failures -> stop
272        recovery.on_error(&session1, &names, &error).unwrap();
273        assert_eq!(
274            recovery.on_error(&session1, &names, &error).unwrap(),
275            ToolErrorAction::Stop
276        );
277
278        // Session 2: should still retry (isolated)
279        assert_eq!(
280            recovery.on_error(&session2, &names, &error).unwrap(),
281            ToolErrorAction::Retry
282        );
283    }
284
285    #[test]
286    fn consecutive_failure_resets_session() {
287        let recovery = ConsecutiveFailureRecovery::new(2);
288        let session_id = SessionId::new(1);
289        let names = vec!["tool_a".to_string()];
290        let error = AgentError::internal("test error");
291
292        // Two failures -> stop
293        recovery.on_error(&session_id, &names, &error).unwrap();
294        assert_eq!(
295            recovery.on_error(&session_id, &names, &error).unwrap(),
296            ToolErrorAction::Stop
297        );
298
299        // Reset session
300        recovery.reset_session(&session_id);
301
302        // Should retry again
303        assert_eq!(
304            recovery.on_error(&session_id, &names, &error).unwrap(),
305            ToolErrorAction::Retry
306        );
307    }
308}