Skip to main content

agentd/context/
memory.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Agent memory**: a durable JSON key/value space in the instance's store
3//! namespace — `memory/<key>` — with optional TTL, size caps, and prefix
4//! listing. Listing uses the store's own `list` when it has one; a store
5//! without one is served from the `memory/_index` record this module
6//! maintains, so listing works on every backend rather than only the ones that
7//! can enumerate. Overridable by an MCP memory server through the registry.
8
9use crate::state::{Durable, Kind, now_ms};
10use crate::store::StoreError;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use std::collections::BTreeMap;
14
15/// The index record's id (never a user key: keys may not start with `_`).
16pub const INDEX_ID: &str = "_index";
17
18/// One memory record (`state` of the envelope).
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct Record {
21    pub value: Value,
22    pub ts: u64,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub ttl_ms: Option<u64>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub by: Option<String>,
27}
28
29impl Record {
30    pub fn expired(&self, now: u64) -> bool {
31        self.ttl_ms
32            .is_some_and(|ttl| now >= self.ts.saturating_add(ttl))
33    }
34    pub fn meta(&self) -> Value {
35        json!({"ts": self.ts, "ttl_ms": self.ttl_ms, "by": self.by})
36    }
37}
38
39/// The memory façade over the store.
40pub struct Memory {
41    max_value_bytes: usize,
42    list_default_limit: usize,
43    /// `Some(index)` when the store has no `list` (probed lazily).
44    index: Option<BTreeMap<String, u64>>,
45    probed: bool,
46}
47
48impl Memory {
49    pub fn new(max_value_bytes: usize, list_default_limit: usize) -> Memory {
50        Memory {
51            max_value_bytes,
52            list_default_limit,
53            index: None,
54            probed: false,
55        }
56    }
57
58    /// Validate a key: non-empty, no whitespace, not reserved, bounded.
59    pub fn check_key(key: &str) -> Result<(), String> {
60        if key.is_empty() || key.len() > 256 {
61            return Err("memory key must be 1..=256 chars".into());
62        }
63        if key.starts_with('_') {
64            return Err("memory keys starting with '_' are reserved".into());
65        }
66        if key.chars().any(char::is_whitespace) {
67            return Err("memory key must not contain whitespace".into());
68        }
69        Ok(())
70    }
71
72    fn probe(&mut self, d: &Durable) {
73        if self.probed {
74            return;
75        }
76        self.probed = true;
77        match d.list(Kind::Memory) {
78            Ok(_) => self.index = None,
79            Err(StoreError::Unsupported(_)) => {
80                let mut idx = BTreeMap::new();
81                if let Ok(Some(env)) = d.get(Kind::Memory, INDEX_ID)
82                    && let Some(m) = env.state.get("keys").and_then(Value::as_object)
83                {
84                    for (k, v) in m {
85                        idx.insert(k.clone(), v.as_u64().unwrap_or(0));
86                    }
87                }
88                self.index = Some(idx);
89            }
90            Err(_) => {}
91        }
92    }
93
94    fn write_index(&self, d: &Durable) -> Result<(), StoreError> {
95        if let Some(idx) = &self.index {
96            d.put(Kind::Memory, INDEX_ID, json!({"keys": idx}), None)?;
97        }
98        Ok(())
99    }
100
101    /// `memory.set {key, value, ttl?}` → the record's meta.
102    pub fn set(
103        &mut self,
104        d: &Durable,
105        key: &str,
106        value: Value,
107        ttl_ms: Option<u64>,
108        by: Option<&str>,
109    ) -> Result<Value, String> {
110        Self::check_key(key)?;
111        let bytes = value.to_string().len();
112        if bytes > self.max_value_bytes {
113            return Err(format!(
114                "memory value is {bytes} bytes; memory.max_value_bytes is {}",
115                self.max_value_bytes
116            ));
117        }
118        self.probe(d);
119        let rec = Record {
120            value,
121            ts: now_ms(),
122            ttl_ms,
123            by: by.map(str::to_string),
124        };
125        d.put(
126            Kind::Memory,
127            key,
128            serde_json::to_value(&rec).unwrap_or(Value::Null),
129            None,
130        )
131        .map_err(|e| e.to_string())?;
132        if let Some(idx) = &mut self.index {
133            idx.insert(key.to_string(), rec.ts);
134            self.write_index(d).map_err(|e| e.to_string())?;
135        }
136        Ok(json!({"ok": true, "key": key, "meta": rec.meta()}))
137    }
138
139    /// `memory.push {key, value}` — append to the ARRAY at `key`, creating it.
140    /// The durable queue primitive: producers push, a consumer shifts, and the
141    /// list survives restarts. Read-modify-write is atomic here because the
142    /// reactor is the single writer.
143    pub fn push(
144        &mut self,
145        d: &Durable,
146        key: &str,
147        value: Value,
148        by: Option<&str>,
149    ) -> Result<Value, String> {
150        let cur = self.get(d, key)?;
151        let mut arr = if cur["found"] == json!(true) {
152            match cur["value"].as_array() {
153                Some(a) => a.clone(),
154                None => return Err(format!("memory.push: the value at {key:?} is not an array")),
155            }
156        } else {
157            Vec::new()
158        };
159        arr.push(value);
160        let length = arr.len();
161        self.set(d, key, Value::Array(arr), None, by)?;
162        Ok(json!({"ok": true, "key": key, "length": length}))
163    }
164
165    /// `memory.shift {key}` — remove and return the FIRST element
166    /// (`{found: false}` on empty or absent — never an error, so a drain loop
167    /// can just stop).
168    pub fn shift(&mut self, d: &Durable, key: &str, by: Option<&str>) -> Result<Value, String> {
169        self.take(d, key, by, true)
170    }
171
172    /// `memory.pop {key}` — remove and return the LAST element.
173    pub fn pop(&mut self, d: &Durable, key: &str, by: Option<&str>) -> Result<Value, String> {
174        self.take(d, key, by, false)
175    }
176
177    fn take(
178        &mut self,
179        d: &Durable,
180        key: &str,
181        by: Option<&str>,
182        first: bool,
183    ) -> Result<Value, String> {
184        let cur = self.get(d, key)?;
185        if cur["found"] != json!(true) {
186            return Ok(json!({"found": false, "key": key, "remaining": 0}));
187        }
188        let mut arr = match cur["value"].as_array() {
189            Some(a) => a.clone(),
190            None => {
191                return Err(format!(
192                    "memory.{}: the value at {key:?} is not an array",
193                    if first { "shift" } else { "pop" }
194                ));
195            }
196        };
197        if arr.is_empty() {
198            return Ok(json!({"found": false, "key": key, "remaining": 0}));
199        }
200        let value = if first {
201            arr.remove(0)
202        } else {
203            arr.pop().expect("non-empty")
204        };
205        let remaining = arr.len();
206        self.set(d, key, Value::Array(arr), None, by)?;
207        Ok(json!({"found": true, "key": key, "value": value, "remaining": remaining}))
208    }
209
210    /// `memory.get {key}` → `{value?, meta?, found}` (expired ⇒ not found).
211    pub fn get(&mut self, d: &Durable, key: &str) -> Result<Value, String> {
212        Self::check_key(key)?;
213        match d.get(Kind::Memory, key).map_err(|e| e.to_string())? {
214            None => Ok(json!({"found": false, "key": key})),
215            Some(env) => {
216                let rec: Record = serde_json::from_value(env.state)
217                    .map_err(|e| format!("memory record {key}: {e}"))?;
218                if rec.expired(now_ms()) {
219                    let _ = self.delete(d, key);
220                    return Ok(json!({"found": false, "key": key, "expired": true}));
221                }
222                Ok(json!({"found": true, "key": key, "value": rec.value, "meta": rec.meta()}))
223            }
224        }
225    }
226
227    /// `memory.delete {key}`.
228    pub fn delete(&mut self, d: &Durable, key: &str) -> Result<Value, String> {
229        Self::check_key(key)?;
230        self.probe(d);
231        d.delete(Kind::Memory, key).map_err(|e| e.to_string())?;
232        if let Some(idx) = &mut self.index {
233            idx.remove(key);
234            self.write_index(d).map_err(|e| e.to_string())?;
235        }
236        Ok(json!({"ok": true, "key": key}))
237    }
238
239    /// `memory.list {prefix?, limit?}` → `{keys: [{key, ts?}], truncated}`.
240    pub fn list(
241        &mut self,
242        d: &Durable,
243        prefix: Option<&str>,
244        limit: Option<usize>,
245    ) -> Result<Value, String> {
246        self.probe(d);
247        let limit = limit.unwrap_or(self.list_default_limit).max(1);
248        let prefix = prefix.unwrap_or("");
249        let mut keys: Vec<Value> = match &self.index {
250            Some(idx) => idx
251                .iter()
252                .filter(|(k, _)| k.starts_with(prefix))
253                .map(|(k, ts)| json!({"key": k, "ts": ts}))
254                .collect(),
255            None => {
256                let listed = d.list(Kind::Memory).map_err(|e| e.to_string())?;
257                let mut out = Vec::new();
258                for ks in listed {
259                    let Some((_, id)) = crate::store::parse_key(d.prefix(), d.instance(), &ks.key)
260                    else {
261                        continue;
262                    };
263                    if id == INDEX_ID || !id.starts_with(prefix) {
264                        continue;
265                    }
266                    out.push(json!({"key": id, "seq": ks.seq}));
267                }
268                out
269            }
270        };
271        keys.sort_by(|a, b| a["key"].as_str().cmp(&b["key"].as_str()));
272        let truncated = keys.len() > limit;
273        keys.truncate(limit);
274        Ok(json!({"keys": keys, "truncated": truncated}))
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::state::Policy;
282    use crate::store::memory::MemoryStore;
283    use std::sync::Arc;
284
285    #[test]
286    fn set_get_list_delete_ttl_and_caps() {
287        let mem = Arc::new(MemoryStore::new());
288        let d = Durable::new(mem, "agentd", "i", Policy::default(), None);
289        let mut m = Memory::new(64, 10);
290        m.set(&d, "user/name", json!("andrii"), None, Some("root"))
291            .unwrap();
292        m.set(&d, "user/tz", json!("Europe/Kyiv"), Some(1), None)
293            .unwrap();
294        m.set(&d, "other", json!({"a": 1}), None, None).unwrap();
295        let g = m.get(&d, "user/name").unwrap();
296        assert_eq!(g["found"], json!(true));
297        assert_eq!(g["value"], json!("andrii"));
298        assert_eq!(g["meta"]["by"], json!("root"));
299        std::thread::sleep(std::time::Duration::from_millis(3));
300        let g = m.get(&d, "user/tz").unwrap();
301        assert_eq!(g["found"], json!(false), "expired: {g}");
302        assert_eq!(g["expired"], json!(true));
303        let l = m.list(&d, Some("user/"), None).unwrap();
304        assert_eq!(l["keys"].as_array().unwrap().len(), 1, "{l}");
305        assert_eq!(l["keys"][0]["key"], json!("user/name"));
306        let all = m.list(&d, None, Some(1)).unwrap();
307        assert_eq!(all["truncated"], json!(true));
308        m.delete(&d, "user/name").unwrap();
309        assert_eq!(m.get(&d, "user/name").unwrap()["found"], json!(false));
310        // Caps + key rules.
311        assert!(
312            m.set(&d, "big", json!("x".repeat(100)), None, None)
313                .is_err()
314        );
315        assert!(m.set(&d, "_reserved", json!(1), None, None).is_err());
316        assert!(m.set(&d, "has space", json!(1), None, None).is_err());
317        assert!(m.get(&d, "").is_err());
318    }
319
320    #[test]
321    fn index_record_is_kept_when_the_store_cannot_list() {
322        // A store whose list is Unsupported.
323        struct NoList(MemoryStore);
324        impl crate::store::Store for NoList {
325            fn put(
326                &self,
327                k: &str,
328                s: u64,
329                e: &Value,
330            ) -> Result<crate::store::PutOutcome, StoreError> {
331                self.0.put(k, s, e)
332            }
333            fn get(&self, k: &str, s: Option<u64>) -> Result<Option<Value>, StoreError> {
334                self.0.get(k, s)
335            }
336            fn list(&self, _p: &str) -> Result<Vec<crate::store::KeySeq>, StoreError> {
337                Err(StoreError::Unsupported("list"))
338            }
339            fn delete(&self, k: &str) -> Result<(), StoreError> {
340                self.0.delete(k)
341            }
342            fn kind(&self) -> &'static str {
343                "nolist"
344            }
345        }
346        let d = Durable::new(
347            Arc::new(NoList(MemoryStore::new())),
348            "agentd",
349            "i",
350            Policy::default(),
351            None,
352        );
353        let mut m = Memory::new(1024, 10);
354        m.set(&d, "a", json!(1), None, None).unwrap();
355        m.set(&d, "b", json!(2), None, None).unwrap();
356        let idx = d
357            .get(Kind::Memory, INDEX_ID)
358            .unwrap()
359            .expect("index record");
360        assert!(idx.state["keys"].get("a").is_some());
361        assert_eq!(
362            m.list(&d, None, None).unwrap()["keys"]
363                .as_array()
364                .unwrap()
365                .len(),
366            2
367        );
368        m.delete(&d, "a").unwrap();
369        // A fresh façade rebuilds its index from the record.
370        let mut m2 = Memory::new(1024, 10);
371        let l = m2.list(&d, None, None).unwrap();
372        assert_eq!(l["keys"].as_array().unwrap().len(), 1);
373        assert_eq!(l["keys"][0]["key"], json!("b"));
374    }
375}