Skip to main content

bamboo_tools/tools/
memory_note.rs

1//! Persistent session-scoped note tool.
2//!
3//! Canonical tool name: `session_note`.
4//! The legacy `memory_note` name is accepted via executor-level alias routing.
5//!
6//! This tool lets the model store (and later retrieve) per-session notes that
7//! are loaded into the system prompt at the start of each round.
8//!
9//! Supports multiple **topics** per session so the model can track separate
10//! workstreams without clobbering each other.
11
12use async_trait::async_trait;
13use serde_json::json;
14
15use crate::tools::session_memory::{
16    execute_session_memory_action, parse_session_note_action, SESSION_NOTE_ACTION_NAMES,
17};
18use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, ToolResult};
19use bamboo_memory::memory_store::MemoryStore;
20
21const TOOL_NAME: &str = "session_note";
22const TOOL_DESCRIPTION: &str = "Read or update the persistent session-scoped note (markdown). Use this for durable local context, user preferences, constraints, and compression-resistant reminders within the current session/workstream. Do not use it as the primary long-term knowledge base. Hard limit: 12000 characters; compress before append/replace if needed.";
23
24#[derive(Debug, Clone)]
25pub struct SessionNoteTool {
26    memory_store: MemoryStore,
27}
28
29impl SessionNoteTool {
30    pub fn new() -> Self {
31        Self {
32            memory_store: MemoryStore::with_defaults(),
33        }
34    }
35
36    #[cfg(test)]
37    fn with_memory_store(memory_store: MemoryStore) -> Self {
38        Self { memory_store }
39    }
40}
41
42impl Default for SessionNoteTool {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48/// Deprecated compatibility alias for older code paths. The canonical tool type
49/// and registered tool name is [`SessionNoteTool`] / `session_note`.
50pub type MemoryNoteTool = SessionNoteTool;
51
52#[async_trait]
53impl Tool for SessionNoteTool {
54    fn name(&self) -> &str {
55        TOOL_NAME
56    }
57
58    fn description(&self) -> &str {
59        TOOL_DESCRIPTION
60    }
61
62    fn mutability(&self) -> crate::ToolMutability {
63        crate::ToolMutability::Mutating
64    }
65
66    fn call_mutability(&self, args: &serde_json::Value) -> crate::ToolMutability {
67        let action = args
68            .get("action")
69            .and_then(|v| v.as_str())
70            .unwrap_or("")
71            .trim()
72            .to_ascii_lowercase();
73        match action.as_str() {
74            "read" | "list_topics" => crate::ToolMutability::ReadOnly,
75            _ => crate::ToolMutability::Mutating,
76        }
77    }
78
79    fn call_concurrency_safe(&self, args: &serde_json::Value) -> bool {
80        matches!(self.call_mutability(args), crate::ToolMutability::ReadOnly)
81    }
82
83    fn parameters_schema(&self) -> serde_json::Value {
84        json!({
85            "type": "object",
86            "properties": {
87                "action": {
88                    "type": "string",
89                    "description": "Operation to perform on the note.",
90                    "enum": ["read", "append", "replace", "clear", "list_topics"]
91                },
92                "content": {
93                    "type": "string",
94                    "description": "Note content to append/replace (markdown). Required for append/replace."
95                },
96                "topic": {
97                    "type": "string",
98                    "description": "Optional topic name (alphanumeric/dash/underscore, max 50 chars). Defaults to 'default'. Use separate topics for unrelated workstreams."
99                }
100            },
101            "required": ["action"]
102        })
103    }
104
105    async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
106        Err(ToolError::Execution(format!(
107            "{TOOL_NAME} must be executed with ToolExecutionContext (session_id required)"
108        )))
109    }
110
111    async fn execute_with_context(
112        &self,
113        args: serde_json::Value,
114        ctx: ToolExecutionContext<'_>,
115    ) -> Result<ToolResult, ToolError> {
116        let Some(session_id) = ctx.session_id else {
117            return Err(ToolError::Execution(
118                "missing session_id in tool context".to_string(),
119            ));
120        };
121
122        let action_raw = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
123        let action = parse_session_note_action(action_raw)?;
124        let topic = args.get("topic").and_then(|v| v.as_str());
125        let content = args.get("content").and_then(|v| v.as_str());
126
127        execute_session_memory_action(
128            &self.memory_store,
129            session_id,
130            action,
131            topic,
132            content,
133            None,
134            SESSION_NOTE_ACTION_NAMES,
135        )
136        .await
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn session_note_schema_requires_action() {
146        let tool = SessionNoteTool::new();
147        let schema = tool.parameters_schema();
148        assert_eq!(schema["required"], json!(["action"]));
149        assert_eq!(tool.name(), TOOL_NAME);
150        assert_eq!(
151            schema["properties"]["action"]["enum"],
152            json!(["read", "append", "replace", "clear", "list_topics"])
153        );
154    }
155
156    #[test]
157    fn session_note_schema_has_topic_field() {
158        let tool = SessionNoteTool::new();
159        let schema = tool.parameters_schema();
160        assert!(schema["properties"]["topic"].is_object());
161        assert_eq!(schema["properties"]["topic"]["type"], "string");
162    }
163
164    #[tokio::test]
165    async fn session_note_requires_session_context() {
166        let tool = SessionNoteTool::new();
167        let result = tool
168            .execute_with_context(
169                json!({"action": "read"}),
170                ToolExecutionContext::none("tool_call"),
171            )
172            .await;
173
174        assert!(matches!(
175            result,
176            Err(ToolError::Execution(msg)) if msg.contains("session_id")
177        ));
178    }
179
180    #[tokio::test]
181    async fn session_note_validates_action_and_content_before_io() {
182        let tool = SessionNoteTool::new();
183
184        let unknown = tool
185            .execute_with_context(
186                json!({"action": "unknown"}),
187                ToolExecutionContext {
188                    session_id: Some("session-1"),
189                    tool_call_id: "tool_call_unknown",
190                    event_tx: None,
191                    available_tool_schemas: None,
192                    bypass_permissions: false,
193                    can_async_resume: false,
194                    pre_parsed_args: None,
195                },
196            )
197            .await;
198        assert!(matches!(
199            unknown,
200            Err(ToolError::InvalidArguments(msg)) if msg.contains("action must be one of")
201        ));
202
203        let missing_content = tool
204            .execute_with_context(
205                json!({"action": "replace"}),
206                ToolExecutionContext {
207                    session_id: Some("session-1"),
208                    tool_call_id: "tool_call_replace",
209                    event_tx: None,
210                    available_tool_schemas: None,
211                    bypass_permissions: false,
212                    can_async_resume: false,
213                    pre_parsed_args: None,
214                },
215            )
216            .await;
217        assert!(matches!(
218            missing_content,
219            Err(ToolError::InvalidArguments(msg)) if msg.contains("content is required")
220        ));
221    }
222
223    #[tokio::test]
224    async fn session_note_uses_memory_store_session_topics() {
225        let dir = tempfile::tempdir().unwrap();
226        let tool = SessionNoteTool::with_memory_store(MemoryStore::new(dir.path()));
227
228        let append = tool
229            .execute_with_context(
230                json!({"action": "append", "topic": "backend", "content": "API finalized"}),
231                ToolExecutionContext {
232                    session_id: Some("session-1"),
233                    tool_call_id: "tool_call_append",
234                    event_tx: None,
235                    available_tool_schemas: None,
236                    bypass_permissions: false,
237                    can_async_resume: false,
238                    pre_parsed_args: None,
239                },
240            )
241            .await
242            .expect("append should succeed");
243        let append_json: serde_json::Value = serde_json::from_str(&append.result).unwrap();
244        assert_eq!(append_json["action"], "append");
245        assert_eq!(append_json["length_chars"], "API finalized".chars().count());
246
247        let read = tool
248            .execute_with_context(
249                json!({"action": "read", "topic": "backend"}),
250                ToolExecutionContext {
251                    session_id: Some("session-1"),
252                    tool_call_id: "tool_call_read",
253                    event_tx: None,
254                    available_tool_schemas: None,
255                    bypass_permissions: false,
256                    can_async_resume: false,
257                    pre_parsed_args: None,
258                },
259            )
260            .await
261            .expect("read should succeed");
262        let read_json: serde_json::Value = serde_json::from_str(&read.result).unwrap();
263        assert_eq!(read_json["action"], "read");
264        assert_eq!(read_json["content"], "API finalized");
265        assert_eq!(read_json["length_chars"], "API finalized".chars().count());
266        assert_eq!(read_json["body_truncated"], false);
267
268        let list = tool
269            .execute_with_context(
270                json!({"action": "list_topics"}),
271                ToolExecutionContext {
272                    session_id: Some("session-1"),
273                    tool_call_id: "tool_call_list",
274                    event_tx: None,
275                    available_tool_schemas: None,
276                    bypass_permissions: false,
277                    can_async_resume: false,
278                    pre_parsed_args: None,
279                },
280            )
281            .await
282            .expect("list should succeed");
283        let list_json: serde_json::Value = serde_json::from_str(&list.result).unwrap();
284        assert_eq!(list_json["topics"][0], "backend");
285        assert_eq!(list_json["count"], 1);
286
287        let clear = tool
288            .execute_with_context(
289                json!({"action": "clear", "topic": "backend"}),
290                ToolExecutionContext {
291                    session_id: Some("session-1"),
292                    tool_call_id: "tool_call_clear",
293                    event_tx: None,
294                    available_tool_schemas: None,
295                    bypass_permissions: false,
296                    can_async_resume: false,
297                    pre_parsed_args: None,
298                },
299            )
300            .await
301            .expect("clear should succeed");
302        let clear_json: serde_json::Value = serde_json::from_str(&clear.result).unwrap();
303        assert_eq!(clear_json["action"], "clear");
304        assert_eq!(clear_json["deleted"], true);
305    }
306
307    #[tokio::test]
308    async fn session_note_read_reports_truncation_and_append_enforces_limit() {
309        let dir = tempfile::tempdir().unwrap();
310        let tool = SessionNoteTool::with_memory_store(MemoryStore::new(dir.path()));
311        let long_content = "x".repeat(32);
312
313        tool.execute_with_context(
314            json!({"action": "replace", "topic": "default", "content": long_content}),
315            ToolExecutionContext {
316                session_id: Some("session-2"),
317                tool_call_id: "tool_call_replace_long",
318                event_tx: None,
319                available_tool_schemas: None,
320                bypass_permissions: false,
321                can_async_resume: false,
322                pre_parsed_args: None,
323            },
324        )
325        .await
326        .expect("replace should succeed");
327
328        let read = tool
329            .execute_with_context(
330                json!({"action": "read", "topic": "default"}),
331                ToolExecutionContext {
332                    session_id: Some("session-2"),
333                    tool_call_id: "tool_call_read_long",
334                    event_tx: None,
335                    available_tool_schemas: None,
336                    bypass_permissions: false,
337                    can_async_resume: false,
338                    pre_parsed_args: None,
339                },
340            )
341            .await
342            .expect("read should succeed");
343        let read_json: serde_json::Value = serde_json::from_str(&read.result).unwrap();
344        assert_eq!(read_json["length_chars"], 32);
345        assert_eq!(read_json["body_truncated"], false);
346
347        tool.execute_with_context(
348            json!({"action": "replace", "topic": "limit", "content": "x".repeat(crate::tools::session_memory::MAX_SESSION_NOTE_CHARS - 1)}),
349            ToolExecutionContext {
350                session_id: Some("session-3"),
351                tool_call_id: "tool_call_replace_limit",
352                event_tx: None,
353                available_tool_schemas: None,
354                bypass_permissions: false,
355                can_async_resume: false,
356                pre_parsed_args: None,
357            },
358        )
359        .await
360        .expect("replace near limit should succeed");
361
362        let append_err = tool
363            .execute_with_context(
364                json!({"action": "append", "topic": "limit", "content": "y"}),
365                ToolExecutionContext {
366                    session_id: Some("session-3"),
367                    tool_call_id: "tool_call_append_limit",
368                    event_tx: None,
369                    available_tool_schemas: None,
370                    bypass_permissions: false,
371                    can_async_resume: false,
372                    pre_parsed_args: None,
373                },
374            )
375            .await
376            .expect_err("append should exceed limit");
377        assert!(append_err
378            .to_string()
379            .contains("session note would exceed the limit"));
380    }
381}