返回 CodeWhale
read_repeat_guard.rs
根目录 / crates / tui / src / core / engine / read_repeat_guard.rs
1 //! Per-user-turn protection against repeated identical read-only tool calls.
2 //!
3 //! The ordinary stuck guard detects consecutive no-progress steps. This guard
4 //! is narrower and deliberately non-consecutive: it keys finalized read-only
5 //! calls by resolved tool name plus canonical arguments, survives interleaved
6 //! model steps, and resets when `handle_deepseek_turn` returns.
7
8 use std::collections::HashMap;
9
10 use serde_json::{Value, json};
11
12 use crate::tools::spec::{ToolError, ToolResult};
13
14 pub(super) const NUDGE_THRESHOLD: usize = 3;
15 pub(super) const RECEIPT_THRESHOLD: usize = 5;
16 pub(super) const STOP_THRESHOLD: usize = 8;
17
18 const CORRECTIVE_NUDGE: &str = "[codewhale read-repeat guard] This identical read-only call has already been requested multiple times in the current user turn. Reuse the result already in context, change the arguments, or switch methods; do not issue the same read unchanged.";
19
20 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
21 pub(super) struct ReadRepeatKey {
22 tool_name: String,
23 arguments_sha256: String,
24 }
25
26 impl ReadRepeatKey {
27 pub(super) fn tool_name(&self) -> &str {
28 &self.tool_name
29 }
30
31 pub(super) fn arguments_sha256(&self) -> &str {
32 &self.arguments_sha256
33 }
34 }
35
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub(super) struct ReadRepeatOccurrence {
38 pub(super) key: ReadRepeatKey,
39 pub(super) count: usize,
40 }
41
42 #[derive(Debug, Clone)]
43 struct PriorSuccess {
44 tool_use_id: String,
45 content_sha256: String,
46 }
47
48 #[derive(Debug, Default)]
49 pub(super) struct ReadRepeatGuard {
50 counts: HashMap<ReadRepeatKey, usize>,
51 prior_successes: HashMap<ReadRepeatKey, PriorSuccess>,
52 }
53
54 impl ReadRepeatGuard {
55 pub(super) fn register(&mut self, tool_name: &str, arguments: &Value) -> ReadRepeatOccurrence {
56 let key = ReadRepeatKey {
57 tool_name: tool_name.to_string(),
58 arguments_sha256: crate::hashing::sha256_hex(
59 canonical_json(arguments).to_string().as_bytes(),
60 ),
61 };
62 let count = self.counts.entry(key.clone()).or_default();
63 *count = count.saturating_add(1);
64 ReadRepeatOccurrence { key, count: *count }
65 }
66
67 pub(super) fn prior_receipt(&self, occurrence: &ReadRepeatOccurrence) -> Option<ToolResult> {
68 if occurrence.count < RECEIPT_THRESHOLD {
69 return None;
70 }
71 let prior = self.prior_successes.get(&occurrence.key)?;
72 Some(receipt_result(
73 occurrence,
74 &prior.tool_use_id,
75 &prior.content_sha256,
76 "prior_result",
77 ))
78 }
79
80 pub(super) fn remember_success(
81 &mut self,
82 occurrence: &ReadRepeatOccurrence,
83 tool_use_id: &str,
84 result: &ToolResult,
85 ) {
86 if !result.success || !result_was_executed(result) {
87 return;
88 }
89 self.prior_successes.insert(
90 occurrence.key.clone(),
91 PriorSuccess {
92 tool_use_id: tool_use_id.to_string(),
93 content_sha256: crate::hashing::sha256_hex(result.content.as_bytes()),
94 },
95 );
96 }
97
98 pub(super) fn coalesced_result(
99 &self,
100 occurrence: &ReadRepeatOccurrence,
101 leader_tool_use_id: &str,
102 leader_result: &Result<ToolResult, ToolError>,
103 ) -> Result<ToolResult, ToolError> {
104 let Ok(leader_result) = leader_result else {
105 return leader_result.clone();
106 };
107 if !leader_result.success {
108 let mut result = leader_result.clone();
109 stamp_repeat_metadata(
110 &mut result,
111 occurrence,
112 false,
113 "coalesced_error",
114 Some(leader_tool_use_id),
115 None,
116 );
117 return Ok(result);
118 }
119
120 let content_sha256 = crate::hashing::sha256_hex(leader_result.content.as_bytes());
121 if occurrence.count >= RECEIPT_THRESHOLD {
122 return Ok(receipt_result(
123 occurrence,
124 leader_tool_use_id,
125 &content_sha256,
126 "same_batch_receipt",
127 ));
128 }
129
130 let mut result = leader_result.clone();
131 stamp_repeat_metadata(
132 &mut result,
133 occurrence,
134 false,
135 "same_batch_subscription",
136 Some(leader_tool_use_id),
137 Some(&content_sha256),
138 );
139 Ok(result)
140 }
141
142 pub(super) fn decorate_model_result(
143 &self,
144 occurrence: &ReadRepeatOccurrence,
145 result: &mut ToolResult,
146 ) {
147 if occurrence.count < NUDGE_THRESHOLD {
148 return;
149 }
150 if !result.content.contains("[codewhale read-repeat guard]") {
151 if !result.content.is_empty() {
152 result.content.push_str("\n\n");
153 }
154 result.content.push_str(CORRECTIVE_NUDGE);
155 }
156 if let Some(repeat) = result
157 .metadata
158 .as_mut()
159 .and_then(Value::as_object_mut)
160 .and_then(|metadata| metadata.get_mut("read_repeat"))
161 .and_then(Value::as_object_mut)
162 {
163 repeat.insert("nudged".to_string(), Value::Bool(true));
164 return;
165 }
166 let executed = result_was_executed(result);
167 stamp_repeat_metadata(result, occurrence, executed, "nudge", None, None);
168 }
169
170 pub(super) fn corrective_nudge(occurrence: &ReadRepeatOccurrence) -> Option<&'static str> {
171 (occurrence.count >= NUDGE_THRESHOLD).then_some(CORRECTIVE_NUDGE)
172 }
173
174 pub(super) fn should_stop(occurrence: &ReadRepeatOccurrence) -> bool {
175 occurrence.count >= STOP_THRESHOLD
176 }
177 }
178
179 fn receipt_result(
180 occurrence: &ReadRepeatOccurrence,
181 source_tool_use_id: &str,
182 source_content_sha256: &str,
183 action: &str,
184 ) -> ToolResult {
185 let mut result = ToolResult::success(format!(
186 "[read-only result receipt]\nIdentical call occurrence {} was not executed. Reuse tool result `{source_tool_use_id}` (content sha256 `{source_content_sha256}`).",
187 occurrence.count
188 ));
189 stamp_repeat_metadata(
190 &mut result,
191 occurrence,
192 false,
193 action,
194 Some(source_tool_use_id),
195 Some(source_content_sha256),
196 );
197 result
198 }
199
200 fn result_was_executed(result: &ToolResult) -> bool {
201 result
202 .metadata
203 .as_ref()
204 .and_then(|metadata| metadata.get("executed"))
205 .and_then(Value::as_bool)
206 .unwrap_or(true)
207 }
208
209 fn stamp_repeat_metadata(
210 result: &mut ToolResult,
211 occurrence: &ReadRepeatOccurrence,
212 executed: bool,
213 action: &str,
214 source_tool_use_id: Option<&str>,
215 source_content_sha256: Option<&str>,
216 ) {
217 let repeat = json!({
218 "tool_name": occurrence.key.tool_name(),
219 "arguments_sha256": occurrence.key.arguments_sha256(),
220 "count": occurrence.count,
221 "action": action,
222 "source_tool_use_id": source_tool_use_id,
223 "source_content_sha256": source_content_sha256,
224 });
225 let metadata = result.metadata.get_or_insert_with(|| json!({}));
226 if let Some(object) = metadata.as_object_mut() {
227 object.insert("executed".to_string(), Value::Bool(executed));
228 object.insert("read_repeat".to_string(), repeat);
229 } else {
230 let prior = std::mem::replace(metadata, json!({}));
231 let object = metadata
232 .as_object_mut()
233 .expect("replacement metadata is an object");
234 object.insert("_prior".to_string(), prior);
235 object.insert("executed".to_string(), Value::Bool(executed));
236 object.insert("read_repeat".to_string(), repeat);
237 }
238 }
239
240 fn canonical_json(value: &Value) -> Value {
241 match value {
242 Value::Object(object) => {
243 let mut entries: Vec<_> = object.iter().collect();
244 entries.sort_by_key(|(key, _)| *key);
245 let mut canonical = serde_json::Map::new();
246 for (key, value) in entries {
247 canonical.insert(key.clone(), canonical_json(value));
248 }
249 Value::Object(canonical)
250 }
251 Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
252 other => other.clone(),
253 }
254 }
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259
260 #[test]
261 fn canonical_argument_order_shares_one_counter() {
262 let mut guard = ReadRepeatGuard::default();
263 let first = guard.register("read_file", &json!({"path": "a", "limit": 10}));
264 let second = guard.register("read_file", &json!({"limit": 10, "path": "a"}));
265
266 assert_eq!(first.key, second.key);
267 assert_eq!(second.count, 2);
268 }
269
270 #[test]
271 fn counts_survive_interleaving_with_other_reads() {
272 let mut guard = ReadRepeatGuard::default();
273 let a1 = guard.register("read_file", &json!({"path": "a"}));
274 let _b1 = guard.register("read_file", &json!({"path": "b"}));
275 let a2 = guard.register("read_file", &json!({"path": "a"}));
276 let _b2 = guard.register("grep_files", &json!({"pattern": "needle"}));
277 let a3 = guard.register("read_file", &json!({"path": "a"}));
278
279 assert_eq!(a1.count, 1);
280 assert_eq!(a2.count, 2);
281 assert_eq!(a3.count, NUDGE_THRESHOLD);
282 }
283
284 #[test]
285 fn fifth_call_reuses_prior_success_and_eighth_stops() {
286 let mut guard = ReadRepeatGuard::default();
287 let arguments = json!({"path": "a"});
288 let first = guard.register("read_file", &arguments);
289 guard.remember_success(&first, "tool-1", &ToolResult::success("contents"));
290
291 for expected in 2..RECEIPT_THRESHOLD {
292 assert_eq!(guard.register("read_file", &arguments).count, expected);
293 }
294 let fifth = guard.register("read_file", &arguments);
295 let mut receipt = guard
296 .prior_receipt(&fifth)
297 .expect("fifth identical read should reuse the prior result");
298 guard.decorate_model_result(&fifth, &mut receipt);
299 assert_eq!(receipt.metadata.as_ref().unwrap()["executed"], false);
300 assert_eq!(
301 receipt.metadata.as_ref().unwrap()["read_repeat"]["action"],
302 "prior_result"
303 );
304 assert_eq!(
305 receipt.metadata.as_ref().unwrap()["read_repeat"]["nudged"],
306 true
307 );
308 assert!(receipt.content.contains("tool-1"));
309
310 let _sixth = guard.register("read_file", &arguments);
311 let _seventh = guard.register("read_file", &arguments);
312 let eighth = guard.register("read_file", &arguments);
313 assert!(ReadRepeatGuard::should_stop(&eighth));
314 }
315
316 #[test]
317 fn nudge_is_added_once_and_is_replay_stable() {
318 let mut guard = ReadRepeatGuard::default();
319 let arguments = json!({"path": "a"});
320 let _first = guard.register("read_file", &arguments);
321 let _second = guard.register("read_file", &arguments);
322 let third = guard.register("read_file", &arguments);
323 let mut result = ToolResult::success("contents");
324
325 guard.decorate_model_result(&third, &mut result);
326 guard.decorate_model_result(&third, &mut result);
327
328 assert_eq!(result.content.matches(CORRECTIVE_NUDGE).count(), 1);
329 assert_eq!(result.metadata.as_ref().unwrap()["read_repeat"]["count"], 3);
330 }
331 }
332
332 lines RUST