1use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14pub 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
29pub 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GuardrailConfig {
56 pub warnings_enabled: bool,
57 pub hard_stop_enabled: bool,
58 pub exact_failure_warn_after: usize,
60 pub exact_failure_block_after: usize,
62 pub same_tool_failure_warn_after: usize,
64 pub same_tool_failure_halt_after: usize,
66 pub no_progress_warn_after: usize,
68 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#[derive(Debug, Clone)]
89pub enum GuardrailDecision {
90 Proceed,
92 Warn(String),
94 Stop(String),
96}
97
98pub 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 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 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 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 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 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 pub fn is_idempotent(name: &str) -> bool {
210 IDEMPOTENT_TOOLS.contains(&name)
211 }
212
213 pub fn is_mutating(name: &str) -> bool {
215 MUTATING_TOOLS.contains(&name)
216 }
217
218 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}