1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
// SPDX-License-Identifier: MIT OR Apache-2.0
//! 2026-09-26: Tool-call dedup: trips when the same call (name plus arguments) repeats.
//!
//! Owner: server API.
//! Invariants: arguments that parse as JSON are hashed in canonical form (sorted
//! keys, no whitespace), so formatting does not separate equal calls; anything
//! else is hashed as the raw string.
//!
//! This catches a repeated call even when the narration around it varies. The
//! ring holds the last `cap` hashes. A call trips when, counting itself, it
//! ends a run of `threshold_consec` identical hashes, or when it and its copies
//! in the ring number `threshold_window`. `new()` uses cap 8, consec 5 and
//! window 6; the streaming chat path also builds one with 4, 2 and 3.
use std::collections::VecDeque;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
const DEFAULT_CAP: usize = 8;
const DEFAULT_CONSEC: u32 = 5;
const DEFAULT_WINDOW: u32 = 6;
/// 2026-09-26: Tool-call argument hash dedup guard.
#[derive(Debug)]
pub struct ToolArgDedup {
recent: VecDeque<u64>,
cap: usize,
threshold_consec: u32,
threshold_window: u32,
}
impl Default for ToolArgDedup {
fn default() -> Self {
Self::new()
}
}
impl ToolArgDedup {
/// 2026-09-26: Construct with cap 8, consec 5, window 6.
pub fn new() -> Self {
Self::with_params(DEFAULT_CAP, DEFAULT_CONSEC, DEFAULT_WINDOW)
}
/// 2026-09-26: Construct with explicit ring capacity (at least 1) and trip thresholds.
pub fn with_params(cap: usize, threshold_consec: u32, threshold_window: u32) -> Self {
Self {
recent: VecDeque::with_capacity(cap.max(1)),
cap: cap.max(1),
threshold_consec,
threshold_window,
}
}
/// 2026-09-26: Check whether `(name, args_json)` trips a threshold, then push its
/// hash onto the ring either way.
pub fn check(&mut self, name: &str, args_json: &str) -> bool {
let h = hash_call(name, args_json);
let trip = self.would_trip(h);
if self.recent.len() >= self.cap {
self.recent.pop_front();
}
self.recent.push_back(h);
trip
}
/// 2026-09-26: Whether pushing `h` would trip a threshold, counting `h` itself.
fn would_trip(&self, h: u64) -> bool {
// 2026-09-26: Consecutive: the run of `h` at the ring's tail, plus the new push.
let mut consec = 1u32;
for &p in self.recent.iter().rev() {
if p == h {
consec += 1;
} else {
break;
}
}
if consec >= self.threshold_consec {
return true;
}
// 2026-09-26: Window: occurrences of `h` in the ring before the push, plus the
// new one.
let occ = self.recent.iter().filter(|&&p| p == h).count() as u32 + 1;
occ >= self.threshold_window
}
/// 2026-09-26: Drop all stored hashes.
pub fn reset(&mut self) {
self.recent.clear();
}
/// 2026-09-26: Number of hashes currently stored.
pub fn len(&self) -> usize {
self.recent.len()
}
}
/// 2026-09-26: Hash `(name, canonical_args)` to a u64 with `DefaultHasher`. The args
/// are re-serialised in canonical (sorted-key, no-whitespace) form, or hashed as
/// the raw string if they do not parse as JSON.
fn hash_call(name: &str, args_json: &str) -> u64 {
let mut h = DefaultHasher::new();
h.write(name.as_bytes());
h.write_u8(0); // 2026-09-26: Separator, so "ab" + "c" != "a" + "bc".
let canonical = canonicalize_json(args_json).unwrap_or_else(|| args_json.to_string());
h.write(canonical.as_bytes());
h.finish()
}
/// 2026-09-26: Re-serialise a JSON value with sorted object keys and no whitespace.
/// Returns `None` if `s` is not valid JSON.
fn canonicalize_json(s: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(s).ok()?;
Some(canonical_value_to_string(&v))
}
fn canonical_value_to_string(v: &serde_json::Value) -> String {
let mut out = String::new();
write_canonical(&mut out, v);
out
}
fn write_canonical(out: &mut String, v: &serde_json::Value) {
use std::fmt::Write;
match v {
serde_json::Value::Null => out.push_str("null"),
serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
serde_json::Value::Number(n) => {
let _ = write!(out, "{}", n);
}
serde_json::Value::String(s) => {
let _ = write!(out, "{}", serde_json::Value::String(s.clone()));
}
serde_json::Value::Array(arr) => {
out.push('[');
for (i, x) in arr.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(out, x);
}
out.push(']');
}
serde_json::Value::Object(map) => {
out.push('{');
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for (i, k) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(out, "{}", serde_json::Value::String(k.clone()));
out.push(':');
write_canonical(out, &map[k]);
}
out.push('}');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn five_consecutive_identical_calls_trip() {
let mut g = ToolArgDedup::new();
for i in 0..4 {
assert!(
!g.check("Bash", r#"{"command":"mkdir -p /tmp/foo"}"#),
"iteration {i} must not trip"
);
}
assert!(
g.check("Bash", r#"{"command":"mkdir -p /tmp/foo"}"#),
"5th identical call must trip"
);
}
#[test]
fn whitespace_and_key_order_normalised() {
let mut g = ToolArgDedup::new();
// 2026-09-26: The first three differ only in whitespace and key order.
assert!(!g.check("Write", r#"{"path":"/tmp/x","content":"hi"}"#));
assert!(!g.check("Write", r#"{ "path" : "/tmp/x" , "content" : "hi" }"#));
assert!(!g.check("Write", r#"{"content":"hi","path":"/tmp/x"}"#));
assert!(!g.check("Write", r#"{"path":"/tmp/x","content":"hi"}"#));
assert!(
g.check("Write", r#"{"path":"/tmp/x","content":"hi"}"#),
"5th identical (modulo whitespace+key-order) call must trip"
);
}
#[test]
fn distinct_calls_do_not_trip() {
let mut g = ToolArgDedup::new();
for i in 0..8 {
let args = format!(r#"{{"command":"mkdir -p /tmp/test-{i}"}}"#);
assert!(
!g.check("Bash", &args),
"8 distinct calls must not trip (i={i})"
);
}
}
#[test]
fn six_of_eight_window_trips() {
let mut g = ToolArgDedup::new();
// 2026-09-26: 6 dupes interleaved with 2 distinct calls within the 8-slot ring.
let dup = r#"{"command":"ls"}"#;
let other_a = r#"{"command":"pwd"}"#;
let other_b = r#"{"command":"whoami"}"#;
// 2026-09-26: The run of dups never exceeds 2, so the 6th dup (index 7) trips
// on the window.
let mut tripped_at = None;
for (i, args) in [dup, dup, other_a, dup, dup, other_b, dup, dup]
.iter()
.enumerate()
{
if g.check("Bash", args) {
tripped_at = Some(i);
break;
}
}
assert!(
tripped_at.is_some(),
"must trip on 6/8 window pattern; ring={:?}",
g.recent
);
}
#[test]
fn name_difference_breaks_dedup() {
let mut g = ToolArgDedup::new();
let args = r#"{"x":1}"#;
// 2026-09-26: Same args under five different names: the hashes differ, so
// nothing trips.
for name in ["A", "B", "C", "D", "E"] {
assert!(!g.check(name, args), "name {name} must not trip");
}
}
#[test]
fn invalid_json_falls_back_to_raw_string() {
let mut g = ToolArgDedup::new();
// 2026-09-26: Malformed JSON is hashed as the raw string, and identical raw
// strings still trip the consecutive threshold.
for _ in 0..4 {
assert!(!g.check("Bash", "not valid {{ json"));
}
assert!(g.check("Bash", "not valid {{ json"));
}
#[test]
fn reset_clears_state() {
let mut g = ToolArgDedup::new();
for _ in 0..4 {
g.check("Bash", r#"{"x":1}"#);
}
assert_eq!(g.len(), 4);
g.reset();
assert_eq!(g.len(), 0);
// 2026-09-26: After reset, four more identical calls stay below the
// consecutive threshold.
for _ in 0..4 {
assert!(!g.check("Bash", r#"{"x":1}"#));
}
}
}