caliban_tools_builtin/memory/
mod.rs1use std::sync::Arc;
9use std::sync::OnceLock;
10
11use async_trait::async_trait;
12use caliban_agent_core::{Tool, ToolContext, ToolError};
13use caliban_memory::{TopicDraft, TopicKind, TopicLoader};
14use caliban_provider::{ContentBlock, TextBlock};
15use serde::Deserialize;
16use serde_json::{Value, json};
17
18#[derive(Debug)]
21pub struct ReadMemoryTopicTool {
22 loader: Arc<TopicLoader>,
23 schema: OnceLock<Value>,
24}
25
26impl ReadMemoryTopicTool {
27 #[must_use]
29 pub fn new(loader: Arc<TopicLoader>) -> Self {
30 Self {
31 loader,
32 schema: OnceLock::new(),
33 }
34 }
35}
36
37#[derive(Debug, Deserialize)]
38struct ReadInput {
39 name: String,
40}
41
42#[async_trait]
43impl Tool for ReadMemoryTopicTool {
44 fn name(&self) -> &'static str {
45 "ReadMemoryTopic"
46 }
47
48 fn description(&self) -> &'static str {
49 "Read one auto-memory topic file by slug. The slug is the value in the `MEMORY.md` index entry (without `.md`). Returns the topic's markdown body."
50 }
51
52 fn input_schema(&self) -> &Value {
53 self.schema.get_or_init(|| {
54 json!({
55 "type": "object",
56 "properties": {
57 "name": {
58 "type": "string",
59 "description": "Topic slug (kebab-case, no path separators, no leading '.')."
60 }
61 },
62 "required": ["name"]
63 })
64 })
65 }
66
67 async fn invoke(&self, input: Value, _cx: ToolContext) -> Result<Vec<ContentBlock>, ToolError> {
68 let parsed: ReadInput = crate::parse_input(input)?;
69 let topic = self.loader.read(&parsed.name).map_err(|e| match e {
70 caliban_memory::MemoryError::InvalidSlug { .. } => {
71 ToolError::invalid_input(e.to_string())
72 }
73 other => ToolError::execution(other),
74 })?;
75 let text = format!(
76 "→ Memory topic '{}' ({}): {}\n\n{}",
77 topic.name,
78 topic.kind.as_str(),
79 topic.description,
80 topic.body
81 );
82 Ok(vec![ContentBlock::Text(TextBlock {
83 text,
84 cache_control: None,
85 })])
86 }
87}
88
89#[derive(Debug)]
92pub struct WriteMemoryTopicTool {
93 loader: Arc<TopicLoader>,
94 schema: OnceLock<Value>,
95}
96
97impl WriteMemoryTopicTool {
98 #[must_use]
100 pub fn new(loader: Arc<TopicLoader>) -> Self {
101 Self {
102 loader,
103 schema: OnceLock::new(),
104 }
105 }
106}
107
108#[derive(Debug, Deserialize)]
109struct WriteInput {
110 name: String,
111 description: String,
112 #[serde(rename = "type")]
113 kind: String,
114 body: String,
115}
116
117#[async_trait]
118impl Tool for WriteMemoryTopicTool {
119 fn name(&self) -> &'static str {
120 "WriteMemoryTopic"
121 }
122
123 fn description(&self) -> &'static str {
124 "Write or update an auto-memory topic file. Atomic: writes the topic file AND updates the MEMORY.md index entry in a single call. `type` must be one of: user, feedback, project, reference."
125 }
126
127 fn input_schema(&self) -> &Value {
128 self.schema.get_or_init(|| {
129 json!({
130 "type": "object",
131 "properties": {
132 "name": {
133 "type": "string",
134 "description": "Topic slug (kebab-case, no path separators, no leading '.')."
135 },
136 "description": {
137 "type": "string",
138 "description": "One-line summary (≤120 chars). Surfaces into the MEMORY.md index entry."
139 },
140 "type": {
141 "type": "string",
142 "enum": ["user", "feedback", "project", "reference"],
143 "description": "Memory type. user=facts about the user, feedback=durable rules/preferences, project=durable project facts, reference=stable external IDs."
144 },
145 "body": {
146 "type": "string",
147 "description": "Markdown body. Use [[other-slug]] to cross-reference siblings (purely informational)."
148 }
149 },
150 "required": ["name", "description", "type", "body"]
151 })
152 })
153 }
154
155 fn parallel_conflict_key(&self, input: &Value) -> Option<String> {
156 let name = input.get("name").and_then(Value::as_str)?;
157 let kind = input.get("type").and_then(Value::as_str)?;
158 Some(format!("memory:{kind}:{name}"))
159 }
160
161 async fn invoke(&self, input: Value, _cx: ToolContext) -> Result<Vec<ContentBlock>, ToolError> {
162 let parsed: WriteInput = crate::parse_input(input)?;
163 let kind = TopicKind::parse(&parsed.kind).ok_or_else(|| {
164 ToolError::invalid_input(format!(
165 "type must be one of user|feedback|project|reference (got '{}')",
166 parsed.kind
167 ))
168 })?;
169 let draft = TopicDraft {
170 name: parsed.name,
171 description: parsed.description,
172 kind,
173 body: parsed.body,
174 };
175 let path = self.loader.write(&draft).map_err(|e| match e {
176 caliban_memory::MemoryError::InvalidSlug { .. } => {
177 ToolError::invalid_input(e.to_string())
178 }
179 other => ToolError::execution(other),
180 })?;
181 Ok(vec![ContentBlock::Text(TextBlock {
182 text: format!(
183 "→ Wrote memory topic '{}' to {} and updated MEMORY.md index",
184 draft.name,
185 path.display(),
186 ),
187 cache_control: None,
188 })])
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use caliban_memory::TopicLoader;
196 use tempfile::TempDir;
197 use tokio_util::sync::CancellationToken;
198
199 fn ctx() -> ToolContext {
200 ToolContext {
201 tool_use_id: "t1".into(),
202 cancel: CancellationToken::new(),
203 hooks: None,
204 turn_index: 0,
205 }
206 }
207
208 fn loader(dir: &std::path::Path) -> Arc<TopicLoader> {
209 Arc::new(TopicLoader::new(dir.to_path_buf()))
210 }
211
212 #[tokio::test]
213 async fn read_returns_body_content() {
214 let tmp = TempDir::new().unwrap();
215 std::fs::write(
216 tmp.path().join("foo.md"),
217 "---\nname: foo\ndescription: \"d\"\nmetadata:\n type: user\n---\n\nThe body text.\n",
218 )
219 .unwrap();
220 let tool = ReadMemoryTopicTool::new(loader(tmp.path()));
221 let out = tool.invoke(json!({"name": "foo"}), ctx()).await.unwrap();
222 let ContentBlock::Text(t) = &out[0] else {
223 panic!()
224 };
225 assert!(t.text.contains("The body text."));
226 assert!(t.text.contains("foo"));
227 assert!(t.text.contains("(user)"));
228 }
229
230 #[tokio::test]
231 async fn write_creates_file_and_updates_index() {
232 let tmp = TempDir::new().unwrap();
233 let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
234 tool.invoke(
235 json!({
236 "name": "personal-email",
237 "description": "use personal email for ~/dev/personal/**",
238 "type": "feedback",
239 "body": "Use john.ford2002@gmail.com.\n"
240 }),
241 ctx(),
242 )
243 .await
244 .unwrap();
245 let topic_path = tmp.path().join("personal-email.md");
246 assert!(topic_path.exists());
247 assert!(!tmp.path().join("personal-email.md.tmp").exists());
249 let index = std::fs::read_to_string(tmp.path().join("MEMORY.md")).unwrap();
250 assert!(index.contains("[personal-email](personal-email.md)"));
251 }
252
253 #[tokio::test]
254 async fn write_rejects_invalid_type() {
255 let tmp = TempDir::new().unwrap();
256 let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
257 let err = tool
258 .invoke(
259 json!({
260 "name": "bad",
261 "description": "d",
262 "type": "junk",
263 "body": "x"
264 }),
265 ctx(),
266 )
267 .await
268 .unwrap_err();
269 assert!(matches!(err, ToolError::InvalidInput(_)));
270 }
271
272 #[tokio::test]
273 async fn write_rejects_traversal_slug() {
274 let tmp = TempDir::new().unwrap();
275 let tool = WriteMemoryTopicTool::new(loader(tmp.path()));
276 let err = tool
277 .invoke(
278 json!({
279 "name": "../escape",
280 "description": "d",
281 "type": "user",
282 "body": "x"
283 }),
284 ctx(),
285 )
286 .await
287 .unwrap_err();
288 assert!(matches!(err, ToolError::InvalidInput(_)));
289 }
290}