1use 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
35pub 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 pub fn new(memory: Arc<dyn Memory>) -> Self {
49 Self { memory, top_k: 5 }
50 }
51
52 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 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 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
98pub 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 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 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
177pub 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 pending: Mutex<Vec<tokio::task::JoinHandle<()>>>,
211}
212
213impl MemorySynthesizer {
214 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 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 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
267fn extract_facts(raw: &str) -> Option<Vec<SynthFact>> {
270 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 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 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 #[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 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 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}