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    /// Feed the full error history back to the LLM and let it evaluate.
14    ///
15    /// Emitted by [`ConsecutiveFailureRecovery`] when the same tool fails
16    /// `max_consecutive_failures` times in a row. Gives the LLM one grace
17    /// round to read the full error stack and either switch strategy or
18    /// explain the failure to the user.
19    RetryWithHistory {
20        /// Collected error messages from the consecutive failure streak.
21        errors: Vec<String>,
22    },
23}
24
25/// Recovery strategy after a tool execution failure
26///
27/// Defaults to [`StopOnError`], following the lightweight kernel design of
28/// conservative defaults and strategy injection.
29/// Upper-layer agents can inject custom strategies such as [`RetryOnError`].
30pub trait ToolErrorRecovery: Send + Sync {
31    fn on_error(
32        &self,
33        _session_id: &SessionId,
34        _tool_names: &[String],
35        _error: &AgentError,
36    ) -> AgentResult<ToolErrorAction>;
37
38    /// Called when a tool executes successfully.
39    ///
40    /// Default no-op. Strategies that track consecutive failures (e.g.
41    /// [`ConsecutiveFailureRecovery`]) override this to reset that tool's counter so
42    /// the "consecutive" semantics hold: a success breaks the failure streak.
43    fn on_success(&self, _session_id: &SessionId, _tool_name: &str) {}
44}
45
46/// Default strategy: stop on tool failure.
47///
48/// This is the most conservative strategy. The kernel only reports the fact
49/// without making business recovery decisions for the upper layer.
50pub struct StopOnError;
51
52impl ToolErrorRecovery for StopOnError {
53    fn on_error(
54        &self,
55        _session_id: &SessionId,
56        _tool_names: &[String],
57        _error: &AgentError,
58    ) -> AgentResult<ToolErrorAction> {
59        Ok(ToolErrorAction::Stop)
60    }
61}
62
63/// Continue on tool failure, feeding the error back to the model
64///
65/// Suitable for scenarios where model self-healing is desired (e.g. code-agent, browser-agent).
66pub struct RetryOnError;
67
68impl ToolErrorRecovery for RetryOnError {
69    fn on_error(
70        &self,
71        _session_id: &SessionId,
72        _tool_names: &[String],
73        _error: &AgentError,
74    ) -> AgentResult<ToolErrorAction> {
75        Ok(ToolErrorAction::Retry)
76    }
77}
78
79/// Recovery strategy that tracks consecutive failures per tool and stops after a limit.
80///
81/// This prevents runaway retry loops where the model keeps calling the same failing
82/// tool (e.g., `execute_ssh_command` failing due to auth, but the model retries
83/// indefinitely). After `max_consecutive_failures` for the same tool name, the
84/// strategy switches from `Retry` to `Stop` with a summary message.
85///
86/// Failure counters are tracked per session and reset when a different tool
87/// succeeds or a new session starts.
88pub struct ConsecutiveFailureRecovery {
89    max_consecutive_failures: usize,
90    /// session_id -> (tool_name -> consecutive_failures)
91    failure_counts: Mutex<HashMap<u64, HashMap<String, usize>>>,
92    /// session_id -> (tool_name -> collected error messages for the current streak)
93    error_messages: Mutex<HashMap<u64, HashMap<String, Vec<String>>>>,
94    /// session_id -> (tool_name -> whether RetryWithHistory grace was already used)
95    grace_used: Mutex<HashMap<u64, HashMap<String, bool>>>,
96}
97
98impl ConsecutiveFailureRecovery {
99    pub fn new(max_consecutive_failures: usize) -> Self {
100        Self {
101            max_consecutive_failures,
102            failure_counts: Mutex::new(HashMap::new()),
103            error_messages: Mutex::new(HashMap::new()),
104            grace_used: Mutex::new(HashMap::new()),
105        }
106    }
107
108    /// Reset failure count, error messages, and grace flag for a specific tool in a session.
109    /// Call this when a tool succeeds to avoid false positives.
110    pub fn reset_failures(&self, session_id: &SessionId, tool_name: &str) {
111        if let Ok(mut counts) = self.failure_counts.lock()
112            && let Some(session_counts) = counts.get_mut(&session_id.id)
113        {
114            session_counts.remove(tool_name);
115        }
116        if let Ok(mut msgs) = self.error_messages.lock()
117            && let Some(session_msgs) = msgs.get_mut(&session_id.id)
118        {
119            session_msgs.remove(tool_name);
120        }
121        if let Ok(mut grace) = self.grace_used.lock()
122            && let Some(session_grace) = grace.get_mut(&session_id.id)
123        {
124            session_grace.remove(tool_name);
125        }
126    }
127
128    /// Reset all failure counts, error messages, and grace flags for a session.
129    /// Call this when a new turn starts.
130    pub fn reset_session(&self, session_id: &SessionId) {
131        if let Ok(mut counts) = self.failure_counts.lock() {
132            counts.remove(&session_id.id);
133        }
134        if let Ok(mut msgs) = self.error_messages.lock() {
135            msgs.remove(&session_id.id);
136        }
137        if let Ok(mut grace) = self.grace_used.lock() {
138            grace.remove(&session_id.id);
139        }
140    }
141}
142
143impl ToolErrorRecovery for ConsecutiveFailureRecovery {
144    fn on_error(
145        &self,
146        session_id: &SessionId,
147        tool_names: &[String],
148        error: &AgentError,
149    ) -> AgentResult<ToolErrorAction> {
150        let mut counts = self
151            .failure_counts
152            .lock()
153            .map_err(|e| AgentError::internal(format!("Failed to lock failure counts: {}", e)))?;
154        let mut msgs = self
155            .error_messages
156            .lock()
157            .map_err(|e| AgentError::internal(format!("Failed to lock error messages: {}", e)))?;
158        let mut grace = self
159            .grace_used
160            .lock()
161            .map_err(|e| AgentError::internal(format!("Failed to lock grace_used: {}", e)))?;
162
163        let session_counts = counts.entry(session_id.id).or_insert_with(HashMap::new);
164        let session_msgs = msgs.entry(session_id.id).or_insert_with(HashMap::new);
165        let session_grace = grace.entry(session_id.id).or_insert_with(HashMap::new);
166
167        // Record error message and increment failure count for each failing tool
168        let error_text = error.to_string();
169        let mut max_failures = 0;
170        let mut threshold_tool: Option<String> = None;
171        for name in tool_names {
172            let count = session_counts.entry(name.clone()).or_insert(0);
173            *count += 1;
174            session_msgs
175                .entry(name.clone())
176                .or_insert_with(Vec::new)
177                .push(error_text.clone());
178            if *count > max_failures {
179                max_failures = *count;
180            }
181            if *count >= self.max_consecutive_failures {
182                threshold_tool = Some(name.clone());
183            }
184        }
185
186        if max_failures >= self.max_consecutive_failures {
187            let tool_name = threshold_tool.unwrap_or_else(|| tool_names[0].clone());
188
189            tracing::warn!(
190                session_id = session_id.id,
191                tool = %tool_name,
192                failures = max_failures,
193                max_consecutive_failures = self.max_consecutive_failures,
194                "ConsecutiveFailureRecovery: threshold reached"
195            );
196
197            // Check if grace was already used for this tool
198            let already_used = session_grace.get(&tool_name).copied().unwrap_or(false);
199
200            if already_used {
201                // Grace exhausted → hard stop. Clear all state for this session.
202                counts.remove(&session_id.id);
203                msgs.remove(&session_id.id);
204                grace.remove(&session_id.id);
205                return Ok(ToolErrorAction::Stop);
206            }
207
208            // First time at threshold → give LLM one grace round
209            session_grace.insert(tool_name.clone(), true);
210
211            let errors = session_msgs.get(&tool_name).cloned().unwrap_or_default();
212
213            // Clear messages but keep counts and grace flag so that the next
214            // failure for the same tool triggers Stop (not another RetryWithHistory).
215            msgs.remove(&session_id.id);
216
217            return Ok(ToolErrorAction::RetryWithHistory { errors });
218        }
219
220        Ok(ToolErrorAction::Retry)
221    }
222
223    fn on_success(&self, session_id: &SessionId, tool_name: &str) {
224        self.reset_failures(session_id, tool_name);
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn stop_on_error_always_stops() {
234        let recovery = StopOnError;
235        let session_id = SessionId::new(1);
236        let names = vec!["tool_a".to_string()];
237        let error = AgentError::internal("test error");
238        assert_eq!(
239            recovery.on_error(&session_id, &names, &error).unwrap(),
240            ToolErrorAction::Stop
241        );
242    }
243
244    #[test]
245    fn retry_on_error_always_retries() {
246        let recovery = RetryOnError;
247        let session_id = SessionId::new(1);
248        let names = vec!["tool_a".to_string()];
249        let error = AgentError::internal("test error");
250        assert_eq!(
251            recovery.on_error(&session_id, &names, &error).unwrap(),
252            ToolErrorAction::Retry
253        );
254    }
255
256    // ── ConsecutiveFailureRecovery: basic flow ───────────────────────
257
258    #[test]
259    fn consecutive_failure_retries_then_retry_with_history() {
260        let recovery = ConsecutiveFailureRecovery::new(3);
261        let session_id = SessionId::new(1);
262        let names = vec!["tool_a".to_string()];
263        let error = AgentError::internal("test error");
264
265        // First two failures: retry
266        assert_eq!(
267            recovery.on_error(&session_id, &names, &error).unwrap(),
268            ToolErrorAction::Retry
269        );
270        assert_eq!(
271            recovery.on_error(&session_id, &names, &error).unwrap(),
272            ToolErrorAction::Retry
273        );
274
275        // Third failure: RetryWithHistory (grace round)
276        let action = recovery.on_error(&session_id, &names, &error).unwrap();
277        assert!(matches!(action, ToolErrorAction::RetryWithHistory { .. }));
278    }
279
280    #[test]
281    fn consecutive_failure_stop_after_grace() {
282        let recovery = ConsecutiveFailureRecovery::new(3);
283        let session_id = SessionId::new(1);
284        let names = vec!["tool_a".to_string()];
285        let error = AgentError::internal("test error");
286
287        // 3 failures → RetryWithHistory (grace)
288        for _ in 0..2 {
289            recovery.on_error(&session_id, &names, &error).unwrap();
290        }
291        let action = recovery.on_error(&session_id, &names, &error).unwrap();
292        assert!(matches!(action, ToolErrorAction::RetryWithHistory { .. }));
293
294        // Counts NOT cleared — tool_a is still at count=3, grace_used=true.
295        // Next failure (count=4) hits threshold → grace already used → Stop.
296        let action = recovery.on_error(&session_id, &names, &error).unwrap();
297        assert!(
298            matches!(action, ToolErrorAction::Stop),
299            "should Stop after grace exhausted, got {:?}",
300            action
301        );
302    }
303
304    #[test]
305    fn consecutive_failure_retry_with_history_collects_errors() {
306        let recovery = ConsecutiveFailureRecovery::new(2);
307        let session_id = SessionId::new(1);
308        let names = vec!["tool_a".to_string()];
309
310        // Two failures with different error messages
311        let error1 = AgentError::internal("first error");
312        let error2 = AgentError::internal("second error");
313
314        recovery.on_error(&session_id, &names, &error1).unwrap();
315        let action = recovery.on_error(&session_id, &names, &error2).unwrap();
316
317        match action {
318            ToolErrorAction::RetryWithHistory { errors } => {
319                assert_eq!(errors.len(), 2);
320                assert!(errors[0].contains("first error"));
321                assert!(errors[1].contains("second error"));
322            }
323            _ => panic!("expected RetryWithHistory, got {:?}", action),
324        }
325    }
326
327    // ── ConsecutiveFailureRecovery: reset behavior ───────────────────
328
329    #[test]
330    fn consecutive_failure_resets_on_success() {
331        let recovery = ConsecutiveFailureRecovery::new(3);
332        let session_id = SessionId::new(1);
333        let names = vec!["tool_a".to_string()];
334        let error = AgentError::internal("test error");
335
336        // Two failures
337        recovery.on_error(&session_id, &names, &error).unwrap();
338        recovery.on_error(&session_id, &names, &error).unwrap();
339
340        // Reset on success
341        recovery.reset_failures(&session_id, "tool_a");
342
343        // Should retry again (counter reset)
344        assert_eq!(
345            recovery.on_error(&session_id, &names, &error).unwrap(),
346            ToolErrorAction::Retry
347        );
348    }
349
350    #[test]
351    fn consecutive_failure_on_success_resets_via_trait() {
352        let recovery = ConsecutiveFailureRecovery::new(2);
353        let session_id = SessionId::new(1);
354        let names = vec!["tool_a".to_string()];
355        let error = AgentError::internal("test error");
356
357        // One failure (below the threshold of 2)
358        assert_eq!(
359            recovery.on_error(&session_id, &names, &error).unwrap(),
360            ToolErrorAction::Retry
361        );
362
363        // A successful execution breaks the streak (this is what the react loop calls).
364        recovery.on_success(&session_id, "tool_a");
365
366        // The next failure starts a fresh streak: retry again, not stop.
367        assert_eq!(
368            recovery.on_error(&session_id, &names, &error).unwrap(),
369            ToolErrorAction::Retry
370        );
371    }
372
373    #[test]
374    fn consecutive_failure_on_success_resets_grace() {
375        let recovery = ConsecutiveFailureRecovery::new(2);
376        let session_id = SessionId::new(1);
377        let names = vec!["tool_a".to_string()];
378        let error = AgentError::internal("test error");
379
380        // 2 failures → RetryWithHistory
381        recovery.on_error(&session_id, &names, &error).unwrap();
382        let action = recovery.on_error(&session_id, &names, &error).unwrap();
383        assert!(matches!(action, ToolErrorAction::RetryWithHistory { .. }));
384
385        // Tool succeeds → reset
386        recovery.on_success(&session_id, "tool_a");
387
388        // Next 2 failures → should get RetryWithHistory again (grace reset)
389        recovery.on_error(&session_id, &names, &error).unwrap();
390        let action = recovery.on_error(&session_id, &names, &error).unwrap();
391        assert!(
392            matches!(action, ToolErrorAction::RetryWithHistory { .. }),
393            "grace should be reusable after success, got {:?}",
394            action
395        );
396    }
397
398    #[test]
399    fn consecutive_failure_resets_clears_error_messages() {
400        let recovery = ConsecutiveFailureRecovery::new(3);
401        let session_id = SessionId::new(1);
402        let names = vec!["tool_a".to_string()];
403        let error = AgentError::internal("test error");
404
405        // Two failures → error messages accumulated
406        recovery.on_error(&session_id, &names, &error).unwrap();
407        recovery.on_error(&session_id, &names, &error).unwrap();
408
409        // Reset
410        recovery.reset_failures(&session_id, "tool_a");
411
412        // Next failure → if we reach threshold, error messages should only
413        // contain the new ones (not the old ones before reset)
414        let error_new = AgentError::internal("new error");
415        recovery.on_error(&session_id, &names, &error_new).unwrap();
416        recovery.on_error(&session_id, &names, &error_new).unwrap();
417        let action = recovery.on_error(&session_id, &names, &error_new).unwrap();
418        match action {
419            ToolErrorAction::RetryWithHistory { errors } => {
420                assert_eq!(errors.len(), 3);
421                for e in &errors {
422                    assert!(e.contains("new error"), "old errors should be cleared");
423                }
424            }
425            _ => panic!("expected RetryWithHistory, got {:?}", action),
426        }
427    }
428
429    #[test]
430    fn consecutive_failure_resets_session_clears_everything() {
431        let recovery = ConsecutiveFailureRecovery::new(2);
432        let session_id = SessionId::new(1);
433        let names = vec!["tool_a".to_string()];
434        let error = AgentError::internal("test error");
435
436        // 2 failures → RetryWithHistory
437        recovery.on_error(&session_id, &names, &error).unwrap();
438        let action = recovery.on_error(&session_id, &names, &error).unwrap();
439        assert!(matches!(action, ToolErrorAction::RetryWithHistory { .. }));
440
441        // Reset session
442        recovery.reset_session(&session_id);
443
444        // Should retry again (fresh session)
445        assert_eq!(
446            recovery.on_error(&session_id, &names, &error).unwrap(),
447            ToolErrorAction::Retry
448        );
449    }
450
451    // ── ConsecutiveFailureRecovery: session isolation ─────────────────
452
453    #[test]
454    fn consecutive_failure_per_session_isolation() {
455        let recovery = ConsecutiveFailureRecovery::new(2);
456        let session1 = SessionId::new(1);
457        let session2 = SessionId::new(2);
458        let names = vec!["tool_a".to_string()];
459        let error = AgentError::internal("test error");
460
461        // Session 1: two failures → RetryWithHistory
462        recovery.on_error(&session1, &names, &error).unwrap();
463        let action = recovery.on_error(&session1, &names, &error).unwrap();
464        assert!(matches!(action, ToolErrorAction::RetryWithHistory { .. }));
465
466        // Session 1: one more failure → Stop (grace already used)
467        let action = recovery.on_error(&session1, &names, &error).unwrap();
468        assert!(
469            matches!(action, ToolErrorAction::Stop),
470            "session 1 should Stop after grace exhausted, got {:?}",
471            action
472        );
473
474        // Session 2: should still retry (isolated, no grace used)
475        assert_eq!(
476            recovery.on_error(&session2, &names, &error).unwrap(),
477            ToolErrorAction::Retry
478        );
479    }
480
481    // ── ConsecutiveFailureRecovery: different tools independent ───────
482
483    #[test]
484    fn consecutive_failure_different_tools_independent() {
485        let recovery = ConsecutiveFailureRecovery::new(2);
486        let session_id = SessionId::new(1);
487        let error = AgentError::internal("test error");
488
489        // tool_a: 1 failure
490        recovery
491            .on_error(&session_id, &["tool_a".to_string()], &error)
492            .unwrap();
493
494        // tool_b: 1 failure (independent counter)
495        assert_eq!(
496            recovery
497                .on_error(&session_id, &["tool_b".to_string()], &error,)
498                .unwrap(),
499            ToolErrorAction::Retry
500        );
501
502        // tool_a: 2nd failure → RetryWithHistory (not affected by tool_b)
503        let action = recovery
504            .on_error(&session_id, &["tool_a".to_string()], &error)
505            .unwrap();
506        assert!(
507            matches!(action, ToolErrorAction::RetryWithHistory { .. }),
508            "tool_a should trigger RetryWithHistory independently of tool_b"
509        );
510    }
511
512    // ── ConsecutiveFailureRecovery: ToolArgsInvalid error messages ────
513
514    #[test]
515    fn consecutive_failure_records_serde_error_details() {
516        let recovery = ConsecutiveFailureRecovery::new(2);
517        let session_id = SessionId::new(1);
518        let names = vec!["write_file".to_string()];
519
520        // Simulate the improved serde error message
521        let error = AgentError::ToolArgsInvalid {
522            name: "write_file".to_string(),
523            raw: "missing field `path` at line 1 column 2 (args: {})".to_string(),
524        };
525
526        recovery.on_error(&session_id, &names, &error).unwrap();
527        let action = recovery.on_error(&session_id, &names, &error).unwrap();
528
529        match action {
530            ToolErrorAction::RetryWithHistory { errors } => {
531                assert_eq!(errors.len(), 2);
532                assert!(
533                    errors[0].contains("missing field"),
534                    "should contain serde error details"
535                );
536            }
537            _ => panic!("expected RetryWithHistory, got {:?}", action),
538        }
539    }
540}