Skip to main content

lc_agents/executor/
file_memory_tool.rs

1// lc-agents/src/executor/file_memory_tool.rs
2//! C1 — a file-memory tool mount (v0.22.1 §S8).
3//!
4//! Mechanical introspection alone cannot persist an agent's cross-session knowledge; the
5//! file-backed memory from lc-memory (`FileMemoryStore`) is normally prompt-injected via
6//! `with_memory`. This module exposes the *same* store as an ordinary `Arc<dyn BaseTool>`, so
7//! an agent can explicitly write, read, append and list named memories itself.
8//!
9//! ## Mounting (default off)
10//!
11//! It is **not** auto-attached — callers opt in explicitly:
12//! `AgentExecutor::with_memory_tool(store)` pushes it into the executor's tool set, mirroring
13//! the Rule-of-Two discipline: every safety feature here defaults to off and is opted into.
14//!
15//! ## Input contract
16//!
17//! Expects a JSON object in the tool input:
18//! `{"op":"view|create|write|append|delete|list","name":"<memory>","content":"<text>"}`.
19//! `op` is required; `name` is required except for `list`; `content` is required for
20//! `create`/`write`/`append`.
21
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use serde_json::Value;
27
28use lc_core::tools::{BaseTool, ToolError};
29use lc_memory::file_memory::{FileMemoryError, FileMemoryStore};
30
31/// Name under which the memory tool registers itself.
32pub const FILE_MEMORY_TOOL_NAME: &str = "file_memory";
33
34/// The memory tool described in the tool description so the LLM knows when to use it.
35const DESCRIPTION: &str = "\
36Persistent file-backed agent memory. Ops:
37- view: read a memory's text (name)
38- create: write a new named memory (name, content)
39- write: overwrite an existing memory (name, content)
40- append: append text to a memory (name, content)
41- delete: remove a memory (name)
42- list: list existing memory names
43Input JSON: {\"op\": \"view\", \"name\": \"user_profile\"}";
44
45/// An `Arc<dyn BaseTool>` adapter over a [`FileMemoryStore`], exposing the store's ops as a
46/// single string-in/string-out tool for the agent loop.
47pub struct FileMemoryTool {
48    store: FileMemoryStore,
49}
50
51impl FileMemoryTool {
52    /// Builds the tool over a store rooted at `root` (the directory is created if absent).
53    pub fn new(root: impl Into<PathBuf>) -> Result<Self, FileMemoryError> {
54        Ok(Self {
55            store: FileMemoryStore::new(root)?,
56        })
57    }
58}
59
60#[async_trait]
61impl BaseTool for FileMemoryTool {
62    fn name(&self) -> &str {
63        FILE_MEMORY_TOOL_NAME
64    }
65
66    fn description(&self) -> &str {
67        DESCRIPTION
68    }
69
70    async fn run(&self, input: String) -> Result<String, ToolError> {
71        let parsed: Value = serde_json::from_str(&input)
72            .map_err(|e| ToolError::InvalidInput(format!("memory input not JSON: {e}")))?;
73        let op = parsed
74            .get("op")
75            .and_then(Value::as_str)
76            .ok_or_else(|| ToolError::InvalidInput("missing required field 'op'".into()))?;
77        let name = parsed.get("name").and_then(Value::as_str);
78        let content = parsed.get("content").and_then(Value::as_str).unwrap_or("");
79
80        match op {
81            "view" => {
82                let n = require_name(name)?;
83                self.store
84                    .view(n)
85                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))
86            }
87            "list" => {
88                let entries = self
89                    .store
90                    .list()
91                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
92                if entries.is_empty() {
93                    Ok("(no memories)".to_string())
94                } else {
95                    let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
96                    Ok(format!("memories: {}", names.join(", ")))
97                }
98            }
99            "create" => {
100                let n = require_name(name)?;
101                self.store
102                    .create(n, content)
103                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
104                Ok(format!("created memory '{n}'"))
105            }
106            "write" => {
107                let n = require_name(name)?;
108                self.store
109                    .write(n, content)
110                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
111                Ok(format!("wrote memory '{n}'"))
112            }
113            "append" => {
114                let n = require_name(name)?;
115                self.store
116                    .append(n, content)
117                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
118                Ok(format!("appended to memory '{n}'"))
119            }
120            "delete" => {
121                let n = require_name(name)?;
122                self.store
123                    .delete(n)
124                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
125                Ok(format!("deleted memory '{n}'"))
126            }
127            other => Err(ToolError::InvalidInput(format!(
128                "unknown op '{other}' (expected view|create|write|append|delete|list)"
129            ))),
130        }
131    }
132}
133
134fn require_name(name: Option<&str>) -> Result<&str, ToolError> {
135    name.filter(|s| !s.is_empty())
136        .ok_or_else(|| ToolError::InvalidInput("missing required field 'name'".into()))
137}
138
139impl std::fmt::Debug for FileMemoryTool {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("FileMemoryTool").finish()
142    }
143}
144
145/// Convenience: builds and mounts as a boxed `BaseTool` (`Err` when the root dir can't be set up).
146pub fn mount(root: impl Into<PathBuf>) -> Result<Arc<dyn BaseTool>, FileMemoryError> {
147    Ok(Arc::new(FileMemoryTool::new(root)?))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn temp_tool() -> (tempfile::TempDir, FileMemoryTool) {
155        let dir = tempfile::tempdir().unwrap();
156        let tool = FileMemoryTool::new(dir.path()).unwrap();
157        (dir, tool)
158    }
159
160    async fn run(tool: &FileMemoryTool, s: &str) -> Result<String, ToolError> {
161        tool.run(s.to_string()).await
162    }
163
164    #[tokio::test]
165    async fn create_view_write_roundtrip() {
166        let (_d, tool) = temp_tool();
167        let out = run(
168            &tool,
169            r#"{"op":"create","name":"profile","content":"Alice"}"#,
170        )
171        .await
172        .unwrap();
173        assert!(out.contains("created memory 'profile'"));
174        let out = run(&tool, r#"{"op":"view","name":"profile"}"#)
175            .await
176            .unwrap();
177        assert_eq!(out, "Alice");
178        let out = run(
179            &tool,
180            r#"{"op":"append","name":"profile","content":" / engineer"}"#,
181        )
182        .await
183        .unwrap();
184        assert!(out.contains("appended"));
185        let view = run(&tool, r#"{"op":"view","name":"profile"}"#)
186            .await
187            .unwrap();
188        assert!(view.contains("engineer"));
189        let out = run(&tool, r#"{"op":"write","name":"profile","content":"Bob"}"#)
190            .await
191            .unwrap();
192        assert!(out.contains("wrote memory 'profile'"));
193        let view = run(&tool, r#"{"op":"view","name":"profile"}"#)
194            .await
195            .unwrap();
196        assert_eq!(view, "Bob");
197    }
198
199    #[tokio::test]
200    async fn list_and_delete() {
201        let (_d, tool) = temp_tool();
202        run(&tool, r#"{"op":"create","name":"a","content":"1"}"#)
203            .await
204            .unwrap();
205        run(&tool, r#"{"op":"create","name":"b","content":"2"}"#)
206            .await
207            .unwrap();
208        let list = run(&tool, r#"{"op":"list"}"#).await.unwrap();
209        assert!(list.contains("a") && list.contains("b"));
210        run(&tool, r#"{"op":"delete","name":"a"}"#).await.unwrap();
211        let list = run(&tool, r#"{"op":"list"}"#).await.unwrap();
212        assert!(!list.contains("a"));
213    }
214
215    #[tokio::test]
216    async fn missing_fields_error() {
217        let (_d, tool) = temp_tool();
218        assert!(matches!(
219            tool.run(r#"{}"#.to_string()).await.unwrap_err(),
220            ToolError::InvalidInput(_)
221        ));
222        let err = tool.run(r#"{"op":"view"}"#.to_string()).await.unwrap_err();
223        assert!(matches!(err, ToolError::InvalidInput(_)));
224        let err = tool
225            .run(r#"{"op":"bogus","name":"x"}"#.to_string())
226            .await
227            .unwrap_err();
228        assert!(matches!(err, ToolError::InvalidInput(_)));
229    }
230
231    #[tokio::test]
232    async fn view_missing_memory_is_execution_error_not_invalid_input() {
233        let (_d, tool) = temp_tool();
234        // a memory that doesn't exist is an execution outcome, not a malformed input
235        let err = tool
236            .run(r#"{"op":"view","name":"nope"}"#.to_string())
237            .await
238            .unwrap_err();
239        assert!(matches!(err, ToolError::ExecutionFailed(_)));
240    }
241
242    #[test]
243    fn mount_returns_arc_base_tool() {
244        let dir = tempfile::tempdir().unwrap();
245        let arc = mount(dir.path()).unwrap();
246        assert_eq!(arc.name(), FILE_MEMORY_TOOL_NAME);
247    }
248}