Skip to main content

harness_loop/
memory_layer.rs

1//! Long-term-memory wiring for [`crate::AgentLoop`].
2//!
3//! Two pieces, designed to be installed together:
4//!
5//! - [`MemoryGuide`] — at session start, calls [`Memory::recall`] with the
6//!   current task description and pushes the top-K matches into
7//!   `ctx.guides` as plain text. The model sees a "Relevant prior context"
8//!   section in its system prompt before the very first model call.
9//!
10//! - [`MemoryWriter`] — captures every assistant text turn (via `PostModel`)
11//!   and persists the *last* one as a [`MemoryEntry`] when the run finishes
12//!   (`TaskCompleted`). This turns "this conversation produced an answer"
13//!   into "future sessions can recall the answer".
14//!
15//! Both share an `Arc<dyn Memory>` so a single backend serves recall +
16//! write. The trait is async; the writer hook uses `tokio::spawn` to commit
17//! without blocking the loop.
18//!
19//! ## Wiring
20//!
21//! ```ignore
22//! let mem: Arc<dyn Memory> = Arc::new(FileMemory::open("~/.harness/mem.jsonl")?);
23//! let loop_ = AgentLoop::new(model)
24//!     .with_guide(Arc::new(MemoryGuide::new(mem.clone()).with_top_k(5)))
25//!     .with_hook(Arc::new(MemoryWriter::new(mem)));
26//! ```
27
28use async_trait::async_trait;
29use harness_core::{
30    Block, Context, Event, Execution, Guide, GuideError, GuideId, GuideScope, Hook, HookOutcome,
31    Memory, MemoryEntry, Model, Task, Turn, TurnRole, World,
32};
33use std::sync::{Arc, Mutex, OnceLock};
34
35/// Guide that recalls relevant prior memories and injects them as a
36/// `Block::Text` into `ctx.guides` so the model sees them in the system
37/// prompt for every iteration of this run.
38pub struct MemoryGuide {
39    memory: Arc<dyn Memory>,
40    top_k: usize,
41}
42
43static MEMORY_GUIDE_ID: OnceLock<GuideId> = OnceLock::new();
44static MEMORY_GUIDE_SCOPE: OnceLock<GuideScope> = OnceLock::new();
45
46impl MemoryGuide {
47    /// Construct a guide that recalls up to 5 entries per session.
48    pub fn new(memory: Arc<dyn Memory>) -> Self {
49        Self { memory, top_k: 5 }
50    }
51
52    /// Override the number of memories recalled per session. Pick small —
53    /// every recalled line spends prompt tokens.
54    pub fn with_top_k(mut self, k: usize) -> Self {
55        self.top_k = k;
56        self
57    }
58}
59
60#[async_trait]
61impl Guide for MemoryGuide {
62    fn id(&self) -> &GuideId {
63        MEMORY_GUIDE_ID.get_or_init(|| "memory-recall".into())
64    }
65    fn kind(&self) -> Execution {
66        // The recall *itself* is computational (keyword match / vector
67        // lookup); the model later infers over the result.
68        Execution::Computational
69    }
70    fn scope(&self) -> &GuideScope {
71        MEMORY_GUIDE_SCOPE.get_or_init(|| GuideScope::Always)
72    }
73    async fn apply(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
74        if self.top_k == 0 {
75            return Ok(());
76        }
77        let hits = match self.memory.recall(&ctx.task.description, self.top_k).await {
78            Ok(v) => v,
79            Err(e) => {
80                // Best-effort: a failing memory backend must not nuke the
81                // task. Log and proceed with no recall.
82                tracing::warn!(error = %e, "memory recall failed; proceeding without it");
83                return Ok(());
84            }
85        };
86        if hits.is_empty() {
87            return Ok(());
88        }
89        let mut lines = String::from("Relevant prior context (from your long-term memory):");
90        for (i, e) in hits.iter().enumerate() {
91            lines.push_str(&format!("\n  {}. {}", i + 1, e.content.trim()));
92        }
93        ctx.guides.push(Block::Text(lines));
94        Ok(())
95    }
96}
97
98/// Hook that writes the final assistant text of every successful run back
99/// into long-term memory.
100///
101/// Behaviour:
102/// - On every `PostModel`, captures `out.text` into an internal slot.
103/// - On `TaskCompleted`, takes the most recent captured text and writes it
104///   as a `MemoryEntry` tagged with the source (defaults to `"session"`).
105/// - On `SessionEnd` without a `TaskCompleted` (i.e. `BudgetExhausted`),
106///   nothing is written — partial work shouldn't pollute long-term memory.
107pub struct MemoryWriter {
108    memory: Arc<dyn Memory>,
109    last_text: Mutex<Option<String>>,
110    source: String,
111    tags: Vec<String>,
112}
113
114impl MemoryWriter {
115    pub fn new(memory: Arc<dyn Memory>) -> Self {
116        Self {
117            memory,
118            last_text: Mutex::new(None),
119            source: "session".into(),
120            tags: Vec::new(),
121        }
122    }
123
124    /// Tag every persisted memory with the given source name (e.g.
125    /// `"investor-bot"`, `"personal-assistant"`). Useful for multi-app
126    /// memory stores.
127    pub fn with_source(mut self, source: impl Into<String>) -> Self {
128        self.source = source.into();
129        self
130    }
131
132    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
133        self.tags = tags.into_iter().map(Into::into).collect();
134        self
135    }
136}
137
138impl Hook for MemoryWriter {
139    fn name(&self) -> &str {
140        "memory-writer"
141    }
142    fn matches(&self, ev: &Event<'_>) -> bool {
143        matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
144    }
145    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
146        match ev {
147            Event::PostModel { out } => {
148                if let Some(text) = &out.text
149                    && !text.trim().is_empty()
150                    && let Ok(mut slot) = self.last_text.lock()
151                {
152                    *slot = Some(text.clone());
153                }
154            }
155            Event::TaskCompleted => {
156                let Some(text) = self.last_text.lock().ok().and_then(|mut g| g.take()) else {
157                    return HookOutcome::Allow;
158                };
159                let entry = MemoryEntry::new(text)
160                    .with_source(self.source.clone())
161                    .with_tags(self.tags.clone());
162                let mem = self.memory.clone();
163                // Fire-and-forget: we're inside an async loop, so spawning
164                // is safe and avoids blocking the next iteration.
165                tokio::spawn(async move {
166                    if let Err(e) = mem.write(entry).await {
167                        tracing::warn!(error = %e, "memory write failed");
168                    }
169                });
170            }
171            _ => {}
172        }
173        HookOutcome::Allow
174    }
175}
176
177/// Smarter alternative to [`MemoryWriter`] — distil the session's assistant
178/// turns into 1..=`max_facts` atomic durable facts using a cheap
179/// "synthesizer" model, instead of persisting the verbatim final answer.
180///
181/// Wire either `MemoryWriter` **or** `MemorySynthesizer`, not both —
182/// `MemorySynthesizer` is a superset of the writer's behaviour with the
183/// extra distillation step.
184///
185/// Behaviour:
186/// - On `PostModel`, appends `out.text` (when present, non-empty) to an
187///   internal buffer.
188/// - On `TaskCompleted`, `tokio::spawn`s a synthesis task: calls
189///   `synth_model.complete()` with a fixed prompt that asks for a JSON
190///   array of `{content, tags}` objects, parses the response, and writes
191///   each one via `Memory::write`.
192/// - Model errors / parse failures fall back to saving the raw response
193///   as a single entry tagged `"synth-raw"` so the session's information
194///   isn't lost entirely.
195/// - On `BudgetExhausted` (no `TaskCompleted` fires), nothing is written.
196///
197/// The synth model should be cheap (`deepseek-v4-flash`, `gpt-5-nano`, etc.).
198/// Constructed independently from the main model so you can use a small
199/// summariser even when the reasoning model is large.
200pub struct MemorySynthesizer {
201    memory: Arc<dyn Memory>,
202    synth_model: Arc<dyn Model>,
203    transcripts: Mutex<Vec<String>>,
204    source: String,
205    base_tags: Vec<String>,
206    max_facts: usize,
207    // JoinHandles of spawned synthesis tasks. The agent loop's owner can
208    // `await flush_pending()` before exiting to guarantee that synth
209    // completes before the process tears down its tokio runtime.
210    pending: Mutex<Vec<tokio::task::JoinHandle<()>>>,
211}
212
213impl MemorySynthesizer {
214    /// Construct a synthesizer that uses `synth_model` to distil the
215    /// session into at most 3 facts.
216    pub fn new(memory: Arc<dyn Memory>, synth_model: Arc<dyn Model>) -> Self {
217        Self {
218            memory,
219            synth_model,
220            transcripts: Mutex::new(Vec::new()),
221            source: "session".into(),
222            base_tags: Vec::new(),
223            max_facts: 3,
224            pending: Mutex::new(Vec::new()),
225        }
226    }
227
228    /// Await all background synthesis tasks that have been kicked off so
229    /// far. Call this before your process exits if you want to guarantee
230    /// the last session's memory is on disk — otherwise the tokio runtime
231    /// may be dropped while the spawn is mid-flight.
232    pub async fn flush_pending(&self) {
233        let handles: Vec<tokio::task::JoinHandle<()>> = match self.pending.lock() {
234            Ok(mut g) => std::mem::take(&mut *g),
235            Err(_) => return,
236        };
237        for h in handles {
238            let _ = h.await;
239        }
240    }
241
242    pub fn with_source(mut self, source: impl Into<String>) -> Self {
243        self.source = source.into();
244        self
245    }
246
247    pub fn with_base_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
248        self.base_tags = tags.into_iter().map(Into::into).collect();
249        self
250    }
251
252    /// Cap how many facts the synthesizer is allowed to emit. Default 3.
253    pub fn with_max_facts(mut self, n: usize) -> Self {
254        self.max_facts = n.max(1);
255        self
256    }
257}
258
259#[derive(serde::Deserialize)]
260struct SynthFact {
261    #[serde(default)]
262    content: String,
263    #[serde(default)]
264    tags: Vec<String>,
265}
266
267/// Best-effort JSON-array extractor: tolerates markdown code fences and
268/// leading/trailing prose around the JSON body.
269fn extract_facts(raw: &str) -> Option<Vec<SynthFact>> {
270    // Strip ```json ... ``` or ``` ... ``` fences if present.
271    let stripped = raw.trim();
272    let body = if let Some(rest) = stripped.strip_prefix("```json") {
273        rest.trim_start_matches('\n')
274            .rsplit_once("```")
275            .map(|(b, _)| b)
276            .unwrap_or(rest)
277    } else if let Some(rest) = stripped.strip_prefix("```") {
278        rest.trim_start_matches('\n')
279            .rsplit_once("```")
280            .map(|(b, _)| b)
281            .unwrap_or(rest)
282    } else {
283        stripped
284    };
285    // Find first '[' and last ']' — JSON array.
286    let start = body.find('[')?;
287    let end = body.rfind(']')?;
288    if end <= start {
289        return None;
290    }
291    serde_json::from_str::<Vec<SynthFact>>(&body[start..=end]).ok()
292}
293
294impl Hook for MemorySynthesizer {
295    fn name(&self) -> &str {
296        "memory-synthesizer"
297    }
298    fn matches(&self, ev: &Event<'_>) -> bool {
299        matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
300    }
301    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
302        match ev {
303            Event::PostModel { out } => {
304                if let Some(text) = &out.text
305                    && !text.trim().is_empty()
306                    && let Ok(mut buf) = self.transcripts.lock()
307                {
308                    buf.push(text.clone());
309                }
310            }
311            Event::TaskCompleted => {
312                let transcript = match self.transcripts.lock() {
313                    Ok(mut g) => std::mem::take(&mut *g).join("\n\n---\n\n"),
314                    Err(_) => return HookOutcome::Allow,
315                };
316                if transcript.trim().is_empty() {
317                    return HookOutcome::Allow;
318                }
319                let mem = self.memory.clone();
320                let model = self.synth_model.clone();
321                let source = self.source.clone();
322                let base_tags = self.base_tags.clone();
323                let max_facts = self.max_facts;
324                let handle = tokio::spawn(async move {
325                    distil_and_write(mem, model, source, base_tags, max_facts, transcript).await;
326                });
327                if let Ok(mut g) = self.pending.lock() {
328                    g.push(handle);
329                }
330            }
331            _ => {}
332        }
333        HookOutcome::Allow
334    }
335}
336
337async fn distil_and_write(
338    memory: Arc<dyn Memory>,
339    model: Arc<dyn Model>,
340    source: String,
341    base_tags: Vec<String>,
342    max_facts: usize,
343    transcript: String,
344) {
345    let prompt = format!(
346        "Below is the assistant's turns from a completed agent session. \
347         Extract 1 to {max_facts} DURABLE FACTS worth remembering for future sessions \
348         (user preferences, decisions made, key findings, learned constraints — NOT \
349         transient details like timestamps or one-off answers). \
350         \n\nReturn ONLY a JSON array (no prose, no markdown fences) where each item is \
351         {{\"content\": \"<one durable fact, 1-2 sentences>\", \"tags\": [\"<keyword>\", ...]}}. \
352         Use 2-5 lowercase keyword tags per fact for retrieval. \
353         If the session produced nothing durable, return [].\
354         \n\n--- SESSION TRANSCRIPT ---\n{transcript}\n--- END TRANSCRIPT ---"
355    );
356
357    let mut ctx = Context::new(Task {
358        description: prompt.clone(),
359        source: None,
360        deadline: None,
361    });
362    ctx.history.push(Turn {
363        role: TurnRole::User,
364        blocks: vec![Block::Text(prompt)],
365    });
366
367    let out = match model.complete(&ctx).await {
368        Ok(o) => o,
369        Err(e) => {
370            tracing::warn!(error = %e, "memory synth model call failed; nothing persisted");
371            return;
372        }
373    };
374    let raw = out.text.unwrap_or_default();
375
376    let mut wrote_any = false;
377    if let Some(facts) = extract_facts(&raw) {
378        for f in facts.into_iter().take(max_facts) {
379            let content = f.content.trim().to_string();
380            if content.is_empty() {
381                continue;
382            }
383            let mut tags = base_tags.clone();
384            tags.extend(f.tags);
385            let entry = MemoryEntry::new(content)
386                .with_source(source.clone())
387                .with_tags(tags);
388            if let Err(e) = memory.write(entry).await {
389                tracing::warn!(error = %e, "memory synth write failed");
390            } else {
391                wrote_any = true;
392            }
393        }
394    }
395
396    if !wrote_any && !raw.trim().is_empty() {
397        // Fallback: model returned something but we couldn't parse it.
398        // Persist verbatim with a "synth-raw" tag so the operator can
399        // grep it later, rather than silently dropping the session.
400        let mut tags = base_tags;
401        tags.push("synth-raw".into());
402        let entry = MemoryEntry::new(raw.trim().to_string())
403            .with_source(source)
404            .with_tags(tags);
405        if let Err(e) = memory.write(entry).await {
406            tracing::warn!(error = %e, "memory synth-raw write failed");
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use harness_core::{ModelOutput, StopReason, Usage};
415    use std::sync::atomic::{AtomicU64, Ordering};
416
417    /// Test-only in-memory backend so we don't touch the filesystem.
418    #[derive(Default)]
419    struct VecMemory {
420        store: Mutex<Vec<MemoryEntry>>,
421    }
422    #[async_trait]
423    impl Memory for VecMemory {
424        async fn recall(
425            &self,
426            query: &str,
427            k: usize,
428        ) -> Result<Vec<MemoryEntry>, harness_core::MemoryError> {
429            let g = self.store.lock().unwrap();
430            let q = query.to_lowercase();
431            let mut hits: Vec<MemoryEntry> = g
432                .iter()
433                .filter(|e| {
434                    let hay = e.content.to_lowercase();
435                    q.split_whitespace().any(|t| hay.contains(t))
436                })
437                .cloned()
438                .collect();
439            hits.truncate(k);
440            Ok(hits)
441        }
442        async fn write(&self, entry: MemoryEntry) -> Result<(), harness_core::MemoryError> {
443            self.store.lock().unwrap().push(entry);
444            Ok(())
445        }
446    }
447
448    static SEQ: AtomicU64 = AtomicU64::new(0);
449
450    #[tokio::test]
451    async fn writer_persists_last_text_on_task_completed() {
452        let mem = Arc::new(VecMemory::default());
453        let w = MemoryWriter::new(mem.clone()).with_source("test-app");
454        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
455            "harness-mw-{}-{}",
456            std::process::id(),
457            SEQ.fetch_add(1, Ordering::SeqCst)
458        )));
459
460        let out = ModelOutput {
461            text: Some("final answer X".into()),
462            tool_calls: vec![],
463            usage: Usage::default(),
464            stop_reason: StopReason::EndTurn,
465            reasoning: None,
466        };
467        let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
468        let _ = w.fire(&Event::TaskCompleted, &mut world);
469
470        // The hook spawns; give the runtime a tick to drain.
471        tokio::task::yield_now().await;
472        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
473
474        let stored = mem.store.lock().unwrap().clone();
475        assert_eq!(stored.len(), 1);
476        assert_eq!(stored[0].content, "final answer X");
477        assert_eq!(stored[0].source.as_deref(), Some("test-app"));
478    }
479
480    #[tokio::test]
481    async fn writer_skips_when_no_task_completed_fires() {
482        let mem = Arc::new(VecMemory::default());
483        let w = MemoryWriter::new(mem.clone());
484        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
485            "harness-mw-{}-{}",
486            std::process::id(),
487            SEQ.fetch_add(1, Ordering::SeqCst)
488        )));
489
490        let out = ModelOutput {
491            text: Some("partial".into()),
492            tool_calls: vec![],
493            usage: Usage::default(),
494            stop_reason: StopReason::ToolUse,
495            reasoning: None,
496        };
497        let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
498        // No TaskCompleted ⇒ nothing should be written.
499        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
500        assert!(mem.store.lock().unwrap().is_empty());
501    }
502
503    #[tokio::test]
504    async fn synthesizer_parses_clean_json_and_writes_atomic_facts() {
505        use harness_models::{MockModel, MockResponse};
506
507        let mem = Arc::new(VecMemory::default());
508        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
509            r#"[
510              {"content": "user prefers dark roast coffee, no sugar", "tags": ["coffee", "preferences"]},
511              {"content": "user lives in Beijing (Asia/Shanghai tz)", "tags": ["location", "timezone"]}
512            ]"#,
513        )));
514        let s = MemorySynthesizer::new(mem.clone(), synth).with_source("test");
515        let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
516            "harness-ms-{}-{}",
517            std::process::id(),
518            SEQ.fetch_add(1, Ordering::SeqCst)
519        )));
520
521        let out_a = ModelOutput {
522            text: Some("I'll remember your coffee preference.".into()),
523            tool_calls: vec![],
524            usage: Usage::default(),
525            stop_reason: StopReason::ToolUse,
526            reasoning: None,
527        };
528        let out_b = ModelOutput {
529            text: Some("Setting Beijing as your timezone.".into()),
530            tool_calls: vec![],
531            usage: Usage::default(),
532            stop_reason: StopReason::EndTurn,
533            reasoning: None,
534        };
535        let _ = s.fire(&Event::PostModel { out: &out_a }, &mut world);
536        let _ = s.fire(&Event::PostModel { out: &out_b }, &mut world);
537        let _ = s.fire(&Event::TaskCompleted, &mut world);
538
539        for _ in 0..50 {
540            if mem.store.lock().unwrap().len() >= 2 {
541                break;
542            }
543            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
544        }
545        let stored = mem.store.lock().unwrap().clone();
546        assert_eq!(stored.len(), 2, "expected 2 atomic facts, got {stored:#?}");
547        assert!(stored.iter().any(|e| e.content.contains("dark roast")));
548        assert!(stored.iter().any(|e| e.content.contains("Beijing")));
549        let coffee = stored
550            .iter()
551            .find(|e| e.content.contains("dark roast"))
552            .unwrap();
553        assert!(coffee.tags.contains(&"coffee".to_string()));
554        assert_eq!(coffee.source.as_deref(), Some("test"));
555    }
556
557    #[tokio::test]
558    async fn synthesizer_strips_markdown_fences_around_json() {
559        use harness_models::{MockModel, MockResponse};
560
561        let mem = Arc::new(VecMemory::default());
562        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
563            "Here are the facts:\n```json\n[{\"content\":\"fact one\",\"tags\":[\"x\"]}]\n```\n",
564        )));
565        let s = MemorySynthesizer::new(mem.clone(), synth);
566        let mut world = harness_context::default_world(std::env::temp_dir());
567
568        let out = ModelOutput {
569            text: Some("some chat".into()),
570            tool_calls: vec![],
571            usage: Usage::default(),
572            stop_reason: StopReason::EndTurn,
573            reasoning: None,
574        };
575        let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
576        let _ = s.fire(&Event::TaskCompleted, &mut world);
577
578        for _ in 0..50 {
579            if !mem.store.lock().unwrap().is_empty() {
580                break;
581            }
582            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
583        }
584        let stored = mem.store.lock().unwrap().clone();
585        assert_eq!(stored.len(), 1);
586        assert_eq!(stored[0].content, "fact one");
587    }
588
589    #[tokio::test]
590    async fn synthesizer_falls_back_to_synth_raw_when_json_unparseable() {
591        use harness_models::{MockModel, MockResponse};
592
593        let mem = Arc::new(VecMemory::default());
594        let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
595            "The user said they like coffee. I think that's important.",
596        )));
597        let s = MemorySynthesizer::new(mem.clone(), synth);
598        let mut world = harness_context::default_world(std::env::temp_dir());
599
600        let out = ModelOutput {
601            text: Some("session chat".into()),
602            tool_calls: vec![],
603            usage: Usage::default(),
604            stop_reason: StopReason::EndTurn,
605            reasoning: None,
606        };
607        let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
608        let _ = s.fire(&Event::TaskCompleted, &mut world);
609
610        for _ in 0..50 {
611            if !mem.store.lock().unwrap().is_empty() {
612                break;
613            }
614            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
615        }
616        let stored = mem.store.lock().unwrap().clone();
617        assert_eq!(stored.len(), 1);
618        assert!(stored[0].tags.contains(&"synth-raw".to_string()));
619        assert!(stored[0].content.contains("coffee"));
620    }
621}