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
112            .failure_counts
113            .lock()
114            .map_err(|e| AgentError::internal(format!("Failed to lock failure counts: {}", e)))?;
115
116        let session_counts = counts.entry(session_id.id).or_insert_with(HashMap::new);
117
118        // Increment failure count for each failing tool
119        let mut max_failures = 0;
120        for name in tool_names {
121            let count = session_counts.entry(name.clone()).or_insert(0);
122            *count += 1;
123            if *count > max_failures {
124                max_failures = *count;
125            }
126        }
127
128        if max_failures >= self.max_consecutive_failures {
129            let failing_tools: Vec<String> = tool_names
130                .iter()
131                .filter(|name| {
132                    session_counts
133                        .get(*name)
134                        .map_or(false, |&c| c >= self.max_consecutive_failures)
135                })
136                .cloned()
137                .collect();
138
139            tracing::warn!(
140                session_id = session_id.id,
141                failing_tools = ?failing_tools,
142                max_consecutive_failures = self.max_consecutive_failures,
143                "ConsecutiveFailureRecovery: stopping after repeated failures"
144            );
145
146            // Clear counts for this session since we're stopping
147            counts.remove(&session_id.id);
148
149            return Ok(ToolErrorAction::Stop);
150        }
151
152        Ok(ToolErrorAction::Retry)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn stop_on_error_always_stops() {
162        let recovery = StopOnError;
163        let session_id = SessionId::new(1);
164        let names = vec!["tool_a".to_string()];
165        let error = AgentError::internal("test error");
166        assert_eq!(
167            recovery.on_error(&session_id, &names, &error).unwrap(),
168            ToolErrorAction::Stop
169        );
170    }
171
172    #[test]
173    fn retry_on_error_always_retries() {
174        let recovery = RetryOnError;
175        let session_id = SessionId::new(1);
176        let names = vec!["tool_a".to_string()];
177        let error = AgentError::internal("test error");
178        assert_eq!(
179            recovery.on_error(&session_id, &names, &error).unwrap(),
180            ToolErrorAction::Retry
181        );
182    }
183
184    #[test]
185    fn consecutive_failure_retries_then_stops() {
186        let recovery = ConsecutiveFailureRecovery::new(3);
187        let session_id = SessionId::new(1);
188        let names = vec!["tool_a".to_string()];
189        let error = AgentError::internal("test error");
190
191        // First two failures: retry
192        assert_eq!(
193            recovery.on_error(&session_id, &names, &error).unwrap(),
194            ToolErrorAction::Retry
195        );
196        assert_eq!(
197            recovery.on_error(&session_id, &names, &error).unwrap(),
198            ToolErrorAction::Retry
199        );
200
201        // Third failure: stop
202        assert_eq!(
203            recovery.on_error(&session_id, &names, &error).unwrap(),
204            ToolErrorAction::Stop
205        );
206    }
207
208    #[test]
209    fn consecutive_failure_resets_on_success() {
210        let recovery = ConsecutiveFailureRecovery::new(3);
211        let session_id = SessionId::new(1);
212        let names = vec!["tool_a".to_string()];
213        let error = AgentError::internal("test error");
214
215        // Two failures
216        recovery.on_error(&session_id, &names, &error).unwrap();
217        recovery.on_error(&session_id, &names, &error).unwrap();
218
219        // Reset on success
220        recovery.reset_failures(&session_id, "tool_a");
221
222        // Should retry again (counter reset)
223        assert_eq!(
224            recovery.on_error(&session_id, &names, &error).unwrap(),
225            ToolErrorAction::Retry
226        );
227    }
228
229    #[test]
230    fn consecutive_failure_per_session_isolation() {
231        let recovery = ConsecutiveFailureRecovery::new(2);
232        let session1 = SessionId::new(1);
233        let session2 = SessionId::new(2);
234        let names = vec!["tool_a".to_string()];
235        let error = AgentError::internal("test error");
236
237        // Session 1: two failures -> stop
238        recovery.on_error(&session1, &names, &error).unwrap();
239        assert_eq!(
240            recovery.on_error(&session1, &names, &error).unwrap(),
241            ToolErrorAction::Stop
242        );
243
244        // Session 2: should still retry (isolated)
245        assert_eq!(
246            recovery.on_error(&session2, &names, &error).unwrap(),
247            ToolErrorAction::Retry
248        );
249    }
250
251    #[test]
252    fn consecutive_failure_resets_session() {
253        let recovery = ConsecutiveFailureRecovery::new(2);
254        let session_id = SessionId::new(1);
255        let names = vec!["tool_a".to_string()];
256        let error = AgentError::internal("test error");
257
258        // Two failures -> stop
259        recovery.on_error(&session_id, &names, &error).unwrap();
260        assert_eq!(
261            recovery.on_error(&session_id, &names, &error).unwrap(),
262            ToolErrorAction::Stop
263        );
264
265        // Reset session
266        recovery.reset_session(&session_id);
267
268        // Should retry again
269        assert_eq!(
270            recovery.on_error(&session_id, &names, &error).unwrap(),
271            ToolErrorAction::Retry
272        );
273    }
274}