1#![forbid(unsafe_code)]
11
12use async_trait::async_trait;
13
14use serde_json::{Value, json};
15use std::collections::HashMap;
16use std::sync::Arc;
17use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
18use wm_memory::MemoryStore;
19
20use super::common::galaxy_name;
21
22pub struct AntiLoopCheckTool {
32 store: Arc<MemoryStore>,
33 stats: ToolStats,
34 effects: EffectRow,
35}
36
37impl AntiLoopCheckTool {
38 pub fn new(store: Arc<MemoryStore>) -> Self {
39 Self {
40 store,
41 stats: ToolStats::default(),
42 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
43 }
44 }
45}
46
47#[async_trait]
48impl Tool for AntiLoopCheckTool {
49 fn name(&self) -> &str {
50 "anti_loop.check"
51 }
52 fn gana(&self) -> Gana {
53 Gana::Wall
54 }
55 fn effects(&self) -> &EffectRow {
56 &self.effects
57 }
58 fn description(&self) -> &str {
59 "Detect repetitive patterns indicating loops or stuck states"
60 }
61 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
62 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
63 let scan_limit = args
64 .get("scan_limit")
65 .and_then(serde_json::Value::as_u64)
66 .unwrap_or(200) as usize;
67 let similarity_threshold = args
68 .get("similarity_threshold")
69 .and_then(serde_json::Value::as_f64)
70 .unwrap_or(0.8) as f32;
71
72 let galaxies: Vec<Galaxy> = match galaxy_str {
73 Some(g) => vec![super::common::parse_galaxy(g)?],
74 None => Galaxy::memory_galaxies().to_vec(),
75 };
76
77 let mut all_mems: Vec<(Galaxy, wm_memory::Memory)> = Vec::new();
78 for galaxy in &galaxies {
79 let mems = self.store.scan(*galaxy, scan_limit)?;
80 all_mems.extend(mems.into_iter().map(|m| (*galaxy, m)));
81 }
82
83 if all_mems.is_empty() {
84 return Ok(json!({
85 "status": "success",
86 "total_memories": 0,
87 "loop_detected": false,
88 "warnings": [],
89 }));
90 }
91
92 all_mems.sort_by_key(|x| std::cmp::Reverse(x.1.metadata.created_at));
94
95 let mut warnings: Vec<Value> = Vec::new();
96
97 let mut content_map: HashMap<String, u32> = HashMap::new();
99 for (_, mem) in &all_mems {
100 *content_map.entry(mem.content.clone()).or_default() += 1;
101 }
102 for (content, count) in &content_map {
103 if *count > 1 {
104 warnings.push(json!({
105 "type": "exact_duplicate",
106 "content_preview": content.chars().take(80).collect::<String>(),
107 "count": count,
108 "severity": if *count > 3 { "high" } else { "medium" },
109 }));
110 }
111 }
112
113 let recent: Vec<&(Galaxy, wm_memory::Memory)> = all_mems.iter().take(50).collect();
115 for i in 0..recent.len() {
116 for j in (i + 1)..recent.len().min(i + 10) {
117 let a = &recent[i].1.content;
118 let b = &recent[j].1.content;
119 let similarity = content_similarity(a, b);
120 if similarity > similarity_threshold && a != b {
121 warnings.push(json!({
122 "type": "near_duplicate",
123 "similarity": (similarity * 100.0).round() / 100.0,
124 "memory_a": recent[i].1.metadata.id,
125 "memory_b": recent[j].1.metadata.id,
126 "content_preview": a.chars().take(60).collect::<String>(),
127 "severity": "medium",
128 }));
129 }
130 }
131 }
132
133 if all_mems.len() >= 10 {
135 let recent_10 = &all_mems[..10];
136 if let (Some(first), Some(last)) = (recent_10.last(), recent_10.first()) {
137 let duration = last.1.metadata.created_at - first.1.metadata.created_at;
138 if duration.num_seconds() < 60 {
139 warnings.push(json!({
140 "type": "burst_creation",
141 "memories_in_burst": 10,
142 "duration_seconds": duration.num_seconds(),
143 "severity": "high",
144 "message": "10+ memories created within 60 seconds — possible loop",
145 }));
146 }
147 }
148 }
149
150 let mut tag_pattern_count: HashMap<Vec<String>, u32> = HashMap::new();
152 for (_, mem) in &all_mems {
153 let mut tags = mem.metadata.tags.clone();
154 tags.sort();
155 *tag_pattern_count.entry(tags).or_default() += 1;
156 }
157 for (tags, count) in &tag_pattern_count {
158 if *count > 5 {
159 warnings.push(json!({
160 "type": "repetitive_tags",
161 "tags": tags,
162 "count": count,
163 "severity": if *count > 10 { "high" } else { "medium" },
164 }));
165 }
166 }
167
168 let loop_detected = warnings
169 .iter()
170 .any(|w| w["severity"].as_str().is_some_and(|s| s == "high"));
171
172 Ok(json!({
173 "status": "success",
174 "total_memories": all_mems.len(),
175 "galaxies_checked": galaxies.len(),
176 "loop_detected": loop_detected,
177 "warning_count": warnings.len(),
178 "warnings": warnings,
179 }))
180 }
181 fn stats(&self) -> &ToolStats {
182 &self.stats
183 }
184}
185
186fn content_similarity(a: &str, b: &str) -> f32 {
188 if a.is_empty() || b.is_empty() {
189 return 0.0;
190 }
191 let ngrams_a: std::collections::HashSet<&str> = a
192 .as_bytes()
193 .windows(3)
194 .map(|w| std::str::from_utf8(w).unwrap_or(""))
195 .collect();
196 let ngrams_b: std::collections::HashSet<&str> = b
197 .as_bytes()
198 .windows(3)
199 .map(|w| std::str::from_utf8(w).unwrap_or(""))
200 .collect();
201 let intersection = ngrams_a.intersection(&ngrams_b).count();
202 let union = ngrams_a.union(&ngrams_b).count();
203 if union == 0 {
204 0.0
205 } else {
206 intersection as f32 / union as f32
207 }
208}
209
210pub struct BoundaryEnforceTool {
217 store: Arc<MemoryStore>,
218 stats: ToolStats,
219 effects: EffectRow,
220}
221
222impl BoundaryEnforceTool {
223 pub fn new(store: Arc<MemoryStore>) -> Self {
224 Self {
225 store,
226 stats: ToolStats::default(),
227 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
228 }
229 }
230}
231
232#[async_trait]
233impl Tool for BoundaryEnforceTool {
234 fn name(&self) -> &str {
235 "boundary.enforce"
236 }
237 fn gana(&self) -> Gana {
238 Gana::Wall
239 }
240 fn effects(&self) -> &EffectRow {
241 &self.effects
242 }
243 fn description(&self) -> &str {
244 "Enforce resource boundaries and report violations"
245 }
246 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
247 let max_memories_per_galaxy = args
248 .get("max_memories_per_galaxy")
249 .and_then(serde_json::Value::as_u64)
250 .unwrap_or(10000);
251 let max_tag_sprawl = args
252 .get("max_tag_sprawl")
253 .and_then(serde_json::Value::as_u64)
254 .unwrap_or(100);
255 let max_content_length = args
256 .get("max_content_length")
257 .and_then(serde_json::Value::as_u64)
258 .unwrap_or(10000) as usize;
259 let max_tags_per_memory = args
260 .get("max_tags_per_memory")
261 .and_then(serde_json::Value::as_u64)
262 .unwrap_or(20) as usize;
263
264 let mut violations: Vec<Value> = Vec::new();
265 let mut galaxy_reports: Vec<Value> = Vec::new();
266 let mut total_memories = 0u64;
267 let mut total_tags: std::collections::HashSet<String> = std::collections::HashSet::new();
268
269 for galaxy in Galaxy::memory_galaxies() {
270 let mems = self.store.scan(galaxy, 100000)?;
271 let count = mems.len() as u64;
272 total_memories += count;
273
274 let mut galaxy_tags: std::collections::HashSet<String> =
275 std::collections::HashSet::new();
276 let mut oversized_count = 0u64;
277 let mut overtagged_count = 0u64;
278
279 for mem in &mems {
280 for tag in &mem.metadata.tags {
281 galaxy_tags.insert(tag.clone());
282 total_tags.insert(tag.clone());
283 }
284 if mem.content.len() > max_content_length {
285 oversized_count += 1;
286 }
287 if mem.metadata.tags.len() > max_tags_per_memory {
288 overtagged_count += 1;
289 }
290 }
291
292 if count > max_memories_per_galaxy {
294 violations.push(json!({
295 "type": "galaxy_overflow",
296 "galaxy": galaxy_name(galaxy),
297 "count": count,
298 "limit": max_memories_per_galaxy,
299 "severity": "high",
300 "recommendation": format!("Galaxy '{}' has {} memories (limit: {}) — consider consolidation or pruning", galaxy_name(galaxy), count, max_memories_per_galaxy),
301 }));
302 }
303
304 if oversized_count > 0 {
306 violations.push(json!({
307 "type": "oversized_memories",
308 "galaxy": galaxy_name(galaxy),
309 "count": oversized_count,
310 "max_length": max_content_length,
311 "severity": "medium",
312 "recommendation": format!("{} memories in '{}' exceed max content length ({})", oversized_count, galaxy_name(galaxy), max_content_length),
313 }));
314 }
315
316 if overtagged_count > 0 {
318 violations.push(json!({
319 "type": "overtagged_memories",
320 "galaxy": galaxy_name(galaxy),
321 "count": overtagged_count,
322 "max_tags": max_tags_per_memory,
323 "severity": "low",
324 }));
325 }
326
327 galaxy_reports.push(json!({
328 "galaxy": galaxy_name(galaxy),
329 "count": count,
330 "unique_tags": galaxy_tags.len(),
331 "oversized": oversized_count,
332 "overtagged": overtagged_count,
333 }));
334 }
335
336 if total_tags.len() as u64 > max_tag_sprawl {
338 violations.push(json!({
339 "type": "tag_sprawl",
340 "total_unique_tags": total_tags.len(),
341 "limit": max_tag_sprawl,
342 "severity": "medium",
343 "recommendation": format!("{} unique tags across all galaxies (limit: {}) — consider tag consolidation", total_tags.len(), max_tag_sprawl),
344 }));
345 }
346
347 let all_clear = violations.is_empty();
348
349 Ok(json!({
350 "status": "success",
351 "all_clear": all_clear,
352 "total_memories": total_memories,
353 "total_unique_tags": total_tags.len(),
354 "violation_count": violations.len(),
355 "violations": violations,
356 "galaxy_reports": galaxy_reports,
357 "limits": {
358 "max_memories_per_galaxy": max_memories_per_galaxy,
359 "max_tag_sprawl": max_tag_sprawl,
360 "max_content_length": max_content_length,
361 "max_tags_per_memory": max_tags_per_memory,
362 },
363 }))
364 }
365 fn stats(&self) -> &ToolStats {
366 &self.stats
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
375 let tmp = tempfile::tempdir().unwrap();
376 let store = MemoryStore::open_default(tmp.path()).unwrap();
377 (tmp, Arc::new(store))
378 }
379
380 fn seed_normal(store: &Arc<MemoryStore>) {
381 for i in 0..5 {
382 let mut mem =
383 wm_memory::Memory::new(Galaxy::Codex, format!("Unique memory content number {i}"));
384 mem.metadata.tags = vec!["tag1".into(), "tag2".into()];
385 mem.metadata.importance = 0.5;
386 store.put(Galaxy::Codex, &mem).unwrap();
387 }
388 }
389
390 fn seed_duplicates(store: &Arc<MemoryStore>) {
391 for _ in 0..4 {
392 let mut mem =
393 wm_memory::Memory::new(Galaxy::Codex, "Duplicate content same text".into());
394 mem.metadata.tags = vec!["dup".into()];
395 store.put(Galaxy::Codex, &mem).unwrap();
396 }
397 }
398
399 #[tokio::test]
400 async fn anti_loop_detects_exact_duplicates() {
401 let (_tmp, store) = open_store();
402 seed_duplicates(&store);
403
404 let tool = AntiLoopCheckTool::new(store);
405 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
406 let obj = result.as_object().unwrap();
407 assert_eq!(obj["status"], "success");
408 let warnings = obj["warnings"].as_array().unwrap();
409 let has_dup = warnings.iter().any(|w| w["type"] == "exact_duplicate");
410 assert!(has_dup, "Should detect exact duplicates");
411 }
412
413 #[tokio::test]
414 async fn anti_loop_no_issues() {
415 let (_tmp, store) = open_store();
416 seed_normal(&store);
417
418 let tool = AntiLoopCheckTool::new(store);
419 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
420 let obj = result.as_object().unwrap();
421 assert_eq!(obj["status"], "success");
422 assert_eq!(obj["loop_detected"], false);
423 }
424
425 #[tokio::test]
426 async fn anti_loop_empty_store() {
427 let (_tmp, store) = open_store();
428 let tool = AntiLoopCheckTool::new(store);
429 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
430 let obj = result.as_object().unwrap();
431 assert_eq!(obj["total_memories"], 0);
432 assert_eq!(obj["loop_detected"], false);
433 }
434
435 #[tokio::test]
436 async fn anti_loop_detects_near_duplicates() {
437 let (_tmp, store) = open_store();
438 let mut mem1 =
439 wm_memory::Memory::new(Galaxy::Codex, "Rust programming language features".into());
440 mem1.metadata.importance = 0.8;
441 store.put(Galaxy::Codex, &mem1).unwrap();
442 let mut mem2 =
443 wm_memory::Memory::new(Galaxy::Codex, "Rust programming language basics".into());
444 mem2.metadata.importance = 0.8;
445 store.put(Galaxy::Codex, &mem2).unwrap();
446
447 let tool = AntiLoopCheckTool::new(store);
448 let result = tool
449 .call(
450 &mut Context::default(),
451 json!({"similarity_threshold": 0.5}),
452 )
453 .await
454 .unwrap();
455 let obj = result.as_object().unwrap();
456 let warnings = obj["warnings"].as_array().unwrap();
457 let has_near = warnings.iter().any(|w| w["type"] == "near_duplicate");
458 assert!(has_near, "Should detect near-duplicates");
459 }
460
461 #[tokio::test]
462 async fn boundary_enforce_all_clear() {
463 let (_tmp, store) = open_store();
464 seed_normal(&store);
465
466 let tool = BoundaryEnforceTool::new(store);
467 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
468 let obj = result.as_object().unwrap();
469 assert_eq!(obj["status"], "success");
470 assert_eq!(obj["all_clear"], true);
471 assert_eq!(obj["violation_count"], 0);
472 }
473
474 #[tokio::test]
475 async fn boundary_enforce_detects_oversized() {
476 let (_tmp, store) = open_store();
477 let mut mem = wm_memory::Memory::new(Galaxy::Codex, "A".repeat(100));
478 mem.metadata.importance = 0.5;
479 store.put(Galaxy::Codex, &mem).unwrap();
480
481 let tool = BoundaryEnforceTool::new(store);
482 let result = tool
483 .call(&mut Context::default(), json!({"max_content_length": 50}))
484 .await
485 .unwrap();
486 let obj = result.as_object().unwrap();
487 assert_eq!(obj["all_clear"], false);
488 let violations = obj["violations"].as_array().unwrap();
489 let has_oversized = violations.iter().any(|v| v["type"] == "oversized_memories");
490 assert!(has_oversized, "Should detect oversized memories");
491 }
492
493 #[tokio::test]
494 async fn boundary_enforce_detects_overtagged() {
495 let (_tmp, store) = open_store();
496 let mut mem = wm_memory::Memory::new(Galaxy::Codex, "Test memory".into());
497 mem.metadata.tags = (0..25).map(|i| format!("tag{i}")).collect();
498 store.put(Galaxy::Codex, &mem).unwrap();
499
500 let tool = BoundaryEnforceTool::new(store);
501 let result = tool
502 .call(&mut Context::default(), json!({"max_tags_per_memory": 20}))
503 .await
504 .unwrap();
505 let obj = result.as_object().unwrap();
506 let violations = obj["violations"].as_array().unwrap();
507 let has_overtagged = violations
508 .iter()
509 .any(|v| v["type"] == "overtagged_memories");
510 assert!(has_overtagged, "Should detect over-tagged memories");
511 }
512
513 #[tokio::test]
514 async fn boundary_enforce_empty_store() {
515 let (_tmp, store) = open_store();
516 let tool = BoundaryEnforceTool::new(store);
517 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
518 let obj = result.as_object().unwrap();
519 assert_eq!(obj["all_clear"], true);
520 assert_eq!(obj["total_memories"], 0);
521 }
522
523 #[tokio::test]
524 async fn boundary_enforce_galaxy_reports() {
525 let (_tmp, store) = open_store();
526 seed_normal(&store);
527
528 let tool = BoundaryEnforceTool::new(store);
529 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
530 let obj = result.as_object().unwrap();
531 let reports = obj["galaxy_reports"].as_array().unwrap();
532 let codex = reports.iter().find(|r| r["galaxy"] == "codex");
533 assert!(codex.is_some());
534 assert_eq!(codex.unwrap()["count"], 5);
535 }
536}