返回 DeepSeek-TUI-2026
loop_guard.rs
根目录 / crates / tui / src / core / engine / loop_guard.rs
1 //! Pure-data guardrails for repeated tool-call loops.
2
3 use std::collections::HashMap;
4 use std::collections::hash_map::DefaultHasher;
5 use std::fmt::Write as _;
6 use std::hash::{Hash, Hasher};
7
8 use serde_json::Value;
9
10 const IDENTICAL_CALL_BLOCK_THRESHOLD: u32 = 3;
11 const FAILURE_WARN_THRESHOLD: u32 = 3;
12 const FAILURE_HALT_THRESHOLD: u32 = 8;
13
14 #[derive(Debug, Clone, PartialEq, Eq)]
15 pub(super) enum AttemptDecision {
16 Proceed,
17 Block(String),
18 }
19
20 #[derive(Debug, Clone, PartialEq, Eq)]
21 pub(super) enum OutcomeDecision {
22 Continue,
23 Warn(String),
24 Halt(String),
25 }
26
27 #[derive(Debug, Default)]
28 pub(super) struct LoopGuard {
29 call_counts: HashMap<(String, u64), u32>,
30 failure_counts: HashMap<String, u32>,
31 }
32
33 impl LoopGuard {
34 pub(super) fn record_attempt(&mut self, tool: &str, args: &Value) -> AttemptDecision {
35 let key = (tool.to_string(), hash_args(args));
36 let count = self.call_counts.entry(key).or_insert(0);
37 *count = count.saturating_add(1);
38 if *count >= IDENTICAL_CALL_BLOCK_THRESHOLD {
39 return AttemptDecision::Block(format!(
40 "Blocked: this exact call (`{tool}` with these arguments) has already run {count} times this turn. Stop retrying it unchanged. Either change the arguments or pick a different tool."
41 ));
42 }
43 AttemptDecision::Proceed
44 }
45
46 pub(super) fn record_outcome(&mut self, tool: &str, ok: bool) -> OutcomeDecision {
47 let failures = self.failure_counts.entry(tool.to_string()).or_insert(0);
48 if ok {
49 *failures = 0;
50 return OutcomeDecision::Continue;
51 }
52
53 *failures = failures.saturating_add(1);
54 if *failures >= FAILURE_HALT_THRESHOLD {
55 return OutcomeDecision::Halt(format!(
56 "Stop retrying `{tool}` - it has failed {failures} consecutive times. Choose a different approach."
57 ));
58 }
59 if *failures == FAILURE_WARN_THRESHOLD {
60 return OutcomeDecision::Warn(format!(
61 "Tool `{tool}` has failed {failures} consecutive times this turn."
62 ));
63 }
64 OutcomeDecision::Continue
65 }
66 }
67
68 fn hash_args(args: &Value) -> u64 {
69 let mut canonical = String::new();
70 write_canonical_json(args, &mut canonical);
71 let mut hasher = DefaultHasher::new();
72 canonical.hash(&mut hasher);
73 hasher.finish()
74 }
75
76 fn write_canonical_json(value: &Value, out: &mut String) {
77 match value {
78 Value::Null => out.push_str("null"),
79 Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
80 Value::Number(value) => {
81 let _ = write!(out, "{value}");
82 }
83 Value::String(value) => {
84 out.push_str(&serde_json::to_string(value).expect("serializing string cannot fail"));
85 }
86 Value::Array(values) => {
87 out.push('[');
88 for (idx, item) in values.iter().enumerate() {
89 if idx > 0 {
90 out.push(',');
91 }
92 write_canonical_json(item, out);
93 }
94 out.push(']');
95 }
96 Value::Object(values) => {
97 out.push('{');
98 let mut entries = values.iter().collect::<Vec<_>>();
99 entries.sort_by(|a, b| a.0.cmp(b.0));
100 for (idx, (key, item)) in entries.into_iter().enumerate() {
101 if idx > 0 {
102 out.push(',');
103 }
104 out.push_str(&serde_json::to_string(key).expect("serializing key cannot fail"));
105 out.push(':');
106 write_canonical_json(item, out);
107 }
108 out.push('}');
109 }
110 }
111 }
112
113 #[cfg(test)]
114 mod tests {
115 use super::*;
116 use serde_json::json;
117
118 #[test]
119 fn third_identical_tool_call_is_blocked() {
120 let mut guard = LoopGuard::default();
121 let args = json!({"path": "src/main.rs"});
122
123 assert_eq!(
124 guard.record_attempt("read_file", &args),
125 AttemptDecision::Proceed
126 );
127 assert_eq!(
128 guard.record_attempt("read_file", &args),
129 AttemptDecision::Proceed
130 );
131
132 let AttemptDecision::Block(message) = guard.record_attempt("read_file", &args) else {
133 panic!("third identical call should be blocked");
134 };
135 assert!(message.contains("read_file"));
136 assert!(message.contains("already run 3 times"));
137 }
138
139 #[test]
140 fn paginated_reads_are_not_false_positives() {
141 let mut guard = LoopGuard::default();
142
143 for offset in [0, 100, 200] {
144 assert_eq!(
145 guard.record_attempt(
146 "read_file",
147 &json!({"path": "src/main.rs", "offset": offset})
148 ),
149 AttemptDecision::Proceed
150 );
151 }
152 }
153
154 #[test]
155 fn tool_failure_counter_warns_at_three_and_halts_at_eight() {
156 let mut guard = LoopGuard::default();
157
158 assert_eq!(
159 guard.record_outcome("grep_files", false),
160 OutcomeDecision::Continue
161 );
162 assert_eq!(
163 guard.record_outcome("grep_files", false),
164 OutcomeDecision::Continue
165 );
166 assert!(matches!(
167 guard.record_outcome("grep_files", false),
168 OutcomeDecision::Warn(message) if message.contains("failed 3 consecutive times")
169 ));
170
171 for _ in 4..8 {
172 assert_eq!(
173 guard.record_outcome("grep_files", false),
174 OutcomeDecision::Continue
175 );
176 }
177 assert!(matches!(
178 guard.record_outcome("grep_files", false),
179 OutcomeDecision::Halt(message) if message.contains("failed 8 consecutive times")
180 ));
181 }
182
183 #[test]
184 fn successful_tool_call_resets_failure_counter() {
185 let mut guard = LoopGuard::default();
186
187 assert_eq!(
188 guard.record_outcome("grep_files", false),
189 OutcomeDecision::Continue
190 );
191 assert_eq!(
192 guard.record_outcome("grep_files", false),
193 OutcomeDecision::Continue
194 );
195 assert_eq!(
196 guard.record_outcome("grep_files", true),
197 OutcomeDecision::Continue
198 );
199 assert_eq!(
200 guard.record_outcome("grep_files", false),
201 OutcomeDecision::Continue
202 );
203 }
204
205 #[test]
206 fn argument_hash_is_independent_of_object_key_order() {
207 let mut guard = LoopGuard::default();
208
209 assert_eq!(
210 guard.record_attempt("read_file", &json!({"path": "a", "offset": 0})),
211 AttemptDecision::Proceed
212 );
213 assert_eq!(
214 guard.record_attempt("read_file", &json!({"offset": 0, "path": "a"})),
215 AttemptDecision::Proceed
216 );
217 assert!(matches!(
218 guard.record_attempt("read_file", &json!({"path": "a", "offset": 0})),
219 AttemptDecision::Block(_)
220 ));
221 }
222 }
223
223 lines RUST