Skip to main content

oxi_ai/utils/
tool_call_loop.rs

1//! Cross-turn tool-call loop guard — ported from omp
2//! `packages/ai/src/utils/tool-call-loop-guard.ts`.
3//!
4//! MIT — attribution: adapted from
5//! [omp](https://github.com/can1357/oh-my-pi) (Can Berk Güder, earendil-works).
6//!
7//! ## Purpose
8//!
9//! Models occasionally fixate on a single tool call: the same name and
10//! arguments across consecutive turns, with the same (often unhelpful)
11//! result. Left unchecked the loop burns the context window and the
12//! user's quota indefinitely.
13//!
14//! [`ToolCallLoopGuard`] records each completed assistant turn and fires
15//! when the *same* single-tool call (modulo argument key ordering) hits
16//! a configurable threshold. Multi-call turns reset detection.
17//!
18//! Exempt tools (e.g. `read` for progressive file exploration, `ls` for
19//! directory walks) bypass detection — their repetition is legitimate.
20
21use serde_json::Value;
22
23/// Runtime settings for cross-turn tool-call repetition detection.
24#[derive(Debug, Clone)]
25pub struct ToolCallLoopGuardOptions {
26    /// Consecutive identical calls that trip the guard. Clamped to ≥ 1.
27    pub threshold: usize,
28    /// Tool names exempt from detection (e.g. `read`, `ls`, `grep`).
29    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/// Details surfaced when a loop is recognised. Hosts can render these
42/// into a steering message or abort the agent run.
43#[derive(Debug, Clone, PartialEq)]
44pub struct RepeatedToolCallDetection {
45    /// The tool that was repeated.
46    pub tool_name: String,
47    /// Consecutive identical calls observed.
48    pub count: usize,
49    /// Truncated preview of the most recent tool result.
50    pub result_summary: String,
51    /// Truncated preview of the canonical arguments JSON.
52    pub arguments_summary: String,
53}
54
55/// One completed assistant turn for the guard to evaluate.
56#[derive(Debug, Clone)]
57pub struct ToolCallLoopTurn<'a> {
58    /// Tool calls in the assistant message. Only single-call turns are
59    /// considered for repetition — multi-call turns reset detection.
60    pub tool_calls: &'a [ToolCallRef],
61    /// Results returned for the call with matching `tool_call_id`, in
62    /// any order. Empty if none (the call is still in flight).
63    pub tool_results: &'a [ToolResultRef],
64}
65
66/// A tool call as seen by the guard. Hosts adapt their provider-native
67/// types into this shape.
68#[derive(Debug, Clone)]
69pub struct ToolCallRef {
70    /// Tool-call identifier — used to match results to calls.
71    pub id: String,
72    /// Tool name (`read`, `write`, `bash`, …).
73    pub name: String,
74    /// JSON-encoded arguments. Canonicalised before hashing so key
75    /// ordering does not matter.
76    pub arguments: Value,
77}
78
79/// A tool result as seen by the guard.
80#[derive(Debug, Clone)]
81pub struct ToolResultRef {
82    /// Matches the originating [`ToolCallRef::id`].
83    pub tool_call_id: String,
84    /// Plain-text content for the summary preview. Multi-part results
85    /// are concatenated by the host before calling.
86    pub content: String,
87}
88
89/// Maximum chars included in the per-detection result summary.
90const RESULT_SUMMARY_LIMIT: usize = 200;
91/// Maximum chars included in the per-detection arguments summary.
92const ARGUMENT_SUMMARY_LIMIT: usize = 400;
93
94/// Detects consecutive identical assistant tool calls across model
95/// turns.
96#[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    /// Construct from options. `threshold` is clamped to ≥ 1.
112    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    /// Override the threshold at runtime.
122    pub fn with_threshold(mut self, threshold: usize) -> Self {
123        self.threshold = threshold.max(1);
124        self
125    }
126
127    /// Mark a tool as exempt from detection.
128    pub fn with_exempt_tool(mut self, tool: impl Into<String>) -> Self {
129        self.exempt_tools.insert(tool.into());
130        self
131    }
132
133    /// Records one completed turn and returns the threshold hit, if any.
134    ///
135    /// A "completed turn" is one assistant message that contained tool
136    /// calls **and** for which the corresponding tool results have been
137    /// emitted. Turns with multiple distinct tool calls reset detection.
138    pub fn record_turn(&mut self, turn: ToolCallLoopTurn<'_>) -> Option<RepeatedToolCallDetection> {
139        // Only single-call turns are considered. Multi-call turns reset
140        // detection (the model has clearly moved on to a different
141        // request shape).
142        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    /// Reset state between unrelated runs (e.g. on session boundary).
179    pub fn reset(&mut self) {
180        self.last_hash = None;
181        self.count = 0;
182    }
183}
184
185/// Recursively sort object keys so JSON equality survives hash-map
186/// insertion-order differences. Arrays preserve order.
187fn 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        // Two consecutive reads — exempt, never fires.
287        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        // After multi-call, the next single identical call should not
305        // immediately fire (count was reset to 0, then incremented to 1).
306        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        // Two calls with different args: no fire, count stays at 1 after
331        // t2.
332        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        // After sorting, "a" comes before "z".
409        assert!(s.find("\"a\"").unwrap() < s.find("\"z\"").unwrap());
410        // Nested keys are sorted too.
411        let nested_start = s.find("\"y\"").unwrap();
412        let nested_b = s.find("\"b\"").unwrap();
413        assert!(nested_b < nested_start);
414    }
415}