Skip to main content

zeroclaw_memory/
agent_scoped_markdown.rs

1//! Cross-agent path-walk variant for Markdown-backed agents.
2//!
3//! The generic [`AgentScopedMemory`](crate::agent_scoped::AgentScopedMemory)
4//! relies on the inner backend filtering rows by `agent_id` at the
5//! storage layer. Markdown has no shared store: each agent's
6//! attribution IS its on-disk path
7//! (`<install>/agents/<alias>/workspace/MEMORY.md` plus
8//! `memory/YYYY-MM-DD.md`). Cross-agent recall therefore composes
9//! multiple `MarkdownMemory` instances rather than filtering rows.
10//!
11//! `AgentScopedMarkdownMemory` holds the bound agent's
12//! `MarkdownMemory` plus a peer set of `(alias, MarkdownMemory)` pairs
13//! resolved at construction from the `read_memory_from` allowlist.
14//! Stores go to the bound agent only; recalls union across all peers
15//! and stamp each merged entry's `key` with a `[<alias>] ` prefix so
16//! callers can attribute the row.
17
18use super::markdown::MarkdownMemory;
19use super::traits::{Memory, MemoryCategory, MemoryEntry};
20use anyhow::Result;
21use async_trait::async_trait;
22
23/// Resolved Markdown-backed peer entry: the sibling agent's alias plus
24/// a `MarkdownMemory` pointed at that sibling's workspace dir.
25pub struct MarkdownPeer {
26    pub alias: String,
27    pub memory: MarkdownMemory,
28}
29
30/// Composed Markdown memory for one agent: own backend plus the
31/// resolved peer set. Stores write only to the bound agent; recalls
32/// union across own + peers with per-row alias attribution.
33pub struct AgentScopedMarkdownMemory {
34    /// The bound agent's alias. Used for attribution on the agent's
35    /// own rows in the merged recall output.
36    own_alias: String,
37    /// The bound agent's MarkdownMemory pointing at
38    /// `<install>/agents/<own_alias>/workspace/`.
39    own: MarkdownMemory,
40    /// Resolved sibling agents this wrapper recalls from. Empty means
41    /// jailed — the agent only sees its own rows. Same-backend
42    /// invariant: every peer here is also Markdown-backed (the
43    /// cross-reference validator rejects mismatched-backend allowlist
44    /// entries at config load).
45    peers: Vec<MarkdownPeer>,
46}
47
48impl AgentScopedMarkdownMemory {
49    pub fn new(
50        own_alias: impl Into<String>,
51        own: MarkdownMemory,
52        peers: Vec<MarkdownPeer>,
53    ) -> Self {
54        Self {
55            own_alias: own_alias.into(),
56            own,
57            peers,
58        }
59    }
60
61    /// Stamp `[<alias>] ` onto each entry's `key` so a merged recall
62    /// makes attribution visible in logs / prompts that surface the key
63    /// verbatim, and populate `agent_alias` + `agent_id` so the
64    /// dashboard renders Markdown rows with the same per-agent chip
65    /// the SQL backends emit via JOIN.
66    fn attribute(alias: &str, mut entries: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
67        for entry in &mut entries {
68            entry.key = format!("[{alias}] {}", entry.key);
69            entry.agent_alias = Some(alias.to_string());
70            entry.agent_id = Some(alias.to_string());
71        }
72        entries
73    }
74
75    /// Lighter-weight variant for non-merged reads (own-only `get`,
76    /// `list`): set attribution without rewriting the key. Used by
77    /// `get` / `list` where the row already comes from the bound
78    /// agent's own backend and no `[alias]` namespacing is needed.
79    fn stamp_attribution(alias: &str, mut entries: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
80        for entry in &mut entries {
81            entry.agent_alias = Some(alias.to_string());
82            entry.agent_id = Some(alias.to_string());
83        }
84        entries
85    }
86}
87
88#[async_trait]
89impl Memory for AgentScopedMarkdownMemory {
90    fn name(&self) -> &str {
91        // Identical to MarkdownMemory's name so dashboards and log
92        // grep keep working.
93        self.own.name()
94    }
95
96    async fn health_check(&self) -> bool {
97        // The bound agent's own MarkdownMemory is the canonical health
98        // signal; peer-dir failures are logged at recall time, not
99        // surfaced as a failed health check (a missing peer dir means
100        // the operator has not yet created that sibling agent — the
101        // current agent is still healthy).
102        self.own.health_check().await
103    }
104
105    async fn store(
106        &self,
107        key: &str,
108        content: &str,
109        category: MemoryCategory,
110        session_id: Option<&str>,
111    ) -> Result<()> {
112        self.own.store(key, content, category, session_id).await
113    }
114
115    async fn store_with_metadata(
116        &self,
117        key: &str,
118        content: &str,
119        category: MemoryCategory,
120        session_id: Option<&str>,
121        namespace: Option<&str>,
122        importance: Option<f64>,
123    ) -> Result<()> {
124        self.own
125            .store_with_metadata(key, content, category, session_id, namespace, importance)
126            .await
127    }
128
129    async fn store_with_agent(
130        &self,
131        key: &str,
132        content: &str,
133        category: MemoryCategory,
134        session_id: Option<&str>,
135        namespace: Option<&str>,
136        importance: Option<f64>,
137        _agent_id: Option<&str>,
138    ) -> Result<()> {
139        // Markdown attribution lives on the on-disk path; the bound
140        // agent's MarkdownMemory always writes to its own dir, so the
141        // caller-supplied agent_id is intentionally ignored here.
142        self.own
143            .store_with_metadata(key, content, category, session_id, namespace, importance)
144            .await
145    }
146
147    async fn recall(
148        &self,
149        query: &str,
150        limit: usize,
151        session_id: Option<&str>,
152        since: Option<&str>,
153        until: Option<&str>,
154    ) -> Result<Vec<MemoryEntry>> {
155        let mut merged = Self::attribute(
156            &self.own_alias,
157            self.own
158                .recall(query, limit, session_id, since, until)
159                .await?,
160        );
161        for peer in &self.peers {
162            match peer
163                .memory
164                .recall(query, limit, session_id, since, until)
165                .await
166            {
167                Ok(rows) => merged.extend(Self::attribute(&peer.alias, rows)),
168                Err(error) => ::zeroclaw_log::record!(
169                    WARN,
170                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
171                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
172                        .with_attrs(
173                            ::serde_json::json!({"peer": peer.alias, "error": format!("{}", error)})
174                        ),
175                    "AgentScopedMarkdownMemory peer recall failed; continuing with other peers"
176                ),
177            }
178        }
179        merged.truncate(limit);
180        Ok(merged)
181    }
182
183    async fn recall_for_agents(
184        &self,
185        allowed_agent_ids: &[&str],
186        query: &str,
187        limit: usize,
188        session_id: Option<&str>,
189        since: Option<&str>,
190        until: Option<&str>,
191    ) -> Result<Vec<MemoryEntry>> {
192        // Empty allowlist means "no extra restriction" — fall back to
193        // the bound own + all peers union.
194        if allowed_agent_ids.is_empty() {
195            return self.recall(query, limit, session_id, since, until).await;
196        }
197
198        // The trait passes UUID strings; for Markdown the runtime
199        // factory passes alias strings (Markdown has no UUID indirection
200        // at the storage layer). We treat the strings as opaque
201        // identifiers and intersect with own_alias + peer aliases.
202        let mut merged = Vec::new();
203        if allowed_agent_ids.contains(&self.own_alias.as_str()) {
204            merged.extend(Self::attribute(
205                &self.own_alias,
206                self.own
207                    .recall(query, limit, session_id, since, until)
208                    .await?,
209            ));
210        }
211        for peer in &self.peers {
212            if !allowed_agent_ids.contains(&peer.alias.as_str()) {
213                continue;
214            }
215            match peer
216                .memory
217                .recall(query, limit, session_id, since, until)
218                .await
219            {
220                Ok(rows) => merged.extend(Self::attribute(&peer.alias, rows)),
221                Err(error) => ::zeroclaw_log::record!(
222                    WARN,
223                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
224                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
225                        .with_attrs(
226                            ::serde_json::json!({"peer": peer.alias, "error": format!("{}", error)})
227                        ),
228                    "AgentScopedMarkdownMemory peer recall failed; continuing with other peers"
229                ),
230            }
231        }
232        merged.truncate(limit);
233        Ok(merged)
234    }
235
236    async fn get(&self, key: &str) -> Result<Option<MemoryEntry>> {
237        let entry = self.own.get(key).await?;
238        Ok(entry.map(|mut e| {
239            e.agent_alias = Some(self.own_alias.clone());
240            e.agent_id = Some(self.own_alias.clone());
241            e
242        }))
243    }
244
245    async fn list(
246        &self,
247        category: Option<&MemoryCategory>,
248        session_id: Option<&str>,
249    ) -> Result<Vec<MemoryEntry>> {
250        let entries = self.own.list(category, session_id).await?;
251        Ok(Self::stamp_attribution(&self.own_alias, entries))
252    }
253
254    async fn forget(&self, key: &str) -> Result<bool> {
255        self.own.forget(key).await
256    }
257
258    async fn forget_for_agent(&self, key: &str, agent_id: &str) -> Result<bool> {
259        self.own.forget_for_agent(key, agent_id).await
260    }
261
262    async fn count(&self) -> Result<usize> {
263        self.own.count().await
264    }
265}
266
267impl ::zeroclaw_api::attribution::Attributable for AgentScopedMarkdownMemory {
268    fn role(&self) -> ::zeroclaw_api::attribution::Role {
269        ::zeroclaw_api::attribution::Role::Memory(
270            ::zeroclaw_api::attribution::MemoryKind::AgentScopedMarkdown,
271        )
272    }
273    fn alias(&self) -> &str {
274        &self.own_alias
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use tempfile::TempDir;
282
283    fn make_md(name: &str) -> (TempDir, MarkdownMemory) {
284        let tmp = TempDir::new().unwrap();
285        let dir = tmp.path().join(name);
286        std::fs::create_dir_all(&dir).unwrap();
287        let mem = MarkdownMemory::new("markdown", &dir);
288        (tmp, mem)
289    }
290
291    #[tokio::test]
292    async fn store_writes_only_to_own_backend() {
293        let (_tmp_a, own) = make_md("alpha-ws");
294        let (_tmp_b, peer_mem) = make_md("beta-ws");
295        let scoped = AgentScopedMarkdownMemory::new(
296            "alpha",
297            own,
298            vec![MarkdownPeer {
299                alias: "beta".into(),
300                memory: peer_mem,
301            }],
302        );
303
304        scoped
305            .store("k1", "alpha-only", MemoryCategory::Core, None)
306            .await
307            .unwrap();
308
309        // Recall returns only the alpha-attributed row; beta's
310        // workspace was never written.
311        let hits = scoped
312            .recall("alpha-only", 10, None, None, None)
313            .await
314            .unwrap();
315        assert_eq!(hits.len(), 1);
316        assert!(
317            hits[0].key.starts_with("[alpha] "),
318            "own-backend rows must surface with [alpha] attribution"
319        );
320    }
321
322    #[tokio::test]
323    async fn recall_unions_own_and_peer_rows_with_attribution() {
324        let (_tmp_a, own) = make_md("alpha-ws");
325        let (_tmp_b, peer_mem) = make_md("beta-ws");
326
327        // Seed the peer's MarkdownMemory directly so the recall has
328        // something on the peer side to merge.
329        peer_mem
330            .store("shared", "beta-content", MemoryCategory::Core, None)
331            .await
332            .unwrap();
333
334        let scoped = AgentScopedMarkdownMemory::new(
335            "alpha",
336            own,
337            vec![MarkdownPeer {
338                alias: "beta".into(),
339                memory: peer_mem,
340            }],
341        );
342
343        // Now seed the own side too.
344        scoped
345            .store("shared", "alpha-content", MemoryCategory::Core, None)
346            .await
347            .unwrap();
348
349        let hits = scoped.recall("shared", 10, None, None, None).await.unwrap();
350        let attribution_set: std::collections::HashSet<&str> =
351            hits.iter().map(|h| h.key.as_str()).collect();
352        assert!(
353            attribution_set.iter().any(|k| k.starts_with("[alpha] ")),
354            "merged recall must include alpha-attributed rows"
355        );
356        assert!(
357            attribution_set.iter().any(|k| k.starts_with("[beta] ")),
358            "merged recall must include beta-attributed rows"
359        );
360    }
361
362    #[tokio::test]
363    async fn recall_for_agents_filters_to_alias_intersection() {
364        let (_tmp_a, own) = make_md("alpha-ws");
365        let (_tmp_b, peer_mem) = make_md("beta-ws");
366
367        peer_mem
368            .store("peer-only", "beta-content", MemoryCategory::Core, None)
369            .await
370            .unwrap();
371
372        let scoped = AgentScopedMarkdownMemory::new(
373            "alpha",
374            own,
375            vec![MarkdownPeer {
376                alias: "beta".into(),
377                memory: peer_mem,
378            }],
379        );
380
381        // Caller asks ONLY for alpha — beta rows must not surface.
382        let hits = scoped
383            .recall_for_agents(&["alpha"], "peer-only", 10, None, None, None)
384            .await
385            .unwrap();
386        assert!(
387            !hits.iter().any(|h| h.key.starts_with("[beta] ")),
388            "caller-restricted recall must drop unlisted peer rows"
389        );
390
391        // Caller asks ONLY for beta — alpha (own) rows must not surface.
392        let hits = scoped
393            .recall_for_agents(&["beta"], "peer-only", 10, None, None, None)
394            .await
395            .unwrap();
396        assert!(
397            !hits.iter().any(|h| h.key.starts_with("[alpha] ")),
398            "caller-restricted recall must drop own rows when own is not on the caller list"
399        );
400        assert!(
401            hits.iter().any(|h| h.key.starts_with("[beta] ")),
402            "caller-restricted recall must include the requested peer's rows"
403        );
404    }
405
406    // Dashboard parity with SQL backends: every row surfaced through the
407    // Markdown wrapper must carry `agent_alias` (and `agent_id`) so the
408    // /api/memory response renders the agent chip correctly, the same as
409    // SQL JOIN-resolved rows.
410    #[tokio::test]
411    async fn list_and_get_stamp_agent_alias_for_dashboard_parity() {
412        let (_tmp, own) = make_md("alpha-ws");
413        let scoped = AgentScopedMarkdownMemory::new("alpha", own, vec![]);
414
415        scoped
416            .store("note", "preferences", MemoryCategory::Core, None)
417            .await
418            .unwrap();
419
420        let list_rows = scoped.list(None, None).await.unwrap();
421        assert!(!list_rows.is_empty(), "list must return the stored row");
422        for row in &list_rows {
423            assert_eq!(
424                row.agent_alias.as_deref(),
425                Some("alpha"),
426                "list rows must be stamped with the bound agent's alias"
427            );
428            assert_eq!(
429                row.agent_id.as_deref(),
430                Some("alpha"),
431                "agent_id mirrors agent_alias on Markdown (no UUID indirection)"
432            );
433        }
434
435        let key = &list_rows[0].key;
436        let got = scoped
437            .get(key)
438            .await
439            .unwrap()
440            .expect("get must find the row");
441        assert_eq!(got.agent_alias.as_deref(), Some("alpha"));
442        assert_eq!(got.agent_id.as_deref(), Some("alpha"));
443    }
444
445    // The recall path's `[alpha] ` key-prefix attribution must coexist
446    // with the new field-level attribution. The fields are what the
447    // dashboard reads; the prefix is what prompts / logs read.
448    #[tokio::test]
449    async fn recall_attribution_carries_through_both_key_prefix_and_alias_field() {
450        let (_tmp_a, own) = make_md("alpha-ws");
451        let (_tmp_b, peer_mem) = make_md("beta-ws");
452        peer_mem
453            .store("peer-note", "from beta", MemoryCategory::Core, None)
454            .await
455            .unwrap();
456        let scoped = AgentScopedMarkdownMemory::new(
457            "alpha",
458            own,
459            vec![MarkdownPeer {
460                alias: "beta".into(),
461                memory: peer_mem,
462            }],
463        );
464        scoped
465            .store("own-note", "from alpha", MemoryCategory::Core, None)
466            .await
467            .unwrap();
468
469        let hits = scoped.recall("from", 10, None, None, None).await.unwrap();
470        let alpha_hit = hits.iter().find(|h| h.key.starts_with("[alpha] ")).unwrap();
471        let beta_hit = hits.iter().find(|h| h.key.starts_with("[beta] ")).unwrap();
472        assert_eq!(alpha_hit.agent_alias.as_deref(), Some("alpha"));
473        assert_eq!(beta_hit.agent_alias.as_deref(), Some("beta"));
474    }
475}