Skip to main content

apollo/tools/
guardrails.rs

1//! Tool call guardrails — loop detection, failure counting, idempotent/mutating classification.
2//!
3//! Port from hermes-agent `tool_guardrails.py`. Tracks per-turn tool-call observations
4//! and returns guardrail decisions.
5//!
6//! pontytail: single-threaded, in-memory history. Per-chat isolation is handled by
7//! the HashMap<String, ToolGuardrails> in the agent loop.
8
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14/// Tools safe to retry (read-only, no side effects)
15pub const IDEMPOTENT_TOOLS: &[&str] = &[
16    "read",
17    "file_ops",
18    "web_search",
19    "web_fetch",
20    "search_files",
21    "session_status",
22    "list_models",
23    "memory_search",
24    "tool_search",
25    "brief",
26    "sleep_tool",
27];
28
29/// Tools that mutate state (dangerous to repeat blindly)
30pub const MUTATING_TOOLS: &[&str] = &[
31    "shell",
32    "write",
33    "edit",
34    "vibemania",
35    "coding_swarm",
36    "message",
37    "todo_write",
38    "cron_tool",
39    "config_tool",
40    "mode_switch",
41    "skill_manager",
42];
43
44/// A single tool call record for guardrail analysis.
45#[derive(Debug, Clone)]
46pub struct ToolCallRecord {
47    pub name: String,
48    pub arguments_hash: String,
49    pub timestamp: Instant,
50    pub is_error: bool,
51}
52
53/// Guardrail configuration thresholds.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GuardrailConfig {
56    pub warnings_enabled: bool,
57    pub hard_stop_enabled: bool,
58    /// Consecutive errors on same tool before warning
59    pub exact_failure_warn_after: usize,
60    /// Consecutive errors on same tool before blocking
61    pub exact_failure_block_after: usize,
62    /// Repeated identical calls before warning
63    pub same_tool_failure_warn_after: usize,
64    /// Repeated identical calls before halting
65    pub same_tool_failure_halt_after: usize,
66    /// No-progress warnings
67    pub no_progress_warn_after: usize,
68    /// No-progress hard stop
69    pub no_progress_block_after: usize,
70}
71
72impl Default for GuardrailConfig {
73    fn default() -> Self {
74        Self {
75            warnings_enabled: true,
76            hard_stop_enabled: false,
77            exact_failure_warn_after: 2,
78            exact_failure_block_after: 5,
79            same_tool_failure_warn_after: 3,
80            same_tool_failure_halt_after: 8,
81            no_progress_warn_after: 2,
82            no_progress_block_after: 5,
83        }
84    }
85}
86
87/// Decision returned by the guardrail after observing a tool call.
88#[derive(Debug, Clone)]
89pub enum GuardrailDecision {
90    /// Proceed normally
91    Proceed,
92    /// Warn the LLM but allow execution
93    Warn(String),
94    /// Hard stop - return this response to the user
95    Stop(String),
96}
97
98/// Tool call guardrails — pure analysis, no side effects.
99pub struct ToolGuardrails {
100    config: GuardrailConfig,
101    history: Vec<ToolCallRecord>,
102    identical_call_count: usize,
103    last_call_identity: Option<(String, String)>,
104}
105
106impl ToolGuardrails {
107    pub fn new(config: GuardrailConfig) -> Self {
108        Self {
109            config,
110            history: Vec::with_capacity(64),
111            identical_call_count: 0,
112            last_call_identity: None,
113        }
114    }
115
116    /// Observe a tool call and return the guardrail decision.
117    pub fn observe(
118        &mut self,
119        name: &str,
120        args: &str,
121        _output: &str,
122        is_error: bool,
123    ) -> GuardrailDecision {
124        let hash = hash_args(args);
125        let identity = (name.to_string(), hash.clone());
126
127        // Track identical consecutive calls
128        if Some(&identity) == self.last_call_identity.as_ref() {
129            self.identical_call_count += 1;
130        } else {
131            self.identical_call_count = 0;
132            self.last_call_identity = Some(identity);
133        }
134
135        // Loop detection: identical consecutive calls
136        if self.identical_call_count >= self.config.same_tool_failure_halt_after
137            && self.config.hard_stop_enabled
138        {
139            tracing::warn!(
140                "[guardrails] Stop: {} called identically {} times",
141                name,
142                self.identical_call_count + 1
143            );
144            return GuardrailDecision::Stop(format!(
145                "Same tool call '{}' repeated {} times — loop detected. Breaking.",
146                name,
147                self.identical_call_count + 1
148            ));
149        }
150        if self.config.warnings_enabled
151            && self.identical_call_count >= self.config.same_tool_failure_warn_after
152        {
153            tracing::warn!(
154                "[guardrails] Warn: {} repeated {} times",
155                name,
156                self.identical_call_count + 1
157            );
158            return GuardrailDecision::Warn(format!(
159                "WARNING: You're calling '{}' identically ({}x). Stop and answer with what you have.",
160                name,
161                self.identical_call_count + 1
162            ));
163        }
164
165        // Error counting
166        if is_error {
167            let error_count = self
168                .history
169                .iter()
170                .rev()
171                .take_while(|r| r.name == name && r.is_error)
172                .count()
173                + 1;
174
175            if self.config.hard_stop_enabled && error_count >= self.config.exact_failure_block_after
176            {
177                tracing::warn!(
178                    "[guardrails] Stop: {} failed {} consecutive times",
179                    name,
180                    error_count
181                );
182                return GuardrailDecision::Stop(format!(
183                    "Tool '{}' failed {} consecutive times — blocking further attempts.",
184                    name, error_count
185                ));
186            }
187            if self.config.warnings_enabled && error_count >= self.config.exact_failure_warn_after {
188                tracing::warn!("[guardrails] Warn: {} failed {} times", name, error_count);
189                return GuardrailDecision::Warn(format!(
190                    "WARNING: Tool '{}' keeps failing ({} consecutive errors). Try a different approach.",
191                    name,
192                    error_count
193                ));
194            }
195        }
196
197        // Record the call
198        self.history.push(ToolCallRecord {
199            name: name.to_string(),
200            arguments_hash: hash,
201            timestamp: Instant::now(),
202            is_error,
203        });
204
205        GuardrailDecision::Proceed
206    }
207
208    /// Check if a tool is idempotent (safe to retry).
209    pub fn is_idempotent(name: &str) -> bool {
210        IDEMPOTENT_TOOLS.contains(&name)
211    }
212
213    /// Check if a tool is mutating (has side effects).
214    pub fn is_mutating(name: &str) -> bool {
215        MUTATING_TOOLS.contains(&name)
216    }
217
218    /// Reset for a new turn.
219    pub fn reset(&mut self) {
220        self.history.clear();
221        self.identical_call_count = 0;
222        self.last_call_identity = None;
223    }
224}
225
226fn hash_args(args: &str) -> String {
227    let mut hasher = Sha256::new();
228    hasher.update(args.as_bytes());
229    format!("{:x}", hasher.finalize())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_allow_first_call() {
238        let mut g = ToolGuardrails::new(GuardrailConfig::default());
239        let decision = g.observe("read", r#"{"path":"test"}"#, "", false);
240        assert!(matches!(decision, GuardrailDecision::Proceed));
241    }
242
243    #[test]
244    fn test_loop_warn() {
245        let config = GuardrailConfig {
246            same_tool_failure_warn_after: 3,
247            ..Default::default()
248        };
249        let mut g = ToolGuardrails::new(config);
250
251        for _ in 0..3 {
252            let _ = g.observe("shell", r#"{"command":"ls"}"#, "", false);
253        }
254        let decision = g.observe("shell", r#"{"command":"ls"}"#, "", false);
255        assert!(matches!(decision, GuardrailDecision::Warn(_)));
256    }
257
258    #[test]
259    fn test_loop_stop() {
260        let config = GuardrailConfig {
261            hard_stop_enabled: true,
262            same_tool_failure_halt_after: 3,
263            ..Default::default()
264        };
265        let mut g = ToolGuardrails::new(config);
266
267        for _ in 0..3 {
268            let _ = g.observe("shell", r#"{"command":"ls"}"#, "", false);
269        }
270        let decision = g.observe("shell", r#"{"command":"ls"}"#, "", false);
271        assert!(matches!(decision, GuardrailDecision::Stop(_)));
272    }
273
274    #[test]
275    fn test_reset() {
276        let config = GuardrailConfig {
277            same_tool_failure_warn_after: 3,
278            ..Default::default()
279        };
280        let mut g = ToolGuardrails::new(config);
281
282        for _ in 0..4 {
283            let _ = g.observe("read", r#"{"path":"x"}"#, "", false);
284        }
285
286        g.reset();
287        let decision = g.observe("read", r#"{"path":"x"}"#, "", false);
288        assert!(matches!(decision, GuardrailDecision::Proceed));
289    }
290
291    #[test]
292    fn test_different_args_no_loop() {
293        let config = GuardrailConfig {
294            same_tool_failure_warn_after: 3,
295            ..Default::default()
296        };
297        let mut g = ToolGuardrails::new(config);
298
299        let _ = g.observe("shell", r#"{"command":"ls"}"#, "", false);
300        let decision = g.observe("shell", r#"{"command":"pwd"}"#, "", false);
301        assert!(matches!(decision, GuardrailDecision::Proceed));
302    }
303
304    #[test]
305    fn test_error_detection() {
306        let config = GuardrailConfig {
307            exact_failure_warn_after: 2,
308            ..Default::default()
309        };
310        let mut g = ToolGuardrails::new(config);
311
312        let _ = g.observe("shell", r#"{"command":"ls"}"#, "", true);
313        let decision = g.observe("shell", r#"{"command":"ls"}"#, "", true);
314        assert!(matches!(decision, GuardrailDecision::Warn(_)));
315    }
316
317    #[test]
318    fn test_idempotent_classification() {
319        assert!(ToolGuardrails::is_idempotent("read"));
320        assert!(ToolGuardrails::is_idempotent("web_search"));
321        assert!(!ToolGuardrails::is_idempotent("shell"));
322        assert!(ToolGuardrails::is_mutating("shell"));
323        assert!(ToolGuardrails::is_mutating("write"));
324        assert!(!ToolGuardrails::is_mutating("read"));
325    }
326}