1use 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
48pub 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}