agent_engine/memory_backend/
mod.rs1use 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
25fn 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 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 #[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 pub fn author(&self) -> &Author {
91 &self.author
92 }
93 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 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 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 #[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 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}