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
35const MEMORY_RECALL_MARKER: &str = "[memory-recall]\n";
40
41pub struct MemoryGuide {
68 memory: Arc<dyn Memory>,
69 top_k: usize,
70 min_score: f32,
71 required_tags: Vec<String>,
72 excluded_tags: Vec<String>,
73}
74
75static MEMORY_GUIDE_ID: OnceLock<GuideId> = OnceLock::new();
76static MEMORY_GUIDE_SCOPE: OnceLock<GuideScope> = OnceLock::new();
77
78impl MemoryGuide {
79 pub fn new(memory: Arc<dyn Memory>) -> Self {
81 Self {
82 memory,
83 top_k: 5,
84 min_score: 0.0,
85 required_tags: Vec::new(),
86 excluded_tags: Vec::new(),
87 }
88 }
89
90 pub fn with_top_k(mut self, k: usize) -> Self {
93 self.top_k = k;
94 self
95 }
96
97 pub fn with_min_score(mut self, s: f32) -> Self {
105 self.min_score = s.clamp(0.0, 1.0);
106 self
107 }
108
109 pub fn with_required_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
111 self.required_tags = tags.into_iter().map(Into::into).collect();
112 self
113 }
114
115 pub fn with_excluded_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
117 self.excluded_tags = tags.into_iter().map(Into::into).collect();
118 self
119 }
120
121 async fn recall_block(&self, query: &str) -> Option<String> {
124 if self.top_k == 0 || query.trim().is_empty() {
125 return None;
126 }
127 let fetch_k = if self.min_score > 0.0
129 || !self.required_tags.is_empty()
130 || !self.excluded_tags.is_empty()
131 {
132 self.top_k.saturating_mul(3).max(self.top_k)
133 } else {
134 self.top_k
135 };
136 let hits = match self.memory.recall(query, fetch_k).await {
137 Ok(v) => v,
138 Err(e) => {
139 tracing::warn!(error = %e, "memory recall failed; proceeding without it");
140 return None;
141 }
142 };
143 let q_tokens = tokenise_for_score(query);
144 let q_len = q_tokens.len().max(1) as f32;
145
146 let mut kept: Vec<&MemoryEntry> = Vec::new();
147 for e in &hits {
148 if !self.required_tags.is_empty()
150 && !self
151 .required_tags
152 .iter()
153 .all(|t| e.tags.iter().any(|x| x == t))
154 {
155 continue;
156 }
157 if !self.excluded_tags.is_empty()
158 && self
159 .excluded_tags
160 .iter()
161 .any(|t| e.tags.iter().any(|x| x == t))
162 {
163 continue;
164 }
165 if self.min_score > 0.0 {
166 let score = recompute_score(&q_tokens, e);
167 if (score / q_len) < self.min_score {
168 continue;
169 }
170 }
171 kept.push(e);
172 if kept.len() >= self.top_k {
173 break;
174 }
175 }
176 if kept.is_empty() {
177 return None;
178 }
179 let mut lines = String::from(MEMORY_RECALL_MARKER);
180 lines.push_str("Relevant prior context (from your long-term memory):");
181 for (i, e) in kept.iter().enumerate() {
182 lines.push_str(&format!("\n {}. {}", i + 1, e.content.trim()));
183 }
184 Some(lines)
185 }
186
187 fn remove_previous_recall_block(ctx: &mut Context) {
188 ctx.guides
189 .retain(|b| !matches!(b, Block::Text(t) if t.starts_with(MEMORY_RECALL_MARKER)));
190 }
191}
192
193fn last_user_text(ctx: &Context) -> Option<String> {
196 use harness_core::{Block as B, TurnRole};
197 for turn in ctx.history.iter().rev() {
198 if turn.role != TurnRole::User {
199 continue;
200 }
201 for block in turn.blocks.iter().rev() {
202 if let B::Text(t) = block
203 && !t.trim().is_empty()
204 {
205 return Some(t.clone());
206 }
207 }
208 }
209 None
210}
211
212fn tokenise_for_score(s: &str) -> std::collections::HashSet<String> {
213 s.to_lowercase()
214 .split(|c: char| !c.is_alphanumeric())
215 .filter(|t| t.len() >= 3)
216 .map(String::from)
217 .collect()
218}
219
220fn recompute_score(query_tokens: &std::collections::HashSet<String>, entry: &MemoryEntry) -> f32 {
221 let mut hay = entry.content.to_lowercase();
222 if !entry.tags.is_empty() {
223 hay.push(' ');
224 hay.push_str(&entry.tags.join(" ").to_lowercase());
225 }
226 query_tokens
227 .iter()
228 .filter(|t| hay.contains(t.as_str()))
229 .count() as f32
230}
231
232#[async_trait]
233impl Guide for MemoryGuide {
234 fn id(&self) -> &GuideId {
235 MEMORY_GUIDE_ID.get_or_init(|| "memory-recall".into())
236 }
237 fn kind(&self) -> Execution {
238 Execution::Computational
241 }
242 fn scope(&self) -> &GuideScope {
243 MEMORY_GUIDE_SCOPE.get_or_init(|| GuideScope::Always)
244 }
245 async fn apply(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
246 Self::remove_previous_recall_block(ctx);
247 if let Some(block) = self.recall_block(&ctx.task.description).await {
248 ctx.guides.push(Block::Text(block));
249 }
250 Ok(())
251 }
252 async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
253 let query = last_user_text(ctx).unwrap_or_else(|| ctx.task.description.clone());
257 Self::remove_previous_recall_block(ctx);
258 if let Some(block) = self.recall_block(&query).await {
259 ctx.guides.push(Block::Text(block));
260 }
261 Ok(())
262 }
263}
264
265pub struct MemoryWriter {
275 memory: Arc<dyn Memory>,
276 last_text: Mutex<Option<String>>,
277 source: String,
278 tags: Vec<String>,
279}
280
281impl MemoryWriter {
282 pub fn new(memory: Arc<dyn Memory>) -> Self {
283 Self {
284 memory,
285 last_text: Mutex::new(None),
286 source: "session".into(),
287 tags: Vec::new(),
288 }
289 }
290
291 pub fn with_source(mut self, source: impl Into<String>) -> Self {
295 self.source = source.into();
296 self
297 }
298
299 pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
300 self.tags = tags.into_iter().map(Into::into).collect();
301 self
302 }
303}
304
305impl Hook for MemoryWriter {
306 fn name(&self) -> &str {
307 "memory-writer"
308 }
309 fn matches(&self, ev: &Event<'_>) -> bool {
310 matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
311 }
312 fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
313 match ev {
314 Event::PostModel { out } => {
315 if let Some(text) = &out.text
316 && !text.trim().is_empty()
317 && let Ok(mut slot) = self.last_text.lock()
318 {
319 *slot = Some(text.clone());
320 }
321 }
322 Event::TaskCompleted => {
323 let Some(text) = self.last_text.lock().ok().and_then(|mut g| g.take()) else {
324 return HookOutcome::Allow;
325 };
326 let entry = MemoryEntry::new(text)
327 .with_source(self.source.clone())
328 .with_tags(self.tags.clone());
329 let mem = self.memory.clone();
330 tokio::spawn(async move {
333 if let Err(e) = mem.write(entry).await {
334 tracing::warn!(error = %e, "memory write failed");
335 }
336 });
337 }
338 _ => {}
339 }
340 HookOutcome::Allow
341 }
342}
343
344pub struct DecisionWriter {
360 memory: Arc<dyn Memory>,
361 markers: Vec<String>,
362 lines: Mutex<Vec<String>>,
363 source: String,
364 tags: Vec<String>,
365}
366
367impl DecisionWriter {
368 pub fn new(memory: Arc<dyn Memory>) -> Self {
369 Self {
370 memory,
371 markers: vec!["DECISION:".into()],
372 lines: Mutex::new(Vec::new()),
373 source: "session".into(),
374 tags: vec!["decision".into()],
375 }
376 }
377
378 pub fn with_markers(mut self, markers: impl IntoIterator<Item = impl Into<String>>) -> Self {
380 self.markers = markers.into_iter().map(Into::into).collect();
381 self
382 }
383
384 pub fn with_source(mut self, source: impl Into<String>) -> Self {
386 self.source = source.into();
387 self
388 }
389
390 pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
391 self.tags = tags.into_iter().map(Into::into).collect();
392 self
393 }
394}
395
396impl Hook for DecisionWriter {
397 fn name(&self) -> &str {
398 "decision-writer"
399 }
400 fn matches(&self, ev: &Event<'_>) -> bool {
401 matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
402 }
403 fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
404 match ev {
405 Event::PostModel { out } => {
406 let Some(text) = &out.text else {
407 return HookOutcome::Allow;
408 };
409 let Ok(mut lines) = self.lines.lock() else {
410 return HookOutcome::Allow;
411 };
412 for line in text.lines() {
413 let line = line.trim();
414 if self.markers.iter().any(|m| line.starts_with(m.as_str()))
415 && !lines.iter().any(|l| l == line)
416 {
417 lines.push(line.to_string());
418 }
419 }
420 }
421 Event::TaskCompleted => {
422 let captured: Vec<String> = match self.lines.lock() {
423 Ok(mut g) => std::mem::take(&mut *g),
424 Err(_) => return HookOutcome::Allow,
425 };
426 if captured.is_empty() {
427 return HookOutcome::Allow;
428 }
429 let entry = MemoryEntry::new(captured.join("\n"))
430 .with_source(self.source.clone())
431 .with_tags(self.tags.clone());
432 let mem = self.memory.clone();
433 tokio::spawn(async move {
434 if let Err(e) = mem.write(entry).await {
435 tracing::warn!(error = %e, "decision write failed");
436 }
437 });
438 }
439 _ => {}
440 }
441 HookOutcome::Allow
442 }
443}
444
445pub struct MemorySynthesizer {
469 memory: Arc<dyn Memory>,
470 synth_model: Arc<dyn Model>,
471 transcripts: Mutex<Vec<String>>,
472 source: String,
473 base_tags: Vec<String>,
474 max_facts: usize,
475 extra_instructions: Option<String>,
479 pending: Mutex<Vec<tokio::task::JoinHandle<()>>>,
483}
484
485impl MemorySynthesizer {
486 pub fn new(memory: Arc<dyn Memory>, synth_model: Arc<dyn Model>) -> Self {
489 Self {
490 memory,
491 synth_model,
492 transcripts: Mutex::new(Vec::new()),
493 source: "session".into(),
494 base_tags: Vec::new(),
495 max_facts: 3,
496 extra_instructions: None,
497 pending: Mutex::new(Vec::new()),
498 }
499 }
500
501 pub fn with_extra_instructions(mut self, instructions: impl Into<String>) -> Self {
518 self.extra_instructions = Some(instructions.into());
519 self
520 }
521
522 pub async fn flush_pending(&self) {
527 let handles: Vec<tokio::task::JoinHandle<()>> = match self.pending.lock() {
528 Ok(mut g) => std::mem::take(&mut *g),
529 Err(_) => return,
530 };
531 for h in handles {
532 let _ = h.await;
533 }
534 }
535
536 pub fn with_source(mut self, source: impl Into<String>) -> Self {
537 self.source = source.into();
538 self
539 }
540
541 pub fn with_base_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
542 self.base_tags = tags.into_iter().map(Into::into).collect();
543 self
544 }
545
546 pub fn with_max_facts(mut self, n: usize) -> Self {
548 self.max_facts = n.max(1);
549 self
550 }
551}
552
553#[derive(serde::Deserialize)]
554struct SynthFact {
555 #[serde(default)]
556 content: String,
557 #[serde(default)]
558 tags: Vec<String>,
559 #[serde(default)]
563 ttl_days: Option<u32>,
564}
565
566fn extract_facts(raw: &str) -> Option<Vec<SynthFact>> {
569 let stripped = raw.trim();
571 let body = if let Some(rest) = stripped.strip_prefix("```json") {
572 rest.trim_start_matches('\n')
573 .rsplit_once("```")
574 .map(|(b, _)| b)
575 .unwrap_or(rest)
576 } else if let Some(rest) = stripped.strip_prefix("```") {
577 rest.trim_start_matches('\n')
578 .rsplit_once("```")
579 .map(|(b, _)| b)
580 .unwrap_or(rest)
581 } else {
582 stripped
583 };
584 let start = body.find('[')?;
586 let end = body.rfind(']')?;
587 if end <= start {
588 return None;
589 }
590 serde_json::from_str::<Vec<SynthFact>>(&body[start..=end]).ok()
591}
592
593impl Hook for MemorySynthesizer {
594 fn name(&self) -> &str {
595 "memory-synthesizer"
596 }
597 fn matches(&self, ev: &Event<'_>) -> bool {
598 matches!(ev, Event::PostModel { .. } | Event::TaskCompleted)
599 }
600 fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
601 match ev {
602 Event::PostModel { out } => {
603 if let Some(text) = &out.text
604 && !text.trim().is_empty()
605 && let Ok(mut buf) = self.transcripts.lock()
606 {
607 buf.push(text.clone());
608 }
609 }
610 Event::TaskCompleted => {
611 let transcript = match self.transcripts.lock() {
612 Ok(mut g) => std::mem::take(&mut *g).join("\n\n---\n\n"),
613 Err(_) => return HookOutcome::Allow,
614 };
615 if transcript.trim().is_empty() {
616 return HookOutcome::Allow;
617 }
618 let mem = self.memory.clone();
619 let model = self.synth_model.clone();
620 let source = self.source.clone();
621 let base_tags = self.base_tags.clone();
622 let max_facts = self.max_facts;
623 let extra = self.extra_instructions.clone();
624 let handle = tokio::spawn(async move {
625 distil_and_write(mem, model, source, base_tags, max_facts, extra, transcript)
626 .await;
627 });
628 if let Ok(mut g) = self.pending.lock() {
629 g.push(handle);
630 }
631 }
632 _ => {}
633 }
634 HookOutcome::Allow
635 }
636}
637
638async fn distil_and_write(
639 memory: Arc<dyn Memory>,
640 model: Arc<dyn Model>,
641 source: String,
642 base_tags: Vec<String>,
643 max_facts: usize,
644 extra_instructions: Option<String>,
645 transcript: String,
646) {
647 let extra_block = match extra_instructions {
648 Some(s) if !s.trim().is_empty() => format!("\n\n[domain context]\n{s}\n"),
649 _ => String::new(),
650 };
651 let prompt = format!(
652 "Below is the assistant's turns from a completed agent session. \
653 Extract 1 to {max_facts} DURABLE FACTS worth remembering for future sessions \
654 (user preferences, decisions made, key findings, learned constraints — NOT \
655 transient details like timestamps or one-off answers).{extra_block} \
656 \n\nReturn ONLY a JSON array (no prose, no markdown fences) where each item is \
657 {{\"content\": \"<one durable fact, 1-2 sentences>\", \"tags\": [\"<keyword>\", ...], \
658 \"ttl_days\": <integer or null>}}. \
659 `ttl_days` controls how long the fact stays in memory: \
660 `null` = permanent (use for stable preferences, identity, long-term decisions); \
661 `7` = one week (current task / sprint scope); \
662 `30`-`180` = project-scope context; \
663 `1` = ephemeral (rarely useful — prefer omitting facts that are this fleeting). \
664 Use 2-5 lowercase keyword tags per fact for retrieval. \
665 If the session produced nothing durable, return [].\
666 \n\n--- SESSION TRANSCRIPT ---\n{transcript}\n--- END TRANSCRIPT ---"
667 );
668
669 let mut ctx = Context::new(Task {
670 description: prompt.clone(),
671 source: None,
672 deadline: None,
673 });
674 ctx.history.push(Turn {
675 role: TurnRole::User,
676 blocks: vec![Block::Text(prompt)],
677 });
678
679 let out = match model.complete(&ctx).await {
680 Ok(o) => o,
681 Err(e) => {
682 tracing::warn!(error = %e, "memory synth model call failed; nothing persisted");
683 return;
684 }
685 };
686 let raw = out.text.unwrap_or_default();
687
688 let parsed = extract_facts(&raw);
689 if let Some(facts) = parsed.as_ref() {
690 for f in facts.iter().take(max_facts) {
691 let content = f.content.trim().to_string();
692 if content.is_empty() {
693 continue;
694 }
695 let mut tags = base_tags.clone();
696 tags.extend(f.tags.clone());
697 let mut entry = MemoryEntry::new(content)
698 .with_source(source.clone())
699 .with_tags(tags);
700 if let Some(days) = f.ttl_days
701 && days > 0
702 {
703 entry = entry.with_ttl_days(days);
704 }
705 if let Err(e) = memory.write(entry).await {
706 tracing::warn!(error = %e, "memory synth write failed");
707 }
708 }
709 } else if !raw.trim().is_empty() {
710 let mut tags = base_tags;
713 tags.push("synth-raw".into());
714 let entry = MemoryEntry::new(raw.trim().to_string())
715 .with_source(source)
716 .with_tags(tags);
717 if let Err(e) = memory.write(entry).await {
718 tracing::warn!(error = %e, "memory synth-raw write failed");
719 }
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use harness_core::{ModelOutput, StopReason};
727 use std::sync::atomic::{AtomicU64, Ordering};
728
729 #[derive(Default)]
731 struct VecMemory {
732 store: Mutex<Vec<MemoryEntry>>,
733 }
734 #[async_trait]
735 impl Memory for VecMemory {
736 async fn recall(
737 &self,
738 query: &str,
739 k: usize,
740 ) -> Result<Vec<MemoryEntry>, harness_core::MemoryError> {
741 let g = self.store.lock().unwrap();
742 let q = query.to_lowercase();
743 let mut hits: Vec<MemoryEntry> = g
744 .iter()
745 .filter(|e| {
746 let hay = e.content.to_lowercase();
747 q.split_whitespace().any(|t| hay.contains(t))
748 })
749 .cloned()
750 .collect();
751 hits.truncate(k);
752 Ok(hits)
753 }
754 async fn write(&self, entry: MemoryEntry) -> Result<(), harness_core::MemoryError> {
755 self.store.lock().unwrap().push(entry);
756 Ok(())
757 }
758 }
759
760 static SEQ: AtomicU64 = AtomicU64::new(0);
761
762 #[tokio::test]
763 async fn writer_persists_last_text_on_task_completed() {
764 let mem = Arc::new(VecMemory::default());
765 let w = MemoryWriter::new(mem.clone()).with_source("test-app");
766 let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
767 "harness-mw-{}-{}",
768 std::process::id(),
769 SEQ.fetch_add(1, Ordering::SeqCst)
770 )));
771
772 let out = ModelOutput {
773 text: Some("final answer X".into()),
774 ..Default::default()
775 };
776 let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
777 let _ = w.fire(&Event::TaskCompleted, &mut world);
778
779 tokio::task::yield_now().await;
781 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
782
783 let stored = mem.store.lock().unwrap().clone();
784 assert_eq!(stored.len(), 1);
785 assert_eq!(stored[0].content, "final answer X");
786 assert_eq!(stored[0].source.as_deref(), Some("test-app"));
787 }
788
789 #[tokio::test]
790 async fn decision_writer_captures_marker_lines_only() {
791 let mem = Arc::new(VecMemory::default());
792 let w = DecisionWriter::new(mem.clone()).with_source("ops");
793 let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
794 "harness-dw-{}-{}",
795 std::process::id(),
796 SEQ.fetch_add(1, Ordering::SeqCst)
797 )));
798
799 let turn1 = ModelOutput {
800 text: Some("Investigating the queue.\nDECISION: raise db_pool_size to 25.".into()),
801 ..Default::default()
802 };
803 let turn2 = ModelOutput {
804 text: Some("DECISION: raise db_pool_size to 25.\nAll done, closing out.".into()),
805 ..Default::default()
806 };
807 let _ = w.fire(&Event::PostModel { out: &turn1 }, &mut world);
808 let _ = w.fire(&Event::PostModel { out: &turn2 }, &mut world);
809 let _ = w.fire(&Event::TaskCompleted, &mut world);
810
811 tokio::task::yield_now().await;
812 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
813
814 let stored = mem.store.lock().unwrap().clone();
815 assert_eq!(stored.len(), 1);
816 assert_eq!(stored[0].content, "DECISION: raise db_pool_size to 25.");
818 assert!(stored[0].tags.contains(&"decision".to_string()));
819 }
820
821 #[tokio::test]
822 async fn decision_writer_writes_nothing_without_markers() {
823 let mem = Arc::new(VecMemory::default());
824 let w = DecisionWriter::new(mem.clone());
825 let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
826 "harness-dw-{}-{}",
827 std::process::id(),
828 SEQ.fetch_add(1, Ordering::SeqCst)
829 )));
830
831 let out = ModelOutput {
832 text: Some("Here are three fun facts about otters.".into()),
833 ..Default::default()
834 };
835 let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
836 let _ = w.fire(&Event::TaskCompleted, &mut world);
837
838 tokio::task::yield_now().await;
839 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
840
841 assert!(mem.store.lock().unwrap().is_empty());
842 }
843
844 #[tokio::test]
845 async fn writer_skips_when_no_task_completed_fires() {
846 let mem = Arc::new(VecMemory::default());
847 let w = MemoryWriter::new(mem.clone());
848 let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
849 "harness-mw-{}-{}",
850 std::process::id(),
851 SEQ.fetch_add(1, Ordering::SeqCst)
852 )));
853
854 let out = ModelOutput {
855 text: Some("partial".into()),
856 stop_reason: StopReason::ToolUse,
857 ..Default::default()
858 };
859 let _ = w.fire(&Event::PostModel { out: &out }, &mut world);
860 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
862 assert!(mem.store.lock().unwrap().is_empty());
863 }
864
865 #[tokio::test]
866 async fn synthesizer_parses_clean_json_and_writes_atomic_facts() {
867 use harness_models::{MockModel, MockResponse};
868
869 let mem = Arc::new(VecMemory::default());
870 let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
871 r#"[
872 {"content": "user prefers dark roast coffee, no sugar", "tags": ["coffee", "preferences"]},
873 {"content": "user lives in Beijing (Asia/Shanghai tz)", "tags": ["location", "timezone"]}
874 ]"#,
875 )));
876 let s = MemorySynthesizer::new(mem.clone(), synth).with_source("test");
877 let mut world = harness_context::default_world(std::env::temp_dir().join(format!(
878 "harness-ms-{}-{}",
879 std::process::id(),
880 SEQ.fetch_add(1, Ordering::SeqCst)
881 )));
882
883 let out_a = ModelOutput {
884 text: Some("I'll remember your coffee preference.".into()),
885 stop_reason: StopReason::ToolUse,
886 ..Default::default()
887 };
888 let out_b = ModelOutput {
889 text: Some("Setting Beijing as your timezone.".into()),
890 ..Default::default()
891 };
892 let _ = s.fire(&Event::PostModel { out: &out_a }, &mut world);
893 let _ = s.fire(&Event::PostModel { out: &out_b }, &mut world);
894 let _ = s.fire(&Event::TaskCompleted, &mut world);
895
896 for _ in 0..50 {
897 if mem.store.lock().unwrap().len() >= 2 {
898 break;
899 }
900 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
901 }
902 let stored = mem.store.lock().unwrap().clone();
903 assert_eq!(stored.len(), 2, "expected 2 atomic facts, got {stored:#?}");
904 assert!(stored.iter().any(|e| e.content.contains("dark roast")));
905 assert!(stored.iter().any(|e| e.content.contains("Beijing")));
906 let coffee = stored
907 .iter()
908 .find(|e| e.content.contains("dark roast"))
909 .unwrap();
910 assert!(coffee.tags.contains(&"coffee".to_string()));
911 assert_eq!(coffee.source.as_deref(), Some("test"));
912 }
913
914 #[tokio::test]
915 async fn synthesizer_strips_markdown_fences_around_json() {
916 use harness_models::{MockModel, MockResponse};
917
918 let mem = Arc::new(VecMemory::default());
919 let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
920 "Here are the facts:\n```json\n[{\"content\":\"fact one\",\"tags\":[\"x\"]}]\n```\n",
921 )));
922 let s = MemorySynthesizer::new(mem.clone(), synth);
923 let mut world = harness_context::default_world(std::env::temp_dir());
924
925 let out = ModelOutput {
926 text: Some("some chat".into()),
927 ..Default::default()
928 };
929 let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
930 let _ = s.fire(&Event::TaskCompleted, &mut world);
931
932 for _ in 0..50 {
933 if !mem.store.lock().unwrap().is_empty() {
934 break;
935 }
936 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
937 }
938 let stored = mem.store.lock().unwrap().clone();
939 assert_eq!(stored.len(), 1);
940 assert_eq!(stored[0].content, "fact one");
941 }
942
943 #[tokio::test]
944 async fn synthesizer_empty_array_persists_nothing() {
945 use harness_models::{MockModel, MockResponse};
949
950 let mem = Arc::new(VecMemory::default());
951 let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text("[]")));
952 let s = MemorySynthesizer::new(mem.clone(), synth);
953 let mut world = harness_context::default_world(std::env::temp_dir());
954
955 let out = ModelOutput {
956 text: Some("fluff".into()),
957 ..Default::default()
958 };
959 let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
960 let _ = s.fire(&Event::TaskCompleted, &mut world);
961
962 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
964 let stored = mem.store.lock().unwrap().clone();
965 assert!(stored.is_empty(), "expected nothing stored, got {stored:?}");
966 }
967
968 #[tokio::test]
969 async fn synthesizer_falls_back_to_synth_raw_when_json_unparseable() {
970 use harness_models::{MockModel, MockResponse};
971
972 let mem = Arc::new(VecMemory::default());
973 let synth: Arc<dyn Model> = Arc::new(MockModel::new().script(MockResponse::text(
974 "The user said they like coffee. I think that's important.",
975 )));
976 let s = MemorySynthesizer::new(mem.clone(), synth);
977 let mut world = harness_context::default_world(std::env::temp_dir());
978
979 let out = ModelOutput {
980 text: Some("session chat".into()),
981 ..Default::default()
982 };
983 let _ = s.fire(&Event::PostModel { out: &out }, &mut world);
984 let _ = s.fire(&Event::TaskCompleted, &mut world);
985
986 for _ in 0..50 {
987 if !mem.store.lock().unwrap().is_empty() {
988 break;
989 }
990 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
991 }
992 let stored = mem.store.lock().unwrap().clone();
993 assert_eq!(stored.len(), 1);
994 assert!(stored[0].tags.contains(&"synth-raw".to_string()));
995 assert!(stored[0].content.contains("coffee"));
996 }
997}