Skip to main content

lc_agents/hooks/
content_filter.rs

1// lc-agents/src/hooks/content_filter.rs
2//! ContentFilterHook — filters or replaces sensitive words in stream output.
3//!
4//! Scans each streaming token for sensitive words and either drops the token
5//! or replaces the sensitive content with a placeholder.
6
7use async_trait::async_trait;
8
9use super::{AgentHook, StreamAction};
10
11/// A hook that filters sensitive words from streaming output.
12///
13/// When `on_stream_chunk` is invoked, it checks the token for any of the
14/// configured sensitive words. If found, the token is either filtered (dropped)
15/// or replaced with a placeholder.
16///
17/// # Example
18///
19/// ```rust,ignore
20/// use lc_agents::hooks::ContentFilterHook;
21///
22/// let hook = ContentFilterHook::new(vec!["secret".to_string(), "password".to_string()]);
23/// let executor = AgentExecutor::new(agent, tools).hook(hook);
24/// ```
25pub struct ContentFilterHook {
26    /// Words to filter from the stream.
27    sensitive_words: Vec<String>,
28    /// Placeholder to replace sensitive words with.
29    placeholder: String,
30    /// If true, drop the entire token if it contains a sensitive word.
31    /// If false, replace the sensitive word with the placeholder.
32    drop_token: bool,
33}
34
35impl ContentFilterHook {
36    /// Creates a new ContentFilterHook with the given sensitive words.
37    pub fn new(sensitive_words: Vec<String>) -> Self {
38        Self {
39            sensitive_words,
40            placeholder: "[REDACTED]".to_string(),
41            drop_token: false,
42        }
43    }
44
45    /// Sets the placeholder for replaced words.
46    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
47        self.placeholder = placeholder.into();
48        self
49    }
50
51    /// Sets whether to drop entire tokens containing sensitive words.
52    pub fn with_drop_token(mut self, drop: bool) -> Self {
53        self.drop_token = drop;
54        self
55    }
56
57    /// Checks if the text contains any sensitive word.
58    fn contains_sensitive(&self, text: &str) -> bool {
59        let text_lower = text.to_lowercase();
60        self.sensitive_words
61            .iter()
62            .any(|word| text_lower.contains(&word.to_lowercase()))
63    }
64
65    /// Replaces sensitive words in the text with the placeholder.
66    fn replace_sensitive(&self, text: &str) -> String {
67        let mut result = text.to_string();
68        for word in &self.sensitive_words {
69            // Case-insensitive replacement
70            let lower = text.to_lowercase();
71            let mut start = 0;
72            while let Some(pos) = lower[start..].find(&word.to_lowercase()) {
73                let actual_pos = start + pos;
74                let end = actual_pos + word.len();
75                result = format!(
76                    "{}{}{}",
77                    &result[..actual_pos],
78                    self.placeholder,
79                    &result[end..]
80                );
81                start = actual_pos + self.placeholder.len();
82                // Re-check the modified string
83                let new_lower = result.to_lowercase();
84                if start >= new_lower.len() {
85                    break;
86                }
87                if !new_lower[start..].contains(&word.to_lowercase()) {
88                    break;
89                }
90            }
91        }
92        result
93    }
94}
95
96#[async_trait]
97impl AgentHook for ContentFilterHook {
98    fn on_stream_chunk(&self, chunk: &str) -> StreamAction {
99        if !self.contains_sensitive(chunk) {
100            return StreamAction::Forward(chunk.to_string());
101        }
102
103        if self.drop_token {
104            StreamAction::Filter
105        } else {
106            StreamAction::Replace(self.replace_sensitive(chunk))
107        }
108    }
109}