clawgarden-agent 0.17.0

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
#![allow(dead_code)]
//! Loop guard - prevents agents from getting stuck in repetitive loops

use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};

/// Maximum number of recent messages to track per correlation
const MAX_TRACKED_MESSAGES: usize = 10;

/// Time window for repetition detection
const REPETITION_WINDOW_SECS: u64 = 60;

/// Threshold for blocking self-repetition
const REPETITION_THRESHOLD: usize = 3;

/// A hashable message content for repetition detection
#[derive(Debug, Clone, Eq)]
pub struct MessageHash {
    pub content_hash: u64,
    pub agent_name: String,
    pub timestamp: Instant,
}

impl PartialEq for MessageHash {
    fn eq(&self, other: &Self) -> bool {
        self.content_hash == other.content_hash && self.agent_name == other.agent_name
    }
}

impl Hash for MessageHash {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.content_hash.hash(state);
        self.agent_name.hash(state);
    }
}

/// Track recent messages for a given correlation ID
#[derive(Debug, Clone)]
pub struct CorrelationTracker {
    pub correlation_id: String,
    messages: Vec<MessageHash>,
    created_at: Instant,
}

impl CorrelationTracker {
    pub fn new(correlation_id: String) -> Self {
        Self {
            correlation_id,
            messages: Vec::new(),
            created_at: Instant::now(),
        }
    }

    /// Add a message hash to this tracker
    pub fn add(&mut self, content_hash: u64, agent_name: String) {
        // Remove old messages outside the window
        let window = Duration::from_secs(REPETITION_WINDOW_SECS);
        self.messages.retain(|m| m.timestamp.elapsed() < window);

        self.messages.push(MessageHash {
            content_hash,
            agent_name,
            timestamp: Instant::now(),
        });

        // Keep only recent messages
        if self.messages.len() > MAX_TRACKED_MESSAGES {
            self.messages.remove(0);
        }
    }

    /// Check if a message would be repetitive (same content, same agent, within window)
    pub fn is_repetitive(&self, content_hash: u64, agent_name: &str) -> bool {
        let window = Duration::from_secs(REPETITION_WINDOW_SECS);

        self.messages
            .iter()
            .filter(|m| m.agent_name == agent_name && m.timestamp.elapsed() < window)
            .filter(|m| m.content_hash == content_hash)
            .count()
            >= REPETITION_THRESHOLD
    }

    /// Get count of similar messages from same agent in window
    #[allow(dead_code)]
    pub fn repetition_count(&self, content_hash: u64, agent_name: &str) -> usize {
        let window = Duration::from_secs(REPETITION_WINDOW_SECS);

        self.messages
            .iter()
            .filter(|m| m.agent_name == agent_name && m.timestamp.elapsed() < window)
            .filter(|m| m.content_hash == content_hash)
            .count()
    }
}

/// Loop guard manager for the agent
#[derive(Debug, Clone)]
pub struct LoopGuard {
    trackers: Vec<CorrelationTracker>,
    self_name: String,
}

impl LoopGuard {
    pub fn new(self_name: String) -> Self {
        Self {
            trackers: Vec::new(),
            self_name,
        }
    }

    /// Simple hash function for content
    pub fn hash_content(content: &str) -> u64 {
        use std::collections::hash_map::DefaultHasher;
        let mut hasher = DefaultHasher::new();
        content.hash(&mut hasher);
        hasher.finish()
    }

    /// Check if speaking would be blocked (self-repetition)
    pub fn should_block(&self, correlation_id: &str, content: &str) -> bool {
        // Find or create tracker for this correlation
        if let Some(tracker) = self
            .trackers
            .iter()
            .find(|t| t.correlation_id == correlation_id)
        {
            let hash = Self::hash_content(content);
            tracker.is_repetitive(hash, &self.self_name)
        } else {
            false
        }
    }

    /// Record a message being spoken
    pub fn record(&mut self, correlation_id: &str, content: &str) {
        let hash = Self::hash_content(content);

        // Find or create tracker
        if let Some(tracker) = self
            .trackers
            .iter_mut()
            .find(|t| t.correlation_id == correlation_id)
        {
            tracker.add(hash, self.self_name.clone());
        } else {
            let mut tracker = CorrelationTracker::new(correlation_id.to_string());
            tracker.add(hash, self.self_name.clone());
            self.trackers.push(tracker);
        }

        // Cleanup old trackers
        self.trackers
            .retain(|t| t.created_at.elapsed() < Duration::from_secs(REPETITION_WINDOW_SECS * 2));
    }

    /// Get a notice message if blocking would occur
    pub fn get_block_notice(&self, correlation_id: &str, content: &str) -> Option<String> {
        if self.should_block(correlation_id, content) {
            Some(format!(
                "Blocked self-repetition in correlation {}",
                correlation_id
            ))
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hash_content_deterministic() {
        let h1 = LoopGuard::hash_content("hello world");
        let h2 = LoopGuard::hash_content("hello world");
        assert_eq!(h1, h2);
    }

    #[test]
    fn test_hash_content_different() {
        let h1 = LoopGuard::hash_content("hello world");
        let h2 = LoopGuard::hash_content("hello world!");
        assert_ne!(h1, h2);
    }

    #[test]
    fn test_loop_guard_blocks_repetition() {
        let mut guard = LoopGuard::new("test_agent".to_string());
        let correlation_id = "corr1";
        let content = "same message";

        // First few should not be blocked
        assert!(!guard.should_block(correlation_id, content));

        guard.record(correlation_id, content);
        guard.record(correlation_id, content);

        // After 3 repetitions, should be blocked
        guard.record(correlation_id, content);
        assert!(guard.should_block(correlation_id, content));
    }

    #[test]
    fn test_loop_guard_different_correlation() {
        let mut guard = LoopGuard::new("test_agent".to_string());
        let content = "same message";

        guard.record("corr1", content);
        guard.record("corr1", content);
        guard.record("corr1", content);

        // Different correlation should not be blocked
        assert!(!guard.should_block("corr2", content));
    }
}