Skip to main content

bamboo_server_tools/
notify.rs

1//! Agent-invocable `notify` tool: proactively alert the human owner outside
2//! the chat transcript.
3//!
4//! This crate stays free of `AppState` (see the crate doc), so the actual
5//! delivery (classify/dedup through `NotificationService`, broadcast to
6//! connected clients, fan-out to desktop/push sinks) lives in `bamboo-server`
7//! behind the [`NotificationDispatcher`] port — this module only validates
8//! arguments and calls it. Crib: `SessionInspectorTool` for the framework-side
9//! tool shape, `ChildSessionAdapter` (bamboo-server) for the port-adapter
10//! precedent.
11
12use async_trait::async_trait;
13use serde::Deserialize;
14use serde_json::json;
15use std::sync::Arc;
16
17use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
18
19/// Maximum `title` length, in characters.
20const MAX_TITLE_CHARS: usize = 100;
21/// Maximum `message` length, in characters.
22const MAX_MESSAGE_CHARS: usize = 500;
23
24/// Port the `notify` tool dispatches through. Implemented server-side by an
25/// adapter with real access to `NotificationService`, the session broadcast
26/// channels, and configured delivery sinks (desktop/ntfy/bark).
27///
28/// `dispatch` is deliberately synchronous and fire-and-forget — it mirrors
29/// `bamboo_server::notify_sinks::NotificationSink::deliver`: the real
30/// classify/dedup/broadcast/sink work happens off this call (a spawned
31/// task), so a slow or unreachable delivery channel can never stall the
32/// agent loop. Consequently the tool can only ever confirm a request was
33/// ATTEMPTED via currently-enabled channels, never that it was delivered.
34pub trait NotificationDispatcher: Send + Sync {
35    /// `priority` is always one of `"low"` | `"normal"` | `"high"` (validated
36    /// by [`NotifyTool::invoke`] before this is called). `url` is an optional
37    /// click-through link for push channels that support one.
38    fn dispatch(
39        &self,
40        session_id: &str,
41        title: &str,
42        body: &str,
43        priority: &str,
44        url: Option<&str>,
45    );
46}
47
48#[derive(Debug, Deserialize)]
49struct NotifyArgs {
50    title: String,
51    message: String,
52    #[serde(default)]
53    priority: Option<String>,
54    #[serde(default)]
55    url: Option<String>,
56}
57
58/// Agent-invocable tool for proactively alerting the human owner outside this
59/// conversation (desktop popup and/or configured push channels).
60pub struct NotifyTool {
61    dispatcher: Arc<dyn NotificationDispatcher>,
62}
63
64impl NotifyTool {
65    pub fn new(dispatcher: Arc<dyn NotificationDispatcher>) -> Self {
66        Self { dispatcher }
67    }
68}
69
70#[async_trait]
71impl Tool for NotifyTool {
72    fn name(&self) -> &str {
73        "notify"
74    }
75
76    fn description(&self) -> &str {
77        "Proactively alert the human owner OUTSIDE this conversation (OS desktop popup and/or any push channels \
78         they've configured), for things worth interrupting them for even if they are not watching this session \
79         right now: a scheduled reminder firing, a long-running task finishing, or something that needs their \
80         attention. Do NOT use this for a normal conversational reply — that already reaches the user through the \
81         chat transcript itself. Use it sparingly, only when the message has standalone value outside the chat."
82    }
83
84    fn parameters_schema(&self) -> serde_json::Value {
85        json!({
86            "type": "object",
87            "properties": {
88                "title": {
89                    "type": "string",
90                    "description": "Short notification title (at most 100 characters)."
91                },
92                "message": {
93                    "type": "string",
94                    "description": "Notification body (at most 500 characters)."
95                },
96                "priority": {
97                    "type": "string",
98                    "enum": ["low", "normal", "high"],
99                    "description": "Urgency of the notification. Defaults to \"normal\"."
100                },
101                "url": {
102                    "type": "string",
103                    "description": "Optional click-through URL/deep-link surfaced by push channels that support one."
104                }
105            },
106            "required": ["title", "message"]
107        })
108    }
109
110    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
111        // Mutates nothing in the session or workspace: it only fires an
112        // outbound side effect (OS popup / push) and never produces a result
113        // the model could branch on, so it's safe in the read-only parallel
114        // batch alongside other tool calls in the same round.
115        ToolClass::READONLY_PARALLEL
116    }
117
118    async fn invoke(
119        &self,
120        args: serde_json::Value,
121        ctx: ToolCtx,
122    ) -> Result<ToolOutcome, ToolError> {
123        let session_id = ctx
124            .session_id()
125            .ok_or_else(|| {
126                ToolError::Execution("notify requires a session_id in tool context".to_string())
127            })?
128            .to_string();
129
130        let parsed: NotifyArgs = serde_json::from_value(args)
131            .map_err(|e| ToolError::InvalidArguments(format!("Invalid notify args: {e}")))?;
132
133        let title = parsed.title.trim();
134        if title.is_empty() {
135            return Err(ToolError::InvalidArguments(
136                "notify: `title` must not be empty".to_string(),
137            ));
138        }
139        if title.chars().count() > MAX_TITLE_CHARS {
140            return Err(ToolError::InvalidArguments(format!(
141                "notify: `title` must be at most {MAX_TITLE_CHARS} characters (got {})",
142                title.chars().count()
143            )));
144        }
145
146        let message = parsed.message.trim();
147        if message.is_empty() {
148            return Err(ToolError::InvalidArguments(
149                "notify: `message` must not be empty".to_string(),
150            ));
151        }
152        if message.chars().count() > MAX_MESSAGE_CHARS {
153            return Err(ToolError::InvalidArguments(format!(
154                "notify: `message` must be at most {MAX_MESSAGE_CHARS} characters (got {})",
155                message.chars().count()
156            )));
157        }
158
159        let priority = match parsed.priority.as_deref() {
160            None => "normal",
161            Some(p) if p.eq_ignore_ascii_case("low") => "low",
162            Some(p) if p.eq_ignore_ascii_case("normal") => "normal",
163            Some(p) if p.eq_ignore_ascii_case("high") => "high",
164            Some(other) => {
165                return Err(ToolError::InvalidArguments(format!(
166                    "notify: `priority` must be one of low|normal|high (got \"{other}\")"
167                )))
168            }
169        };
170
171        let url = parsed
172            .url
173            .as_deref()
174            .map(str::trim)
175            .filter(|s| !s.is_empty());
176
177        self.dispatcher
178            .dispatch(&session_id, title, message, priority, url);
179
180        Ok(ToolOutcome::Completed(ToolResult {
181            success: true,
182            result: format!(
183                "Notification \"{title}\" (priority: {priority}) sent to every notification channel currently \
184                 enabled in settings (in-app plus any configured desktop/push channels). This confirms the request \
185                 was dispatched, not that it was delivered or read."
186            ),
187            display_preference: Some("Collapsible".to_string()),
188            images: Vec::new(),
189        }))
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::sync::Mutex;
197
198    #[derive(Default)]
199    struct RecordedCall {
200        session_id: String,
201        title: String,
202        body: String,
203        priority: String,
204        url: Option<String>,
205    }
206
207    #[derive(Default)]
208    struct MockDispatcher {
209        calls: Mutex<Vec<RecordedCall>>,
210    }
211
212    impl NotificationDispatcher for MockDispatcher {
213        fn dispatch(
214            &self,
215            session_id: &str,
216            title: &str,
217            body: &str,
218            priority: &str,
219            url: Option<&str>,
220        ) {
221            self.calls.lock().unwrap().push(RecordedCall {
222                session_id: session_id.to_string(),
223                title: title.to_string(),
224                body: body.to_string(),
225                priority: priority.to_string(),
226                url: url.map(str::to_string),
227            });
228        }
229    }
230
231    fn ctx() -> ToolCtx {
232        let mut ctx = ToolCtx::none("call-1");
233        ctx.session_id = Some(std::sync::Arc::from("sess-1"));
234        ctx
235    }
236
237    #[test]
238    fn tool_name_is_notify() {
239        let dispatcher = Arc::new(MockDispatcher::default());
240        let tool = NotifyTool::new(dispatcher);
241        assert_eq!(tool.name(), "notify");
242    }
243
244    #[test]
245    fn schema_requires_title_and_message() {
246        let dispatcher = Arc::new(MockDispatcher::default());
247        let tool = NotifyTool::new(dispatcher);
248        let schema = tool.parameters_schema();
249        let required: Vec<&str> = schema["required"]
250            .as_array()
251            .unwrap()
252            .iter()
253            .map(|v| v.as_str().unwrap())
254            .collect();
255        assert!(required.contains(&"title"));
256        assert!(required.contains(&"message"));
257        assert!(!required.contains(&"priority"));
258        assert!(!required.contains(&"url"));
259    }
260
261    #[test]
262    fn classify_is_readonly_parallel() {
263        let dispatcher = Arc::new(MockDispatcher::default());
264        let tool = NotifyTool::new(dispatcher);
265        assert_eq!(tool.classify(&json!({})), ToolClass::READONLY_PARALLEL);
266    }
267
268    #[tokio::test]
269    async fn invoke_dispatches_with_defaults_and_confirms() {
270        let dispatcher = Arc::new(MockDispatcher::default());
271        let tool = NotifyTool::new(dispatcher.clone());
272
273        let out = tool
274            .invoke(json!({"title": "Reminder", "message": "Stand up"}), ctx())
275            .await
276            .expect("invoke should succeed");
277        let ToolOutcome::Completed(result) = out else {
278            panic!("expected Completed")
279        };
280        assert!(result.success);
281        assert!(result.result.contains("Reminder"));
282        assert!(result.result.contains("normal"));
283
284        let calls = dispatcher.calls.lock().unwrap();
285        assert_eq!(calls.len(), 1);
286        assert_eq!(calls[0].session_id, "sess-1");
287        assert_eq!(calls[0].title, "Reminder");
288        assert_eq!(calls[0].body, "Stand up");
289        assert_eq!(calls[0].priority, "normal");
290        assert_eq!(calls[0].url, None);
291    }
292
293    #[tokio::test]
294    async fn invoke_forwards_explicit_priority_and_url() {
295        let dispatcher = Arc::new(MockDispatcher::default());
296        let tool = NotifyTool::new(dispatcher.clone());
297
298        tool.invoke(
299            json!({
300                "title": "Build finished",
301                "message": "The nightly build finished with 0 failures.",
302                "priority": "HIGH",
303                "url": "bamboo://session/sess-1"
304            }),
305            ctx(),
306        )
307        .await
308        .expect("invoke should succeed");
309
310        let calls = dispatcher.calls.lock().unwrap();
311        assert_eq!(calls[0].priority, "high");
312        assert_eq!(calls[0].url.as_deref(), Some("bamboo://session/sess-1"));
313    }
314
315    #[tokio::test]
316    async fn invoke_rejects_missing_title() {
317        let dispatcher = Arc::new(MockDispatcher::default());
318        let tool = NotifyTool::new(dispatcher);
319        let result = tool.invoke(json!({"message": "hi"}), ctx()).await;
320        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
321    }
322
323    #[tokio::test]
324    async fn invoke_rejects_missing_message() {
325        let dispatcher = Arc::new(MockDispatcher::default());
326        let tool = NotifyTool::new(dispatcher);
327        let result = tool.invoke(json!({"title": "hi"}), ctx()).await;
328        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
329    }
330
331    #[tokio::test]
332    async fn invoke_rejects_oversize_title() {
333        let dispatcher = Arc::new(MockDispatcher::default());
334        let tool = NotifyTool::new(dispatcher);
335        let long_title = "x".repeat(MAX_TITLE_CHARS + 1);
336        let result = tool
337            .invoke(json!({"title": long_title, "message": "hi"}), ctx())
338            .await;
339        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
340    }
341
342    #[tokio::test]
343    async fn invoke_rejects_oversize_message() {
344        let dispatcher = Arc::new(MockDispatcher::default());
345        let tool = NotifyTool::new(dispatcher);
346        let long_message = "x".repeat(MAX_MESSAGE_CHARS + 1);
347        let result = tool
348            .invoke(json!({"title": "hi", "message": long_message}), ctx())
349            .await;
350        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
351    }
352
353    #[tokio::test]
354    async fn invoke_rejects_empty_title_after_trim() {
355        let dispatcher = Arc::new(MockDispatcher::default());
356        let tool = NotifyTool::new(dispatcher);
357        let result = tool
358            .invoke(json!({"title": "   ", "message": "hi"}), ctx())
359            .await;
360        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
361    }
362
363    #[tokio::test]
364    async fn invoke_rejects_invalid_priority() {
365        let dispatcher = Arc::new(MockDispatcher::default());
366        let tool = NotifyTool::new(dispatcher);
367        let result = tool
368            .invoke(
369                json!({"title": "hi", "message": "hi", "priority": "urgent"}),
370                ctx(),
371            )
372            .await;
373        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
374    }
375
376    #[tokio::test]
377    async fn invoke_rejects_missing_session_id() {
378        let dispatcher = Arc::new(MockDispatcher::default());
379        let tool = NotifyTool::new(dispatcher);
380        let result = tool
381            .invoke(
382                json!({"title": "hi", "message": "hi"}),
383                ToolCtx::none("call-1"),
384            )
385            .await;
386        assert!(matches!(result, Err(ToolError::Execution(_))));
387    }
388
389    #[tokio::test]
390    async fn invoke_treats_blank_url_as_absent() {
391        let dispatcher = Arc::new(MockDispatcher::default());
392        let tool = NotifyTool::new(dispatcher.clone());
393        tool.invoke(json!({"title": "hi", "message": "hi", "url": "   "}), ctx())
394            .await
395            .expect("invoke should succeed");
396        let calls = dispatcher.calls.lock().unwrap();
397        assert_eq!(calls[0].url, None);
398    }
399}