1use serde_json::Value;
22
23#[derive(Debug, Clone)]
25pub struct ToolCallLoopGuardOptions {
26 pub threshold: usize,
28 pub exempt_tools: Vec<String>,
30}
31
32impl Default for ToolCallLoopGuardOptions {
33 fn default() -> Self {
34 Self {
35 threshold: 5,
36 exempt_tools: vec!["read".into(), "ls".into(), "grep".into()],
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq)]
44pub struct RepeatedToolCallDetection {
45 pub tool_name: String,
47 pub count: usize,
49 pub result_summary: String,
51 pub arguments_summary: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct ToolCallLoopTurn<'a> {
58 pub tool_calls: &'a [ToolCallRef],
61 pub tool_results: &'a [ToolResultRef],
64}
65
66#[derive(Debug, Clone)]
69pub struct ToolCallRef {
70 pub id: String,
72 pub name: String,
74 pub arguments: Value,
77}
78
79#[derive(Debug, Clone)]
81pub struct ToolResultRef {
82 pub tool_call_id: String,
84 pub content: String,
87}
88
89const RESULT_SUMMARY_LIMIT: usize = 200;
91const ARGUMENT_SUMMARY_LIMIT: usize = 400;
93
94#[derive(Debug)]
97pub struct ToolCallLoopGuard {
98 threshold: usize,
99 exempt_tools: std::collections::HashSet<String>,
100 last_hash: Option<String>,
101 count: usize,
102}
103
104impl Default for ToolCallLoopGuard {
105 fn default() -> Self {
106 Self::new(ToolCallLoopGuardOptions::default())
107 }
108}
109
110impl ToolCallLoopGuard {
111 pub fn new(options: ToolCallLoopGuardOptions) -> Self {
113 Self {
114 threshold: options.threshold.max(1),
115 exempt_tools: options.exempt_tools.into_iter().collect(),
116 last_hash: None,
117 count: 0,
118 }
119 }
120
121 pub fn with_threshold(mut self, threshold: usize) -> Self {
123 self.threshold = threshold.max(1);
124 self
125 }
126
127 pub fn with_exempt_tool(mut self, tool: impl Into<String>) -> Self {
129 self.exempt_tools.insert(tool.into());
130 self
131 }
132
133 pub fn record_turn(&mut self, turn: ToolCallLoopTurn<'_>) -> Option<RepeatedToolCallDetection> {
139 if turn.tool_calls.len() != 1 {
143 self.last_hash = None;
144 self.count = 0;
145 return None;
146 }
147 let tool_call = &turn.tool_calls[0];
148 if self.exempt_tools.contains(&tool_call.name) {
149 self.last_hash = None;
150 self.count = 0;
151 return None;
152 }
153
154 let canonical_args = canonicalize_json(&tool_call.arguments);
155 let canonical_str =
156 serde_json::to_string(&canonical_args).unwrap_or_else(|_| "<?>".to_string());
157 let hash = format!("{}:{}", tool_call.name, canonical_str);
158
159 if Some(&hash) == self.last_hash.as_ref() {
160 self.count += 1;
161 } else {
162 self.last_hash = Some(hash);
163 self.count = 1;
164 }
165
166 if self.count != self.threshold {
167 return None;
168 }
169
170 Some(RepeatedToolCallDetection {
171 tool_name: tool_call.name.clone(),
172 count: self.count,
173 result_summary: summarize_tool_result(turn.tool_results, &tool_call.id),
174 arguments_summary: summarize_text(&canonical_str, ARGUMENT_SUMMARY_LIMIT),
175 })
176 }
177
178 pub fn reset(&mut self) {
180 self.last_hash = None;
181 self.count = 0;
182 }
183}
184
185fn canonicalize_json(value: &Value) -> Value {
188 match value {
189 Value::Object(map) => {
190 use serde_json::Map;
191 let mut sorted: Vec<(String, Value)> = map
192 .iter()
193 .map(|(k, v)| (k.clone(), canonicalize_json(v)))
194 .collect();
195 sorted.sort_by(|a, b| a.0.cmp(&b.0));
196 let mut out = Map::new();
197 for (k, v) in sorted {
198 out.insert(k, v);
199 }
200 Value::Object(out)
201 }
202 Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
203 _ => value.clone(),
204 }
205}
206
207fn summarize_text(text: &str, limit: usize) -> String {
208 let s = text.trim();
209 if s.chars().count() <= limit {
210 return s.to_string();
211 }
212 let truncated: String = s.chars().take(limit.saturating_sub(1)).collect();
213 format!("{truncated}…")
214}
215
216fn summarize_tool_result(results: &[ToolResultRef], tool_call_id: &str) -> String {
217 let matching = results
218 .iter()
219 .find(|r| r.tool_call_id == tool_call_id)
220 .map(|r| r.content.clone())
221 .unwrap_or_default();
222 summarize_text(&matching, RESULT_SUMMARY_LIMIT)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use serde_json::json;
229
230 fn call(name: &str, args: Value) -> ToolCallRef {
231 ToolCallRef {
232 id: format!("{name}-id"),
233 name: name.into(),
234 arguments: args,
235 }
236 }
237
238 fn result_for(id: &str, content: &str) -> ToolResultRef {
239 ToolResultRef {
240 tool_call_id: id.into(),
241 content: content.into(),
242 }
243 }
244
245 #[test]
246 fn default_threshold_is_five_with_read_ls_grep_exempt() {
247 let g = ToolCallLoopGuard::default();
248 assert_eq!(g.threshold, 5);
249 assert!(g.exempt_tools.contains("read"));
250 assert!(g.exempt_tools.contains("ls"));
251 assert!(g.exempt_tools.contains("grep"));
252 }
253
254 #[test]
255 fn fires_at_threshold_for_identical_single_call() {
256 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
257 threshold: 3,
258 exempt_tools: vec![],
259 });
260 let c = call("write", json!({"path": "/a", "content": "x"}));
261 let r = result_for(&c.id, "ok");
262 let turn = ToolCallLoopTurn {
263 tool_calls: std::slice::from_ref(&c),
264 tool_results: std::slice::from_ref(&r),
265 };
266 assert!(g.record_turn(turn.clone()).is_none());
267 assert!(g.record_turn(turn.clone()).is_none());
268 let hit = g.record_turn(turn).expect("third call should trip");
269 assert_eq!(hit.tool_name, "write");
270 assert_eq!(hit.count, 3);
271 assert_eq!(hit.result_summary, "ok");
272 assert!(hit.arguments_summary.contains("\"path\""));
273 }
274
275 #[test]
276 fn exempt_tool_resets_state() {
277 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
278 threshold: 2,
279 exempt_tools: vec!["read".into()],
280 });
281 let c = call("read", json!({"path": "/a"}));
282 let turn = ToolCallLoopTurn {
283 tool_calls: std::slice::from_ref(&c),
284 tool_results: &[],
285 };
286 assert!(g.record_turn(turn.clone()).is_none());
288 assert!(g.record_turn(turn).is_none());
289 }
290
291 #[test]
292 fn multi_call_turn_resets_state() {
293 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
294 threshold: 2,
295 exempt_tools: vec![],
296 });
297 let c1 = call("write", json!({"path": "/a"}));
298 let c2 = call("write", json!({"path": "/b"}));
299 let multi = ToolCallLoopTurn {
300 tool_calls: &[c1, c2],
301 tool_results: &[],
302 };
303 assert!(g.record_turn(multi).is_none());
304 let c = call("write", json!({"path": "/a"}));
307 let single = ToolCallLoopTurn {
308 tool_calls: std::slice::from_ref(&c),
309 tool_results: &[],
310 };
311 assert!(g.record_turn(single).is_none());
312 }
313
314 #[test]
315 fn different_arguments_reset_state() {
316 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
317 threshold: 3,
318 exempt_tools: vec![],
319 });
320 let c1 = call("write", json!({"path": "/a"}));
321 let c2 = call("write", json!({"path": "/b"}));
322 let t1 = ToolCallLoopTurn {
323 tool_calls: std::slice::from_ref(&c1),
324 tool_results: &[],
325 };
326 let t2 = ToolCallLoopTurn {
327 tool_calls: std::slice::from_ref(&c2),
328 tool_results: &[],
329 };
330 assert!(g.record_turn(t1).is_none());
333 assert!(g.record_turn(t2).is_none());
334 }
335
336 #[test]
337 fn argument_key_order_is_canonicalized() {
338 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
339 threshold: 2,
340 exempt_tools: vec![],
341 });
342 let c1 = call("write", json!({"a": 1, "b": 2}));
343 let c2 = call("write", json!({"b": 2, "a": 1}));
344 let t1 = ToolCallLoopTurn {
345 tool_calls: std::slice::from_ref(&c1),
346 tool_results: &[],
347 };
348 let t2 = ToolCallLoopTurn {
349 tool_calls: std::slice::from_ref(&c2),
350 tool_results: &[],
351 };
352 assert!(g.record_turn(t1).is_none());
353 let hit = g.record_turn(t2).expect("key order should not matter");
354 assert_eq!(hit.count, 2);
355 }
356
357 #[test]
358 fn reset_clears_state() {
359 let mut g = ToolCallLoopGuard::new(ToolCallLoopGuardOptions {
360 threshold: 2,
361 exempt_tools: vec![],
362 });
363 let c = call("write", json!({"path": "/a"}));
364 let t = ToolCallLoopTurn {
365 tool_calls: std::slice::from_ref(&c),
366 tool_results: &[],
367 };
368 g.record_turn(t.clone());
369 g.reset();
370 assert_eq!(g.count, 0);
371 assert!(g.last_hash.is_none());
372 }
373
374 #[test]
375 fn summarize_text_truncates_with_ellipsis() {
376 let s = "x".repeat(100);
377 let out = summarize_text(&s, 10);
378 assert_eq!(out.chars().count(), 10);
379 assert!(out.ends_with('…'));
380 }
381
382 #[test]
383 fn summarize_text_short_passthrough() {
384 let out = summarize_text("hello", 10);
385 assert_eq!(out, "hello");
386 }
387
388 #[test]
389 fn summarize_tool_result_truncates_and_matches_id() {
390 let r = result_for("abc", &"y".repeat(500));
391 let out = summarize_tool_result(std::slice::from_ref(&r), "abc");
392 assert_eq!(out.chars().count(), RESULT_SUMMARY_LIMIT);
393 assert!(out.ends_with('…'));
394 }
395
396 #[test]
397 fn summarize_tool_result_missing_id_returns_empty() {
398 let r = result_for("other", "content");
399 let out = summarize_tool_result(std::slice::from_ref(&r), "missing");
400 assert!(out.is_empty());
401 }
402
403 #[test]
404 fn canonicalize_json_sorts_object_keys_recursively() {
405 let v = json!({"z": 1, "a": {"y": 2, "b": 3}});
406 let c = canonicalize_json(&v);
407 let s = serde_json::to_string(&c).unwrap();
408 assert!(s.find("\"a\"").unwrap() < s.find("\"z\"").unwrap());
410 let nested_start = s.find("\"y\"").unwrap();
412 let nested_b = s.find("\"b\"").unwrap();
413 assert!(nested_b < nested_start);
414 }
415}