Skip to main content

agent_engine/memory_backend/
mod.rs

1//! A chat's memory binding: the project its notes and context archives
2//! belong to, resolved once from the chat's folder and carried across
3//! threads, so that an ambient `cwd` cannot redirect a write. There is one
4//! store, plain files under the base directory; the blocking file calls run
5//! on a blocking worker. The binding also carries the run's author, which
6//! `tool.call` forwards to extensions as `caller`.
7use crate::{Result, RuntimeError};
8use agent_core::memory::author::Author;
9use agent_core::memory::store::{
10    self, MemoryDescriptor, MemoryRecord, NewMemoryRecord, ProjectMemoryQuery, ProjectScope,
11};
12use std::path::{Path, PathBuf};
13mod history;
14
15#[derive(Clone, Debug)]
16pub struct MemoryBinding {
17    base: PathBuf,
18    scope: std::result::Result<ProjectScope, String>,
19    author: Author,
20}
21fn error(message: impl Into<String>) -> RuntimeError {
22    RuntimeError::Tool(format!("memory: {}", message.into()))
23}
24
25/// What `memory_store` (`what` = "note") and `memory_forget` ("forget") say
26/// for an error of the plain-file store. When the line was written and only
27/// its sync failed, the model must not take it for a failed call: a second
28/// `memory_store` makes a second note, and a second `memory_forget` answers
29/// "not found".
30fn legacy_write_error(e: store::MemoryError, what: &str) -> RuntimeError {
31    let store::MemoryError::WrittenUnconfirmed { id, cause } = &e else {
32        return error(e.to_string());
33    };
34    match (id, what) {
35        (Some(id), "forget") => error(format!(
36            "the forget of {id} was written but the disk did not confirm it ({cause}). The note \
37             is forgotten now and searches no longer return it. Do not call memory_forget for it \
38             again (it would answer not found); to check, fetch {id} with memory_fetch: not found \
39             means it is forgotten."
40        )),
41        (None, "forget") => error(format!(
42            "the forget was written but the disk did not confirm it ({cause}). The note is \
43             forgotten now and searches no longer return it. Do not call memory_forget for it \
44             again (it would answer not found); use memory_search if you need to check."
45        )),
46        (Some(id), _) => error(format!(
47            "the note was written as {id} but the disk did not confirm it ({cause}). It is in \
48             memory now. Do NOT save it again: fetch {id} with memory_fetch to check, and call \
49             memory_store again only if the fetch does not return it."
50        )),
51        (None, _) => error(format!(
52            "the note was written but the disk did not confirm it ({cause}). It is in memory \
53             now. Do NOT save it again: use memory_search to find it first, and call \
54             memory_store again only if the search does not return it."
55        )),
56    }
57}
58
59impl MemoryBinding {
60    /// The binding of a chat that runs in `cwd`: the base directory, and the
61    /// project of that folder. Without a folder, or with one that has no
62    /// project, the binding has none and every call through it is refused;
63    /// no project is guessed from the process folder. (Under the daemon a
64    /// chat's folder differs from the process folder.)
65    pub fn for_cwd(cwd: Option<PathBuf>) -> Self {
66        let scope = cwd
67            .ok_or_else(|| "workspace unavailable".to_owned())
68            .and_then(|cwd| {
69                ProjectScope::discover(&cwd).map_err(|_| "project scope unavailable".to_owned())
70            });
71        Self::new(agent_core::config::base_dir(), scope)
72    }
73    /// Tests only: the binding of a chat that runs in the process folder,
74    /// which is what a runtime holds until it is bound.
75    #[cfg(test)]
76    pub(crate) fn for_process_cwd() -> Self {
77        Self::for_cwd(std::env::current_dir().ok())
78    }
79    fn new(base: PathBuf, scope: std::result::Result<ProjectScope, String>) -> Self {
80        Self {
81            base,
82            scope,
83            author: Author::fresh(),
84        }
85    }
86    /// Immutable execution provenance, not a saved conversation identity or
87    /// an authorization grant. Ordinary clones retain this exact author.
88    /// Forwarded to extensions as `caller` on `tool.call`; an extension's
89    /// forum records it as the run's author.
90    pub fn author(&self) -> &Author {
91        &self.author
92    }
93    /// New worker execution: fork only the author, retaining the captured
94    /// base and project (including a scope error). Resumed workers get a
95    /// new actor too; nothing is rediscovered.
96    pub fn for_worker(&self) -> Self {
97        Self {
98            author: self.author.child(),
99            ..self.clone()
100        }
101    }
102    pub fn scope(&self) -> Result<&ProjectScope> {
103        self.scope
104            .as_ref()
105            .map_err(|reason| error(format!("host project scope unavailable: {reason}")))
106    }
107    pub fn base(&self) -> &Path {
108        &self.base
109    }
110    pub async fn search(&self, q: ProjectMemoryQuery) -> Result<Vec<MemoryDescriptor>> {
111        let scope = self.scope()?.clone();
112        let base = self.base.clone();
113        tokio::task::spawn_blocking(move || store::search_project_in(&base, &scope, &q))
114            .await
115            .map_err(|_| error("legacy search worker failed"))?
116            .map_err(|e| error(e.to_string()))
117    }
118    pub async fn fetch(&self, ids: &[&str]) -> Result<Vec<MemoryRecord>> {
119        let scope = self.scope()?.clone();
120        if ids.is_empty() || ids.len() > 25 || ids.iter().any(|id| id.is_empty() || id.len() > 128)
121        {
122            return Err(error("fetch requires 1..=25 bounded exact IDs"));
123        }
124        let base = self.base.clone();
125        let ids = ids.iter().map(|s| s.to_string()).collect::<Vec<_>>();
126        tokio::task::spawn_blocking(move || {
127            store::fetch_exact_in(
128                &base,
129                &scope,
130                &ids.iter().map(String::as_str).collect::<Vec<_>>(),
131            )
132        })
133        .await
134        .map_err(|_| error("legacy fetch worker failed"))?
135        .map_err(|e| error(e.to_string()))
136    }
137    pub async fn store(&self, new: NewMemoryRecord) -> Result<MemoryRecord> {
138        let scope = self.scope()?.clone();
139        if new.content.len() > store::MAX_CONTENT_BYTES {
140            return Err(error("content exceeds 16384 bytes"));
141        }
142        let base = self.base.clone();
143        tokio::task::spawn_blocking(move || store::store_record_in(&base, &scope, new))
144            .await
145            .map_err(|_| error("legacy store worker failed"))?
146            .map_err(|e| legacy_write_error(e, "note"))
147    }
148    pub async fn forget(&self, id: &str) -> Result<()> {
149        let scope = self.scope()?.clone();
150        if id.is_empty() || id.len() > 128 {
151            return Err(error("invalid exact ID"));
152        }
153        let base = self.base.clone();
154        let id = id.to_owned();
155        tokio::task::spawn_blocking(move || store::forget_in(&base, &scope, &id))
156            .await
157            .map_err(|_| error("legacy forget worker failed"))?
158            .map_err(|e| legacy_write_error(e, "forget"))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn a_write_that_was_not_confirmed_tells_the_model_to_search_before_saving_again() {
168        let unconfirmed = |id: Option<&str>| store::MemoryError::WrittenUnconfirmed {
169            id: id.map(str::to_string),
170            cause: "EIO".into(),
171        };
172        let stored = legacy_write_error(unconfirmed(Some("mem-abc")), "note").to_string();
173        assert!(stored.contains("memory: the note was written as mem-abc"), "{stored}");
174        assert!(stored.contains("EIO"), "{stored}");
175        assert!(
176            stored.contains("Do NOT save it again: fetch mem-abc with memory_fetch to check"),
177            "{stored}"
178        );
179        let forgotten = legacy_write_error(unconfirmed(Some("mem-abc")), "forget").to_string();
180        assert!(forgotten.contains("memory: the forget of mem-abc was written"), "{forgotten}");
181        assert!(forgotten.contains("Do not call memory_forget for it again"), "{forgotten}");
182        assert!(forgotten.contains("fetch mem-abc with memory_fetch"), "{forgotten}");
183        // Without an id there is nothing exact to fetch: search.
184        let stored = legacy_write_error(unconfirmed(None), "note").to_string();
185        assert!(stored.contains("memory: the note was written but"), "{stored}");
186        assert!(stored.contains("Do NOT save it again: use memory_search"), "{stored}");
187        let forgotten = legacy_write_error(unconfirmed(None), "forget").to_string();
188        assert!(forgotten.contains("memory: the forget was written"), "{forgotten}");
189        // Any other store error is passed on as it was.
190        let other = legacy_write_error(store::MemoryError::NotFound("mem-1".into()), "forget");
191        assert_eq!(
192            other.to_string(),
193            error(store::MemoryError::NotFound("mem-1".into()).to_string()).to_string()
194        );
195    }
196
197    /// A binding made with no folder, or with a deleted one, has no project
198    /// and names why; every note call through it is refused before a file
199    /// is touched.
200    #[tokio::test]
201    async fn a_binding_without_a_folder_refuses_every_call() {
202        let gone = tempfile::tempdir().unwrap();
203        let gone_path = gone.path().join("deleted");
204        for (cwd, why) in [
205            (None, "workspace unavailable"),
206            (Some(gone_path), "project scope unavailable"),
207        ] {
208            let binding = MemoryBinding::for_cwd(cwd);
209            let refused = binding.scope().unwrap_err().to_string();
210            assert_eq!(
211                refused,
212                error(format!("host project scope unavailable: {why}")).to_string()
213            );
214            assert!(refused.ends_with(&format!("memory: host project scope unavailable: {why}")));
215            assert!(binding.search(Default::default()).await.is_err());
216            assert!(binding.fetch(&["mem-unknown"]).await.is_err());
217            assert!(binding.forget("mem-unknown").await.is_err());
218        }
219        assert_eq!(std::fs::read_dir(gone.path()).unwrap().count(), 0);
220    }
221}
222
223#[cfg(test)]
224mod worker_tests {
225    use super::*;
226
227    fn assert_same_storage(parent: &MemoryBinding, child: &MemoryBinding) {
228        assert_eq!(child.base, parent.base);
229        assert_eq!(child.scope, parent.scope);
230    }
231
232    #[test]
233    fn author_is_fresh_but_clones_keep_identity() {
234        let dir = tempfile::tempdir().unwrap();
235        let scope = ProjectScope::for_root(dir.path()).unwrap();
236        let parent = MemoryBinding::new(dir.path().into(), Ok(scope.clone()));
237        let fresh = MemoryBinding::new(dir.path().into(), Ok(scope));
238        parent.author().validate().unwrap();
239        assert!(parent.author().parent.is_none());
240        assert_ne!(parent.author().actor, fresh.author().actor);
241        assert_ne!(parent.author().group, fresh.author().group);
242        let clone = parent.clone();
243        assert_eq!(clone.author(), parent.author());
244        assert_same_storage(&parent, &clone);
245        // A worker's clones keep the worker's author, not the parent's.
246        let worker = parent.for_worker();
247        assert_eq!(worker.clone().author(), worker.author());
248        assert_ne!(worker.clone().author(), parent.author());
249    }
250
251    #[test]
252    fn worker_forks_only_author_even_with_bad_scope() {
253        let dir = tempfile::tempdir().unwrap();
254        let root = dir.path().canonicalize().unwrap();
255        let scope = ProjectScope::for_root(&root).unwrap();
256        for bad_scope in [false, true] {
257            let selected_scope = if bad_scope {
258                Err("captured project discovery failure".into())
259            } else {
260                Ok(scope.clone())
261            };
262            let parent = MemoryBinding::new(root.clone(), selected_scope);
263            let first = parent.for_worker();
264            let second = parent.for_worker();
265            for worker in [&first, &second] {
266                assert_same_storage(&parent, worker);
267                assert_eq!(worker.scope().is_err(), bad_scope);
268                worker.author().validate().unwrap();
269                assert_ne!(worker.author().actor, parent.author().actor);
270                assert_eq!(worker.author().group, parent.author().group);
271                assert_eq!(
272                    worker.author().parent.as_deref(),
273                    Some(parent.author().actor.as_str())
274                );
275                assert_eq!(worker.clone().author(), worker.author());
276            }
277            assert_ne!(first.author().actor, second.author().actor);
278            let grandchild = first.for_worker();
279            assert_same_storage(&first, &grandchild);
280            assert_eq!(grandchild.author().group, parent.author().group);
281            assert_eq!(
282                grandchild.author().parent.as_deref(),
283                Some(first.author().actor.as_str())
284            );
285            assert!(parent.author().parent.is_none());
286        }
287        assert!(!root.join("memory").exists());
288    }
289}