harness_loop/experience/
store.rs1use crate::experience::episode::Episode;
11use harness_core::{Memory, MemoryEntry, MemoryError};
12use std::sync::Arc;
13
14pub 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 pub fn with_source(mut self, source: impl Into<String>) -> Self {
34 self.source = source.into();
35 self
36 }
37
38 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 tags.extend(ep.skills.iter().map(|s| format!("skill:{s}")));
52 tags.extend(ep.tags.iter().cloned());
53 tags
54 }
55
56 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 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 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 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 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 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}