Skip to main content

a3s_code_core/
memory.rs

1//! Memory and learning system for the agent.
2//!
3//! Core types (`MemoryStore`, `MemoryItem`, `MemoryType`, `RelevanceConfig`,
4//! `FileMemoryStore`, `InMemoryStore`) live in `a3s-memory`.
5//!
6//! This module owns `MemoryConfig`, `MemoryStats`, `AgentMemory` (three-tier
7//! session memory), and `MemoryContextProvider` (context injection bridge).
8
9use a3s_memory::{MemoryItem, MemoryStore, MemoryType, PrunePolicy, RelevanceConfig};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::VecDeque;
13use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14use std::sync::Arc;
15use tokio::sync::{oneshot, Notify, RwLock};
16
17#[path = "memory/maintenance.rs"]
18mod maintenance;
19#[path = "memory/semantic_refresh.rs"]
20mod semantic_refresh;
21pub use maintenance::{
22    MemoryMaintenanceCloseReport, MemoryMaintenanceContext, MemoryMaintenanceError,
23    MemoryMaintenanceHealth, MemoryMaintenanceJob, MemoryMaintenanceJobHealth,
24    MemoryMaintenanceOptions, MemoryMaintenanceOutcome, MemoryMaintenancePhase,
25    MemoryMaintenanceRuntime, ScheduledMemoryMaintenance,
26};
27pub use semantic_refresh::{
28    ScheduledSemanticRefresh, SemanticRefreshMetrics, SemanticRefreshRunMetrics,
29    SemanticRefreshRunOutcome, SEMANTIC_REFRESH_JOB_NAME, SEMANTIC_REFRESH_RECENT_RUN_LIMIT,
30};
31
32const MEMORY_STATUS_METADATA: &str = "a3s.memory.status";
33const MEMORY_STATUS_SUPERSEDED: &str = "superseded";
34
35/// One durable-memory write as observed by a host integration.
36///
37/// `incoming` preserves the identity and per-turn metadata of this observation,
38/// while `stored` is the canonical item returned by the memory backend. They
39/// differ when a backend consolidates a duplicate into an existing item.
40#[derive(Debug, Clone)]
41pub struct MemoryObservation {
42    pub incoming: MemoryItem,
43    pub stored: MemoryItem,
44    pub merged: bool,
45}
46
47/// Host extension point invoked after a durable memory has been persisted.
48///
49/// Observer failures are logged but never roll back the memory write. This is
50/// intended for derived, auditable projections such as preference and workflow
51/// learning; the memory store remains the source of truth.
52#[async_trait::async_trait]
53pub trait MemoryObserver: Send + Sync {
54    async fn on_memory_stored(&self, observation: MemoryObservation) -> anyhow::Result<()>;
55}
56
57// ============================================================================
58// Configuration
59// ============================================================================
60
61/// Configuration for the agent memory system (three-tier: working/short-term/long-term)
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct MemoryConfig {
65    /// Relevance scoring parameters
66    #[serde(default)]
67    pub relevance: RelevanceConfig,
68    /// Maximum short-term memory items (default: 100)
69    #[serde(default = "MemoryConfig::default_max_short_term")]
70    pub max_short_term: usize,
71    /// Maximum working memory items (default: 10)
72    #[serde(default = "MemoryConfig::default_max_working")]
73    pub max_working: usize,
74    /// Pruning policy run by an explicitly owned maintenance runtime.
75    /// `None` disables scheduled pruning.
76    #[serde(default)]
77    pub prune_policy: Option<PrunePolicy>,
78    /// How often owned maintenance prunes, in seconds (default: 3600).
79    #[serde(default = "MemoryConfig::default_prune_interval_secs")]
80    pub prune_interval_secs: u64,
81    /// Use an LLM after every completed, non-empty turn to judge whether the
82    /// turn contains durable memories and, when it does, distill them from the
83    /// transcript.
84    ///
85    /// Enabled by default when memory is configured. Semantic value decisions
86    /// belong to the LLM; the runtime does not use content-keyword gates.
87    #[serde(
88        default = "MemoryConfig::default_llm_extraction",
89        alias = "llm_extraction"
90    )]
91    pub llm_extraction: bool,
92    /// Maximum durable memories the LLM extractor may write per turn.
93    #[serde(default = "MemoryConfig::default_llm_extraction_max_items")]
94    pub llm_extraction_max_items: usize,
95    /// Maximum transcript characters passed into the LLM memory extractor.
96    #[serde(default = "MemoryConfig::default_llm_extraction_max_input_chars")]
97    pub llm_extraction_max_input_chars: usize,
98}
99
100impl MemoryConfig {
101    fn default_max_short_term() -> usize {
102        100
103    }
104    fn default_max_working() -> usize {
105        10
106    }
107    fn default_prune_interval_secs() -> u64 {
108        3600
109    }
110    fn default_llm_extraction() -> bool {
111        true
112    }
113    fn default_llm_extraction_max_items() -> usize {
114        5
115    }
116    fn default_llm_extraction_max_input_chars() -> usize {
117        8_000
118    }
119}
120
121impl Default for MemoryConfig {
122    fn default() -> Self {
123        Self {
124            relevance: RelevanceConfig::default(),
125            max_short_term: 100,
126            max_working: 10,
127            prune_policy: None,
128            prune_interval_secs: 3600,
129            llm_extraction: true,
130            llm_extraction_max_items: 5,
131            llm_extraction_max_input_chars: 8_000,
132        }
133    }
134}
135
136// ============================================================================
137// Memory Stats
138// ============================================================================
139
140/// Statistics for the three-tier agent memory system
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct MemoryStats {
143    pub long_term_count: usize,
144    pub short_term_count: usize,
145    pub working_count: usize,
146}
147
148// ============================================================================
149// Agent Memory (three-tier: working / short-term / long-term)
150// ============================================================================
151
152/// Three-tier agent memory: working, short-term (session), and long-term (persisted).
153#[derive(Clone)]
154pub struct AgentMemory {
155    /// Long-term memory store
156    pub(crate) store: Arc<dyn MemoryStore>,
157    /// Short-term memory (current session)
158    short_term: Arc<RwLock<VecDeque<MemoryItem>>>,
159    /// Working memory (active context)
160    working: Arc<RwLock<Vec<MemoryItem>>>,
161    pub(crate) max_short_term: usize,
162    pub(crate) max_working: usize,
163    pub(crate) relevance_config: RelevanceConfig,
164    pub(crate) llm_extraction: bool,
165    pub(crate) llm_extraction_max_items: usize,
166    pub(crate) llm_extraction_max_input_chars: usize,
167    extraction_queue: Arc<MemoryExtractionQueue>,
168    observers: Arc<Vec<Arc<dyn MemoryObserver>>>,
169    durable_memory: Option<crate::durable_memory::DurableMemorySession>,
170    prune_policy: Option<PrunePolicy>,
171    prune_interval: std::time::Duration,
172    maintenance_claimed: Arc<AtomicBool>,
173}
174
175#[derive(Default)]
176struct MemoryExtractionQueue {
177    state: std::sync::Mutex<MemoryExtractionQueueState>,
178    pending: AtomicUsize,
179    idle: Notify,
180}
181
182#[derive(Default)]
183struct MemoryExtractionQueueState {
184    tail: Option<oneshot::Receiver<()>>,
185}
186
187/// A FIFO ticket for one completed-turn extraction.
188///
189/// Registration happens before a background task is spawned, so session close
190/// can observe every accepted extraction even if the task has not been polled
191/// yet. Chaining each ticket to its predecessor preserves completed-turn order
192/// without blocking streaming callers.
193pub(crate) struct MemoryExtractionTicket {
194    predecessor: Option<oneshot::Receiver<()>>,
195    completion: Option<oneshot::Sender<()>>,
196    queue: Arc<MemoryExtractionQueue>,
197}
198
199impl MemoryExtractionTicket {
200    pub(crate) async fn wait_for_turn(&mut self) {
201        if let Some(predecessor) = self.predecessor.take() {
202            let _ = predecessor.await;
203        }
204    }
205}
206
207impl Drop for MemoryExtractionTicket {
208    fn drop(&mut self) {
209        if let Some(completion) = self.completion.take() {
210            let _ = completion.send(());
211        }
212        if self.queue.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
213            self.queue.idle.notify_waiters();
214        }
215    }
216}
217
218impl std::fmt::Debug for AgentMemory {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("AgentMemory")
221            .field("max_short_term", &self.max_short_term)
222            .field("max_working", &self.max_working)
223            .field("observers", &self.observers.len())
224            .field("maintenance_configured", &self.maintenance_configured())
225            .finish()
226    }
227}
228
229impl AgentMemory {
230    /// Create a new agent memory system with default configuration
231    pub fn new(store: Arc<dyn MemoryStore>) -> Self {
232        Self::with_config(store, MemoryConfig::default())
233    }
234
235    /// Create a new agent memory system with custom configuration.
236    ///
237    /// Construction is side-effect free. A session or embedding host must own
238    /// a [`MemoryMaintenanceRuntime`] to execute a configured prune policy.
239    pub fn with_config(store: Arc<dyn MemoryStore>, config: MemoryConfig) -> Self {
240        Self::with_config_and_observers(store, config, Vec::new())
241    }
242
243    /// Create a memory system with host observers for successful durable
244    /// writes. Observers receive both the incoming observation and the
245    /// canonical stored item so duplicate consolidation remains auditable.
246    pub fn with_config_and_observers(
247        store: Arc<dyn MemoryStore>,
248        config: MemoryConfig,
249        observers: Vec<Arc<dyn MemoryObserver>>,
250    ) -> Self {
251        Self::with_config_observers_and_durable(store, config, observers, None)
252    }
253
254    /// Create a memory system with host observers and an optional exact V2
255    /// durable-memory binding.
256    pub fn with_config_observers_and_durable(
257        store: Arc<dyn MemoryStore>,
258        config: MemoryConfig,
259        observers: Vec<Arc<dyn MemoryObserver>>,
260        durable_memory: Option<crate::durable_memory::DurableMemorySession>,
261    ) -> Self {
262        Self {
263            store,
264            short_term: Arc::new(RwLock::new(VecDeque::new())),
265            working: Arc::new(RwLock::new(Vec::new())),
266            max_short_term: config.max_short_term,
267            max_working: config.max_working,
268            relevance_config: config.relevance,
269            llm_extraction: config.llm_extraction,
270            llm_extraction_max_items: config.llm_extraction_max_items,
271            llm_extraction_max_input_chars: config.llm_extraction_max_input_chars,
272            extraction_queue: Arc::new(MemoryExtractionQueue::default()),
273            observers: Arc::new(observers),
274            durable_memory,
275            prune_policy: config.prune_policy,
276            prune_interval: std::time::Duration::from_secs(config.prune_interval_secs),
277            maintenance_claimed: Arc::new(AtomicBool::new(false)),
278        }
279    }
280
281    pub(crate) fn score(&self, item: &MemoryItem, now: DateTime<Utc>) -> f32 {
282        let age_days = (now - item.timestamp).num_seconds() as f32 / 86400.0;
283        let decay = (-age_days / self.relevance_config.decay_days).exp();
284        item.importance * self.relevance_config.importance_weight
285            + decay * self.relevance_config.recency_weight
286    }
287
288    /// Store a memory in long-term storage and add to short-term
289    pub async fn remember(&self, item: MemoryItem) -> anyhow::Result<()> {
290        self.remember_item(item).await.map(|_| ())
291    }
292
293    /// Store a memory and return the normalized item that was sent to storage.
294    pub async fn remember_item(&self, item: MemoryItem) -> anyhow::Result<MemoryItem> {
295        let incoming = item.clone();
296        let item = self.store.store_and_return(item).await?;
297        let mut short_term = self.short_term.write().await;
298        if let Some(existing) = short_term
299            .iter_mut()
300            .find(|existing| existing.id == item.id)
301        {
302            *existing = item.clone();
303        } else {
304            short_term.push_back(item.clone());
305        }
306        if short_term.len() > self.max_short_term {
307            short_term.pop_front();
308        }
309        drop(short_term);
310
311        if !self.observers.is_empty() {
312            let observation = MemoryObservation {
313                merged: item.id != incoming.id,
314                incoming,
315                stored: item.clone(),
316            };
317            for observer in self.observers.iter() {
318                if let Err(error) = observer.on_memory_stored(observation.clone()).await {
319                    tracing::warn!(%error, "memory observer failed after persistence");
320                }
321            }
322        }
323        Ok(item)
324    }
325
326    /// Remove a memory from long-term storage and session-local memory tiers.
327    pub async fn forget(&self, id: &str) -> anyhow::Result<()> {
328        self.store.delete(id).await?;
329        self.short_term.write().await.retain(|item| item.id != id);
330        self.working.write().await.retain(|item| item.id != id);
331        Ok(())
332    }
333
334    /// Preserve a superseded V1 item for audit while removing it from recall.
335    pub(crate) async fn mark_superseded(
336        &self,
337        id: &str,
338        replacement_id: &str,
339    ) -> anyhow::Result<bool> {
340        if id == replacement_id {
341            anyhow::bail!("a memory cannot supersede itself");
342        }
343        let Some(mut item) = self.store.retrieve(id).await? else {
344            return Ok(false);
345        };
346        item.metadata.insert(
347            MEMORY_STATUS_METADATA.to_string(),
348            MEMORY_STATUS_SUPERSEDED.to_string(),
349        );
350        item.metadata
351            .insert("superseded_by".to_string(), replacement_id.to_string());
352        item.metadata
353            .insert("protected".to_string(), "true".to_string());
354        if !item.tags.iter().any(|tag| tag == "superseded") {
355            item.tags.push("superseded".to_string());
356        }
357        self.store.store(item).await?;
358        self.short_term.write().await.retain(|item| item.id != id);
359        self.working.write().await.retain(|item| item.id != id);
360        Ok(true)
361    }
362
363    /// Remember a successful pattern
364    pub async fn remember_success(
365        &self,
366        prompt: &str,
367        tools_used: &[String],
368        result: &str,
369    ) -> anyhow::Result<()> {
370        self.remember_success_item(prompt, tools_used, result)
371            .await
372            .map(|_| ())
373    }
374
375    /// Remember a successful pattern and return the stored memory item.
376    pub async fn remember_success_item(
377        &self,
378        prompt: &str,
379        tools_used: &[String],
380        result: &str,
381    ) -> anyhow::Result<MemoryItem> {
382        let content = format!(
383            "Success: {}\nTools: {}\nResult: {}",
384            prompt,
385            tools_used.join(", "),
386            result
387        );
388        let mut item = MemoryItem::new(content)
389            .with_importance(0.8)
390            .with_tag("success")
391            .with_tag("pattern")
392            .with_type(MemoryType::Procedural)
393            .with_metadata("prompt", prompt)
394            .with_metadata("tools", tools_used.join(","));
395        for tool in tools_used {
396            item = item.with_tag(tool.clone());
397        }
398        self.remember_item(item).await
399    }
400
401    /// Remember a failure to avoid repeating
402    pub async fn remember_failure(
403        &self,
404        prompt: &str,
405        error: &str,
406        attempted_tools: &[String],
407    ) -> anyhow::Result<()> {
408        self.remember_failure_item(prompt, error, attempted_tools)
409            .await
410            .map(|_| ())
411    }
412
413    /// Remember a failed pattern and return the stored memory item.
414    pub async fn remember_failure_item(
415        &self,
416        prompt: &str,
417        error: &str,
418        attempted_tools: &[String],
419    ) -> anyhow::Result<MemoryItem> {
420        let content = format!(
421            "Failure: {}\nError: {}\nAttempted tools: {}",
422            prompt,
423            error,
424            attempted_tools.join(", ")
425        );
426        let mut item = MemoryItem::new(content)
427            .with_importance(0.9)
428            .with_tag("failure")
429            .with_tag("avoid")
430            .with_type(MemoryType::Episodic)
431            .with_metadata("prompt", prompt)
432            .with_metadata("error", error);
433        for tool in attempted_tools {
434            item = item.with_tag(tool.clone());
435        }
436        self.remember_item(item).await
437    }
438
439    /// Recall similar past experiences
440    pub async fn recall_similar(
441        &self,
442        prompt: &str,
443        limit: usize,
444    ) -> anyhow::Result<Vec<MemoryItem>> {
445        let items = self.store.search(prompt, recall_scan_limit(limit)).await?;
446        Ok(recallable_items(items, limit))
447    }
448
449    /// Recall by tags
450    pub async fn recall_by_tags(
451        &self,
452        tags: &[String],
453        limit: usize,
454    ) -> anyhow::Result<Vec<MemoryItem>> {
455        let items = self
456            .store
457            .search_by_tags(tags, recall_scan_limit(limit))
458            .await?;
459        Ok(recallable_items(items, limit))
460    }
461
462    /// Get recent memories
463    pub async fn get_recent(&self, limit: usize) -> anyhow::Result<Vec<MemoryItem>> {
464        let items = self.store.get_recent(recall_scan_limit(limit)).await?;
465        Ok(recallable_items(items, limit))
466    }
467
468    /// Add to working memory (auto-trims by relevance if over capacity)
469    pub async fn add_to_working(&self, item: MemoryItem) -> anyhow::Result<()> {
470        let mut working = self.working.write().await;
471        working.push(item);
472        if working.len() > self.max_working {
473            let now = Utc::now();
474            working.sort_by(|a, b| {
475                self.score(b, now)
476                    .partial_cmp(&self.score(a, now))
477                    .unwrap_or(std::cmp::Ordering::Equal)
478            });
479            working.truncate(self.max_working);
480        }
481        Ok(())
482    }
483
484    /// Get working memory
485    pub async fn get_working(&self) -> Vec<MemoryItem> {
486        self.working
487            .read()
488            .await
489            .iter()
490            .filter(|item| is_recallable(item))
491            .cloned()
492            .collect()
493    }
494
495    /// Clear working memory
496    pub async fn clear_working(&self) {
497        self.working.write().await.clear();
498    }
499
500    /// Get short-term memory
501    pub async fn get_short_term(&self) -> Vec<MemoryItem> {
502        self.short_term
503            .read()
504            .await
505            .iter()
506            .filter(|item| is_recallable(item))
507            .cloned()
508            .collect()
509    }
510
511    /// Clear short-term memory
512    pub async fn clear_short_term(&self) {
513        self.short_term.write().await.clear();
514    }
515
516    /// Get memory statistics
517    pub async fn stats(&self) -> anyhow::Result<MemoryStats> {
518        Ok(MemoryStats {
519            long_term_count: self.store.count().await?,
520            short_term_count: self.short_term.read().await.len(),
521            working_count: self.working.read().await.len(),
522        })
523    }
524
525    /// Get access to the underlying store
526    pub fn store(&self) -> &Arc<dyn MemoryStore> {
527        &self.store
528    }
529
530    /// Get working memory count
531    pub async fn working_count(&self) -> usize {
532        self.working.read().await.len()
533    }
534
535    /// Get short-term memory count
536    pub async fn short_term_count(&self) -> usize {
537        self.short_term.read().await.len()
538    }
539
540    pub(crate) fn llm_extraction_enabled(&self) -> bool {
541        self.llm_extraction
542    }
543
544    pub(crate) fn durable_memory(&self) -> Option<&crate::durable_memory::DurableMemorySession> {
545        self.durable_memory.as_ref()
546    }
547
548    /// Return whether this memory has built-in periodic maintenance configured.
549    pub fn maintenance_configured(&self) -> bool {
550        self.prune_policy.is_some()
551    }
552
553    fn maintenance_prune_schedule(&self) -> Option<(PrunePolicy, std::time::Duration)> {
554        self.prune_policy
555            .clone()
556            .map(|policy| (policy, self.prune_interval))
557    }
558
559    pub(crate) fn llm_extraction_max_items(&self) -> usize {
560        self.llm_extraction_max_items
561    }
562
563    pub(crate) fn llm_extraction_max_input_chars(&self) -> usize {
564        self.llm_extraction_max_input_chars
565    }
566
567    pub(crate) fn enqueue_llm_extraction(&self) -> MemoryExtractionTicket {
568        let (completion, receiver) = oneshot::channel();
569        let predecessor = {
570            let mut state = self
571                .extraction_queue
572                .state
573                .lock()
574                .unwrap_or_else(std::sync::PoisonError::into_inner);
575            state.tail.replace(receiver)
576        };
577        self.extraction_queue.pending.fetch_add(1, Ordering::AcqRel);
578        MemoryExtractionTicket {
579            predecessor,
580            completion: Some(completion),
581            queue: Arc::clone(&self.extraction_queue),
582        }
583    }
584
585    /// Wait until every extraction registered before this call has settled.
586    /// Returns `false` when the bounded close-time wait expires.
587    pub(crate) async fn drain_llm_extractions(&self, timeout: std::time::Duration) -> bool {
588        let wait_until_idle = async {
589            loop {
590                let notified = self.extraction_queue.idle.notified();
591                if self.extraction_queue.pending.load(Ordering::Acquire) == 0 {
592                    return;
593                }
594                notified.await;
595            }
596        };
597        tokio::time::timeout(timeout, wait_until_idle).await.is_ok()
598    }
599}
600
601// ============================================================================
602// Memory Context Provider
603// ============================================================================
604
605/// Context provider that surfaces past memories as agent context.
606pub struct MemoryContextProvider {
607    memory: AgentMemory,
608}
609
610impl MemoryContextProvider {
611    pub fn new(memory: AgentMemory) -> Self {
612        Self { memory }
613    }
614}
615
616pub(crate) fn memory_items_to_context_result(
617    provider: impl Into<String>,
618    items: Vec<MemoryItem>,
619) -> crate::context::ContextResult {
620    let mut result = crate::context::ContextResult::new(provider);
621    let items = items.into_iter().filter(is_recallable).collect::<Vec<_>>();
622    let total = items.len().max(1);
623    for (index, item) in items.into_iter().enumerate() {
624        let supersedes = relation_ids(&item, "supersedes");
625        let conflicts_with = relation_ids(&item, "conflicts_with");
626        let content = memory_context_content(&item, &supersedes, &conflicts_with);
627        let token_count = (content.len() / 4).max(1);
628        let recall_rank_score = 1.0 - (index as f32 / total as f32);
629        let relevance = (item.relevance_score() * 0.35 + recall_rank_score * 0.65).clamp(0.0, 1.0);
630        let context_item = crate::context::ContextItem::new(
631            &item.id,
632            crate::context::ContextType::Memory,
633            content,
634        )
635        .with_relevance(relevance)
636        .with_token_count(token_count)
637        .with_source(format!("memory://{}", item.id))
638        .with_metadata("memory_id", serde_json::json!(item.id))
639        .with_metadata(
640            "memory_type",
641            serde_json::json!(memory_type_label(item.memory_type)),
642        )
643        .with_metadata("tags", serde_json::json!(item.tags))
644        .with_metadata("importance", serde_json::json!(item.importance))
645        .with_provenance("long_term_memory")
646        .with_priority(0.35)
647        .with_trust(0.7)
648        .with_freshness(0.5);
649        let context_item = add_relation_metadata(context_item, "supersedes", supersedes);
650        let context_item = add_relation_metadata(context_item, "conflicts_with", conflicts_with);
651        result.add_item(context_item);
652    }
653    result
654}
655
656fn recall_scan_limit(limit: usize) -> usize {
657    limit.saturating_mul(4).max(limit)
658}
659
660fn recallable_items(items: Vec<MemoryItem>, limit: usize) -> Vec<MemoryItem> {
661    items
662        .into_iter()
663        .filter(is_recallable)
664        .take(limit)
665        .collect()
666}
667
668fn is_recallable(item: &MemoryItem) -> bool {
669    !matches!(
670        item.metadata
671            .get(MEMORY_STATUS_METADATA)
672            .map(|status| status.trim().to_ascii_lowercase()),
673        Some(status) if matches!(status.as_str(), "superseded" | "tombstoned")
674    )
675}
676
677fn relation_ids(item: &MemoryItem, key: &str) -> Vec<String> {
678    item.metadata
679        .get(key)
680        .map(|value| {
681            value
682                .split(',')
683                .map(str::trim)
684                .filter(|id| !id.is_empty())
685                .map(ToOwned::to_owned)
686                .collect()
687        })
688        .unwrap_or_default()
689}
690
691fn memory_context_content(
692    item: &MemoryItem,
693    supersedes: &[String],
694    conflicts_with: &[String],
695) -> String {
696    let mut content = item.content.clone();
697    if supersedes.is_empty() && conflicts_with.is_empty() {
698        return content;
699    }
700
701    content.push_str("\n\nMemory relations:");
702    if !supersedes.is_empty() {
703        content.push_str("\n- supersedes: ");
704        content.push_str(&relation_sources(supersedes));
705    }
706    if !conflicts_with.is_empty() {
707        content.push_str("\n- conflicts_with: ");
708        content.push_str(&relation_sources(conflicts_with));
709    }
710    content
711}
712
713fn relation_sources(ids: &[String]) -> String {
714    ids.iter()
715        .map(|id| format!("memory://{id}"))
716        .collect::<Vec<_>>()
717        .join(", ")
718}
719
720fn add_relation_metadata(
721    item: crate::context::ContextItem,
722    key: &str,
723    ids: Vec<String>,
724) -> crate::context::ContextItem {
725    if ids.is_empty() {
726        item
727    } else {
728        item.with_metadata(key, serde_json::json!(ids))
729    }
730}
731
732fn memory_type_label(memory_type: MemoryType) -> &'static str {
733    match memory_type {
734        MemoryType::Episodic => "episodic",
735        MemoryType::Semantic => "semantic",
736        MemoryType::Procedural => "procedural",
737        MemoryType::Working => "working",
738    }
739}
740
741#[async_trait::async_trait]
742impl crate::context::ContextProvider for MemoryContextProvider {
743    fn name(&self) -> &str {
744        "memory"
745    }
746
747    async fn query(
748        &self,
749        query: &crate::context::ContextQuery,
750    ) -> anyhow::Result<crate::context::ContextResult> {
751        let limit = query.max_results.min(5);
752        let items = self.memory.recall_similar(&query.query, limit).await?;
753
754        Ok(memory_items_to_context_result("memory", items))
755    }
756
757    async fn on_turn_complete(
758        &self,
759        _session_id: &str,
760        _prompt: &str,
761        _response: &str,
762    ) -> anyhow::Result<()> {
763        // Memory extraction is owned by the agent loop's LLM value judge.
764        // This provider only contributes recalled memories as prompt context.
765        Ok(())
766    }
767}
768#[cfg(test)]
769#[path = "memory/tests.rs"]
770mod tests;