Skip to main content

harness_loop/experience/
store.rs

1//! `ExperienceStore` — persist and recall [`Episode`]s over any [`Memory`].
2//!
3//! The store is backend-agnostic: it writes each episode as a `MemoryEntry`
4//! whose content is the episode's natural-language render (so keyword *and*
5//! semantic backends can index it) and whose tags include an `experience`
6//! marker plus one `tool:<name>` tag per tool used (for tool-based filtering).
7//! Pair it with a **semantic** `Memory` (e.g. a CortexDB- or embeddings-backed
8//! one) to get semantic recall; with a keyword backend recall is lexical.
9
10use crate::experience::episode::Episode;
11use harness_core::{Memory, MemoryEntry, MemoryError};
12use std::sync::Arc;
13
14/// Default tag marking an entry as an experience episode.
15pub const EXPERIENCE_TAG: &str = "experience";
16
17pub struct ExperienceStore {
18    memory: Arc<dyn Memory>,
19    source: String,
20    tag: String,
21}
22
23impl ExperienceStore {
24    pub fn new(memory: Arc<dyn Memory>) -> Self {
25        Self {
26            memory,
27            source: "experience".into(),
28            tag: EXPERIENCE_TAG.into(),
29        }
30    }
31
32    /// Tag entries with a source (e.g. the app / user id) for multi-tenant stores.
33    pub fn with_source(mut self, source: impl Into<String>) -> Self {
34        self.source = source.into();
35        self
36    }
37
38    /// Override the marker tag (default `experience`).
39    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
40        self.tag = tag.into();
41        self
42    }
43
44    fn tags_for(&self, ep: &Episode) -> Vec<String> {
45        let mut tags = vec![self.tag.clone()];
46        tags.extend(ep.tools.iter().map(|t| format!("tool:{t}")));
47        // `skill:<name>` mirrors `tool:<name>`. The reviser's question is
48        // "which runs followed skill X, and how did they go?" — a lexical hit on
49        // the rendered `Skills used:` line answers it fuzzily, but a metadata-
50        // aware backend can answer it exactly, and only if the tag is there.
51        tags.extend(ep.skills.iter().map(|s| format!("skill:{s}")));
52        tags.extend(ep.tags.iter().cloned());
53        tags
54    }
55
56    /// Persist one episode.
57    pub async fn record(&self, ep: &Episode) -> Result<(), MemoryError> {
58        let entry = MemoryEntry::new(ep.render())
59            .with_source(self.source.clone())
60            .with_tags(self.tags_for(ep));
61        self.memory.write(entry).await
62    }
63
64    /// Recall up to `k` episodes most relevant to `situation`. Only entries
65    /// carrying the marker tag are returned, reconstructed via [`Episode::parse`].
66    pub async fn recall(&self, situation: &str, k: usize) -> Vec<Episode> {
67        if k == 0 || situation.trim().is_empty() {
68            return Vec::new();
69        }
70        // Over-fetch a little so tag-filtering doesn't starve the result.
71        let hits = match self
72            .memory
73            .recall(situation, k.saturating_mul(2).max(k))
74            .await
75        {
76            Ok(v) => v,
77            Err(e) => {
78                tracing::warn!(error = %e, "experience recall failed");
79                return Vec::new();
80            }
81        };
82        hits.into_iter()
83            .filter(|e| e.tags.iter().any(|t| t == &self.tag))
84            .filter_map(|e| Episode::parse(&e.content))
85            .take(k)
86            .collect()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use async_trait::async_trait;
94    use std::sync::Mutex;
95
96    #[derive(Default)]
97    struct VecMemory {
98        store: Mutex<Vec<MemoryEntry>>,
99    }
100    #[async_trait]
101    impl Memory for VecMemory {
102        async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
103            let g = self.store.lock().unwrap();
104            let q = query.to_lowercase();
105            let mut hits: Vec<MemoryEntry> = g
106                .iter()
107                .filter(|e| {
108                    let hay = e.content.to_lowercase();
109                    q.split_whitespace().any(|t| hay.contains(t))
110                })
111                .cloned()
112                .collect();
113            hits.truncate(k);
114            Ok(hits)
115        }
116        async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError> {
117            self.store.lock().unwrap().push(entry);
118            Ok(())
119        }
120    }
121
122    #[tokio::test]
123    async fn record_then_recall_roundtrips_and_tags_tools() {
124        let mem = Arc::new(VecMemory::default());
125        let store = ExperienceStore::new(mem.clone()).with_source("t");
126        store
127            .record(
128                &Episode::new(
129                    "deploy the website to production",
130                    "ran deploy, verified live",
131                )
132                .with_tools(["read_file", "shell"]),
133            )
134            .await
135            .unwrap();
136
137        // Stored with experience + tool tags.
138        let stored = mem.store.lock().unwrap().clone();
139        assert_eq!(stored.len(), 1);
140        assert!(stored[0].tags.contains(&"experience".to_string()));
141        assert!(stored[0].tags.contains(&"tool:shell".to_string()));
142
143        // Recall by a similar situation.
144        let hits = store.recall("deploy website production", 3).await;
145        assert_eq!(hits.len(), 1);
146        assert_eq!(hits[0].tools, vec!["read_file", "shell"]);
147    }
148
149    #[tokio::test]
150    async fn recall_ignores_non_experience_entries() {
151        let mem = Arc::new(VecMemory::default());
152        // A plain memory (no experience tag) that also matches the query.
153        mem.write(MemoryEntry::new("deploy notes").with_tags(["misc"]))
154            .await
155            .unwrap();
156        let store = ExperienceStore::new(mem.clone());
157        assert!(store.recall("deploy", 5).await.is_empty());
158    }
159}