Skip to main content

bamboo_notification/
policy.rs

1//! Pure classification of agent events into candidate notifications.
2//!
3//! [`classify`] is the single source of truth for *which* events are
4//! notification-worthy, *how* they are categorised and prioritised, and *what*
5//! their dedup key is. It is intentionally side-effect free: it reads an
6//! [`AgentEvent`] and the user's [`NotificationPreferences`] and returns an
7//! optional [`ClassifiedNotification`]. Dedup timing and id/timestamp minting
8//! happen one layer up in [`crate::service`].
9
10use bamboo_agent_core::AgentEvent;
11
12use crate::preferences::NotificationPreferences;
13
14/// Maximum body length for a clarification notification, in characters.
15const CLARIFICATION_BODY_MAX: usize = 120;
16
17/// The kind of user-facing notification a classified event maps to.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum NotificationCategory {
20    /// A tool is requesting explicit user approval before running.
21    NeedsApproval,
22    /// The agent is asking the user a free-form clarifying question.
23    NeedsClarification,
24    /// Context usage has reached a critical level.
25    ContextCritical,
26    /// A background sub-agent task has completed.
27    SubagentCompleted,
28}
29
30impl NotificationCategory {
31    /// Returns the stable wire string for this category.
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            NotificationCategory::NeedsApproval => "needs_approval",
35            NotificationCategory::NeedsClarification => "needs_clarification",
36            NotificationCategory::ContextCritical => "context_critical",
37            NotificationCategory::SubagentCompleted => "subagent_completed",
38        }
39    }
40}
41
42/// The urgency of a notification, surfaced to the client for sorting/styling.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum NotificationPriority {
45    /// Needs prompt user attention (e.g. the agent is blocked on input).
46    High,
47    /// Informational but worth surfacing.
48    Normal,
49    /// Low-signal; can be quietly delivered.
50    Low,
51}
52
53impl NotificationPriority {
54    /// Returns the stable wire string for this priority.
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            NotificationPriority::High => "high",
58            NotificationPriority::Normal => "normal",
59            NotificationPriority::Low => "low",
60        }
61    }
62}
63
64/// A notification candidate produced by [`classify`].
65///
66/// This carries everything the policy decided; the service layer adds the
67/// volatile bits (unique id, creation timestamp) when materialising the
68/// outgoing [`AgentEvent::Notification`].
69#[derive(Debug, Clone, PartialEq)]
70pub struct ClassifiedNotification {
71    /// Category the event was classified into.
72    pub category: NotificationCategory,
73    /// Priority assigned to the notification.
74    pub priority: NotificationPriority,
75    /// Short title line.
76    pub title: String,
77    /// Body text.
78    pub body: String,
79    /// Stable key used to coalesce duplicate notifications within a window.
80    pub dedup_key: String,
81}
82
83/// Returns `true` when a clarification question is actually a permission prompt.
84///
85/// Permission prompts are produced by the permission gate / `request_permissions`
86/// tool and carry one of these markers in their question text; we route them to
87/// the `NeedsApproval` category rather than `NeedsClarification`.
88fn is_permission_prompt(question: &str) -> bool {
89    question.contains("Permission required") || question.contains("Permission Request")
90}
91
92/// Truncates `text` to at most `max` characters, appending an ellipsis when cut.
93///
94/// Operates on Unicode scalar values (chars), not bytes, so it never splits a
95/// multi-byte character.
96fn truncate(text: &str, max: usize) -> String {
97    if text.chars().count() <= max {
98        return text.to_string();
99    }
100    let mut out: String = text.chars().take(max).collect();
101    out.push('…');
102    out
103}
104
105/// Classifies an agent event into an optional notification candidate.
106///
107/// Returns `None` immediately when notifications are disabled, when the event is
108/// not notification-worthy, or when the specific per-category preference is off.
109/// The match uses a catch-all arm, so adding new [`AgentEvent`] variants never
110/// breaks this function.
111pub fn classify(
112    session_id: &str,
113    event: &AgentEvent,
114    prefs: &NotificationPreferences,
115) -> Option<ClassifiedNotification> {
116    if !prefs.enabled {
117        return None;
118    }
119
120    match event {
121        AgentEvent::NeedClarification {
122            question,
123            tool_call_id,
124            ..
125        } => {
126            let (category, title) = if is_permission_prompt(question) {
127                if !prefs.on_tool_approval {
128                    return None;
129                }
130                (NotificationCategory::NeedsApproval, "Approval needed")
131            } else {
132                if !prefs.on_clarification {
133                    return None;
134                }
135                (
136                    NotificationCategory::NeedsClarification,
137                    "Your input needed",
138                )
139            };
140            Some(ClassifiedNotification {
141                category,
142                priority: NotificationPriority::High,
143                title: title.to_string(),
144                body: truncate(question, CLARIFICATION_BODY_MAX),
145                dedup_key: format!(
146                    "clarification:{session_id}:{}",
147                    tool_call_id.as_deref().unwrap_or("")
148                ),
149            })
150        }
151
152        AgentEvent::ContextPressureNotification { level, message, .. } => {
153            if level != "critical" {
154                return None;
155            }
156            if !prefs.on_context_pressure {
157                return None;
158            }
159            Some(ClassifiedNotification {
160                category: NotificationCategory::ContextCritical,
161                priority: NotificationPriority::Normal,
162                title: "Context almost full".to_string(),
163                body: message.clone(),
164                dedup_key: format!("context:{session_id}"),
165            })
166        }
167
168        AgentEvent::SubAgentCompleted {
169            child_session_id,
170            status,
171            ..
172        } => {
173            if status != "completed" {
174                return None;
175            }
176            if !prefs.on_subagent_complete {
177                return None;
178            }
179            Some(ClassifiedNotification {
180                category: NotificationCategory::SubagentCompleted,
181                priority: NotificationPriority::Normal,
182                title: "Background task done".to_string(),
183                body: format!("A background task finished ({}).", child_session_id),
184                dedup_key: format!("subagent:{}", child_session_id),
185            })
186        }
187
188        _ => None,
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn clarification(question: &str, tool_call_id: Option<&str>) -> AgentEvent {
197        AgentEvent::NeedClarification {
198            question: question.to_string(),
199            options: None,
200            tool_call_id: tool_call_id.map(str::to_string),
201            tool_name: None,
202            allow_custom: true,
203        }
204    }
205
206    fn context(level: &str) -> AgentEvent {
207        AgentEvent::ContextPressureNotification {
208            percent: 95.0,
209            level: level.to_string(),
210            message: "Context is nearly full.".to_string(),
211        }
212    }
213
214    fn subagent(status: &str) -> AgentEvent {
215        AgentEvent::SubAgentCompleted {
216            parent_session_id: "parent".to_string(),
217            child_session_id: "child-1".to_string(),
218            status: status.to_string(),
219            error: None,
220        }
221    }
222
223    #[test]
224    fn plain_clarification_is_needs_clarification() {
225        let prefs = NotificationPreferences::default();
226        let event = clarification("Which file should I edit?", Some("tc-1"));
227        let result = classify("sess", &event, &prefs).unwrap();
228        assert_eq!(result.category, NotificationCategory::NeedsClarification);
229        assert_eq!(result.priority, NotificationPriority::High);
230        assert_eq!(result.title, "Your input needed");
231        assert_eq!(result.dedup_key, "clarification:sess:tc-1");
232    }
233
234    #[test]
235    fn permission_prompt_is_needs_approval() {
236        let prefs = NotificationPreferences::default();
237        let event = clarification("**Permission required** to run `rm -rf`", Some("tc-2"));
238        let result = classify("sess", &event, &prefs).unwrap();
239        assert_eq!(result.category, NotificationCategory::NeedsApproval);
240        assert_eq!(result.title, "Approval needed");
241    }
242
243    #[test]
244    fn permission_request_marker_also_matches() {
245        let prefs = NotificationPreferences::default();
246        let event = clarification("Permission Request: write to disk", None);
247        let result = classify("sess", &event, &prefs).unwrap();
248        assert_eq!(result.category, NotificationCategory::NeedsApproval);
249        // No tool_call_id → empty trailing segment.
250        assert_eq!(result.dedup_key, "clarification:sess:");
251    }
252
253    #[test]
254    fn clarification_body_truncates_with_ellipsis() {
255        let prefs = NotificationPreferences::default();
256        let long = "x".repeat(200);
257        let event = clarification(&long, None);
258        let result = classify("sess", &event, &prefs).unwrap();
259        assert_eq!(result.body.chars().count(), CLARIFICATION_BODY_MAX + 1);
260        assert!(result.body.ends_with('…'));
261    }
262
263    #[test]
264    fn clarification_gated_by_on_clarification() {
265        let prefs = NotificationPreferences {
266            on_clarification: false,
267            ..NotificationPreferences::default()
268        };
269        let event = clarification("plain question", None);
270        assert!(classify("sess", &event, &prefs).is_none());
271    }
272
273    #[test]
274    fn approval_gated_by_on_tool_approval() {
275        let prefs = NotificationPreferences {
276            on_tool_approval: false,
277            ..NotificationPreferences::default()
278        };
279        let event = clarification("Permission required: do thing", None);
280        assert!(classify("sess", &event, &prefs).is_none());
281    }
282
283    #[test]
284    fn context_critical_fires() {
285        let prefs = NotificationPreferences::default();
286        let result = classify("sess", &context("critical"), &prefs).unwrap();
287        assert_eq!(result.category, NotificationCategory::ContextCritical);
288        assert_eq!(result.priority, NotificationPriority::Normal);
289        assert_eq!(result.title, "Context almost full");
290        assert_eq!(result.body, "Context is nearly full.");
291        assert_eq!(result.dedup_key, "context:sess");
292    }
293
294    #[test]
295    fn context_warning_is_none() {
296        let prefs = NotificationPreferences::default();
297        assert!(classify("sess", &context("warning"), &prefs).is_none());
298    }
299
300    #[test]
301    fn context_gated_by_on_context_pressure() {
302        let prefs = NotificationPreferences {
303            on_context_pressure: false,
304            ..NotificationPreferences::default()
305        };
306        assert!(classify("sess", &context("critical"), &prefs).is_none());
307    }
308
309    #[test]
310    fn subagent_completed_fires() {
311        let prefs = NotificationPreferences::default();
312        let result = classify("sess", &subagent("completed"), &prefs).unwrap();
313        assert_eq!(result.category, NotificationCategory::SubagentCompleted);
314        assert_eq!(result.priority, NotificationPriority::Normal);
315        assert_eq!(result.title, "Background task done");
316        assert_eq!(result.body, "A background task finished (child-1).");
317        assert_eq!(result.dedup_key, "subagent:child-1");
318    }
319
320    #[test]
321    fn subagent_error_is_none() {
322        let prefs = NotificationPreferences::default();
323        assert!(classify("sess", &subagent("error"), &prefs).is_none());
324    }
325
326    #[test]
327    fn subagent_gated_by_on_subagent_complete() {
328        let prefs = NotificationPreferences {
329            on_subagent_complete: false,
330            ..NotificationPreferences::default()
331        };
332        assert!(classify("sess", &subagent("completed"), &prefs).is_none());
333    }
334
335    #[test]
336    fn master_switch_off_yields_none() {
337        let prefs = NotificationPreferences {
338            enabled: false,
339            ..NotificationPreferences::default()
340        };
341        assert!(classify("sess", &context("critical"), &prefs).is_none());
342        assert!(classify("sess", &subagent("completed"), &prefs).is_none());
343        assert!(classify("sess", &clarification("q", None), &prefs).is_none());
344    }
345
346    #[test]
347    fn unrelated_variant_is_none() {
348        let prefs = NotificationPreferences::default();
349        let event = AgentEvent::Token {
350            content: "hello".to_string(),
351        };
352        assert!(classify("sess", &event, &prefs).is_none());
353    }
354}