Skip to main content

klieo_core/
memory.rs

1//! Memory traits — short-term, long-term, episodic.
2
3use crate::error::MemoryError;
4use crate::ids::{FactId, RunId, ThreadId};
5use crate::llm::Message;
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// Outcome of a tool invocation as recorded in the episodic event stream.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "outcome", rename_all = "snake_case")]
13pub enum ToolResult {
14    /// Tool returned a successful JSON result.
15    Ok {
16        /// Result payload.
17        value: serde_json::Value,
18    },
19    /// Tool returned an error message.
20    Err {
21        /// Error string.
22        message: String,
23    },
24}
25
26impl ToolResult {
27    /// Build a successful result. Convenience for the
28    /// `ToolResult::Ok { value }` struct-variant ceremony.
29    pub fn ok(value: serde_json::Value) -> Self {
30        Self::Ok { value }
31    }
32
33    /// Build an error result. Convenience for the
34    /// `ToolResult::Err { message }` struct-variant ceremony.
35    pub fn err(message: impl Into<String>) -> Self {
36        Self::Err {
37            message: message.into(),
38        }
39    }
40}
41
42#[cfg(test)]
43mod tool_result_factories_tests {
44    use super::*;
45
46    #[test]
47    fn ok_factory_builds_ok_variant() {
48        let v = serde_json::json!({"hit": true});
49        let r = ToolResult::ok(v.clone());
50        match r {
51            ToolResult::Ok { value } => assert_eq!(value, v),
52            _ => panic!("expected ToolResult::Ok variant"),
53        }
54    }
55
56    #[test]
57    fn err_factory_builds_err_variant() {
58        let r = ToolResult::err("boom");
59        match r {
60            ToolResult::Err { message } => assert_eq!(message, "boom"),
61            _ => panic!("expected ToolResult::Err variant"),
62        }
63    }
64}
65
66/// Conversation buffer scoped to a single thread.
67///
68/// ```
69/// # tokio_test::block_on(async {
70/// use klieo_core::test_utils::InMemoryShortTerm;
71/// use klieo_core::{ShortTermMemory, Message, Role, ThreadId};
72///
73/// let m = InMemoryShortTerm::default();
74/// let thread = ThreadId::new("t1");
75/// m.append(thread.clone(), Message {
76///     role: Role::User, content: "hi".into(),
77///     tool_calls: vec![], tool_call_id: None,
78/// }).await.unwrap();
79/// let loaded = m.load(thread, 1024).await.unwrap();
80/// assert_eq!(loaded.len(), 1);
81/// # });
82/// ```
83#[async_trait]
84pub trait ShortTermMemory: Send + Sync {
85    /// Append a message to the thread's history.
86    async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError>;
87
88    /// Append a batch of messages, oldest first. The default appends each in
89    /// turn; backends with bulk-insert support should override to collapse the
90    /// N writes into one round-trip (the resume path replays a full history).
91    async fn append_batch(
92        &self,
93        thread: ThreadId,
94        messages: Vec<Message>,
95    ) -> Result<(), MemoryError> {
96        for msg in messages {
97            self.append(thread.clone(), msg).await?;
98        }
99        Ok(())
100    }
101
102    /// Load up to `max_tokens` of the most-recent messages, oldest first.
103    /// Implementations approximate token counts (provider-specific).
104    async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError>;
105
106    /// Drop all messages for `thread`.
107    async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError>;
108}
109
110/// Namespacing for long-term memory facts.
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112pub enum Scope {
113    /// Workspace-scoped (multi-agent shared).
114    Workspace(String),
115    /// Per-agent scoped.
116    Agent(String),
117    /// Process-global (use sparingly).
118    Global,
119}
120
121/// One stored fact in long-term memory.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct Fact {
125    /// Plain-text body, embedded for retrieval.
126    pub text: String,
127    /// Caller-supplied metadata, opaque to the store.
128    #[serde(default)]
129    pub metadata: serde_json::Value,
130}
131
132impl Fact {
133    /// Prefer this over struct literals outside `klieo-core` (`#[non_exhaustive]`);
134    /// metadata defaults to JSON null — attach it with [`Fact::with_metadata`].
135    pub fn new(text: impl Into<String>) -> Self {
136        Self {
137            text: text.into(),
138            metadata: serde_json::Value::Null,
139        }
140    }
141
142    /// Override the default JSON-null metadata with an opaque caller value.
143    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
144        self.metadata = metadata;
145        self
146    }
147}
148
149/// Long-term semantic memory.
150///
151/// ```
152/// # tokio_test::block_on(async {
153/// use klieo_core::test_utils::InMemoryLongTerm;
154/// use klieo_core::{Fact, LongTermMemory, Scope};
155///
156/// let m = InMemoryLongTerm::default();
157/// let scope = Scope::Workspace("ws".into());
158/// m.remember(scope.clone(), Fact::new("the sky is blue")).await.unwrap();
159/// let hits = m.recall(scope, "sky", 1).await.unwrap();
160/// assert_eq!(hits.len(), 1);
161/// # });
162/// ```
163#[async_trait]
164pub trait LongTermMemory: Send + Sync {
165    /// Store a fact under `scope`. Returns a stable id.
166    async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError>;
167
168    /// Top-`k` semantic recall under `scope` for the supplied query.
169    async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError>;
170
171    /// Remove a stored fact.
172    async fn forget(&self, id: FactId) -> Result<(), MemoryError>;
173}
174
175/// One event in the episodic event stream of a single agent run.
176///
177/// Marked `#[non_exhaustive]` so additive variants (e.g.
178/// [`Self::SummaryCheckpoint`]) can be introduced without forcing a
179/// SemVer-major bump. Match arms in downstream crates must include a
180/// fallback `_ => …`.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[non_exhaustive]
183pub enum Episode {
184    /// Run started.
185    Started {
186        /// Agent name.
187        agent: String,
188    },
189    /// LLM call completed.
190    ///
191    /// `provider`/`model`/`prompt_tokens`/`completion_tokens` are
192    /// `Option` so legacy emit sites can leave them `None`; downstream
193    /// projectors fall back to the `LlmIo` sidecar in `klieo-runlog`
194    /// when the structured fields are absent.
195    LlmCall {
196        /// Total tokens reported by the provider (prompt + completion).
197        tokens: u32,
198        /// Wall-clock latency in milliseconds.
199        latency_ms: u32,
200        /// Provider identifier — e.g. `"ollama"`, `"openai"`,
201        /// `"anthropic"`, `"gemini"`. `None` for older records or
202        /// providers that don't expose a stable name.
203        ///
204        /// `#[serde(default)]` so legacy episodes serialised under
205        /// klieo 0.6.x deserialise as `None`.
206        #[serde(default)]
207        provider: Option<String>,
208        /// Model identifier — e.g. `"qwen2.5:14b"`, `"gpt-4o-mini"`.
209        #[serde(default)]
210        model: Option<String>,
211        /// Prompt-side token count when the provider splits the
212        /// breakdown; `None` falls back to `tokens` for total-only
213        /// reports.
214        #[serde(default)]
215        prompt_tokens: Option<u32>,
216        /// Completion-side token count when the provider splits the
217        /// breakdown.
218        #[serde(default)]
219        completion_tokens: Option<u32>,
220    },
221    /// Tool call completed.
222    ToolCall {
223        /// Tool name.
224        name: String,
225        /// JSON arguments.
226        args: serde_json::Value,
227        /// Tool outcome.
228        result: ToolResult,
229    },
230    /// Agent published a bus message.
231    BusPublish {
232        /// Subject.
233        subject: String,
234    },
235    /// Agent received a bus message.
236    BusReceive {
237        /// Subject.
238        subject: String,
239    },
240    /// Causal link: this run received a bus message caused by another run's
241    /// publish. Recorded by `AgentContext::record_received` when the publisher
242    /// threaded its run id via the `klieo-causation-run` bus header. Additive —
243    /// absent on records from publishers that did not thread the causation header.
244    BusCausalLink {
245        /// Subject the causal handoff occurred on.
246        subject: String,
247        /// Run id of the publisher that caused this receive.
248        caused_by_run: String,
249    },
250    /// Run completed successfully.
251    Completed,
252    /// Run failed.
253    Failed {
254        /// Error message.
255        error: String,
256    },
257    /// Summarizer checkpoint completed.
258    ///
259    /// Emitted by [`crate::summarize::summarize_history`] in lieu of
260    /// [`Self::LlmCall`] so the audit trail can distinguish summarizer
261    /// overhead from substantive agent reasoning. Downstream
262    /// observability (e.g. `klieo-runlog`) typically projects this as
263    /// a separate step kind so cost / latency attribution stays
264    /// faithful.
265    SummaryCheckpoint {
266        /// Number of older messages folded into the summary call.
267        input_message_count: u32,
268        /// Length of the resulting summary, in Unicode scalar values.
269        summary_chars: u32,
270        /// Wall-clock latency of the summarizer call.
271        latency_ms: u32,
272        /// Total tokens reported by the summarizer LLM (prompt +
273        /// completion).
274        tokens: u32,
275    },
276    /// Operational-layer event (klieo-ops). Body is an opaque
277    /// `serde_json::Value` to keep klieo-core free of an ops dependency.
278    /// klieo-ops provides typed serde conversion helpers via `OpsEvent`.
279    Ops(serde_json::Value),
280    /// Non-PII tenant attribution stamped at run entry when an external
281    /// caller drives the run.
282    ///
283    /// `tenant_label` is a derived identifier (e.g. truncated SHA-256
284    /// of the caller's `sub`) — never the raw principal, which lives
285    /// only in server-side tracing/authorization. Emitted at most once
286    /// per run, adjacent to [`Self::Started`], so the audit trail can
287    /// attribute each run to its driving tenant without admitting PII
288    /// into agent memory or LLM-visible context.
289    RunAttributed {
290        /// Derived non-PII attribution label for the driving caller.
291        tenant_label: String,
292    },
293    /// Cross-hop provenance origin stamped at run entry when an
294    /// authenticated external caller supplies a parent-chain anchor.
295    ///
296    /// `parent_anchor` is the caller's own provenance chain-entry id (or
297    /// its run's episodic-root hash) — recorded **verbatim** so the value
298    /// equals the caller's identifier and downstream tooling can stitch
299    /// klieo→klieo lineage across deployments. It is a **caller-asserted,
300    /// unverified** claim (klieo does not own or validate the caller's
301    /// chain); it is co-recorded with [`Self::RunAttributed`] so the
302    /// claim is attributable to the authenticated principal that made it.
303    /// Emitted at most once per run, adjacent to [`Self::Started`]; never
304    /// admitted into agent memory or LLM-visible context.
305    RunOrigin {
306        /// Verbatim caller-supplied cross-hop provenance anchor.
307        parent_anchor: String,
308    },
309    /// A graphRAG recall performed during the run. Recorded by the
310    /// recall-recording wrapper so the run view can surface — and
311    /// deep-link — retrieval calls. `query` is redacted + length-bounded
312    /// at the recording boundary before it reaches this episode.
313    MemoryRecall {
314        /// Redacted, length-bounded recall query text.
315        query: String,
316        /// Requested top-k.
317        #[serde(default)]
318        k: u32,
319        /// Fact ids the recall returned.
320        #[serde(default)]
321        returned_fact_ids: Vec<FactId>,
322    },
323}
324
325impl Episode {
326    /// Construct an [`Episode::LlmCall`] with the legacy two-field shape
327    /// (`tokens` + `latency_ms`), leaving the 0.7-added
328    /// `provider`/`model`/`prompt_tokens`/`completion_tokens` fields as
329    /// `None`.
330    ///
331    /// Use this when the emit site does not have the enriched fields to
332    /// hand — e.g. test fixtures, providers that only report total
333    /// tokens, or call sites being migrated incrementally. For full
334    /// 0.7 emit semantics, construct the struct variant directly.
335    ///
336    /// ```
337    /// use klieo_core::Episode;
338    /// let ep = Episode::llm_call(42, 17);
339    /// match ep {
340    ///     Episode::LlmCall { tokens, latency_ms, provider, .. } => {
341    ///         assert_eq!(tokens, 42);
342    ///         assert_eq!(latency_ms, 17);
343    ///         assert!(provider.is_none());
344    ///     }
345    ///     _ => unreachable!(),
346    /// }
347    /// ```
348    pub fn llm_call(tokens: u32, latency_ms: u32) -> Self {
349        Episode::LlmCall {
350            tokens,
351            latency_ms,
352            provider: None,
353            model: None,
354            prompt_tokens: None,
355            completion_tokens: None,
356        }
357    }
358}
359
360/// Filter passed to `EpisodicMemory::list_runs`.
361#[derive(Debug, Clone, Default)]
362pub struct RunFilter {
363    /// Filter by agent name (substring match).
364    pub agent: Option<String>,
365    /// Inclusive lower bound on `started_at`.
366    pub since: Option<DateTime<Utc>>,
367    /// Inclusive upper bound on `started_at`.
368    pub until: Option<DateTime<Utc>>,
369    /// Maximum rows returned.
370    pub limit: Option<usize>,
371}
372
373/// Index-row summary returned by `list_runs`.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct RunSummary {
376    /// Run id.
377    pub run_id: RunId,
378    /// Agent name.
379    pub agent: String,
380    /// First-event timestamp.
381    pub started_at: DateTime<Utc>,
382    /// Last-event timestamp, if completed/failed.
383    pub finished_at: Option<DateTime<Utc>>,
384    /// Number of episodes.
385    pub episode_count: u32,
386}
387
388/// Append-only event log of agent runs.
389///
390/// ```
391/// # tokio_test::block_on(async {
392/// use klieo_core::test_utils::InMemoryEpisodic;
393/// use klieo_core::{Episode, EpisodicMemory, RunId};
394///
395/// let m = InMemoryEpisodic::default();
396/// let run = RunId::new();
397/// m.record(run, Episode::Started { agent: "a".into() }).await.unwrap();
398/// let events = m.replay(run).await.unwrap();
399/// assert_eq!(events.len(), 1);
400/// # });
401/// ```
402#[async_trait]
403pub trait EpisodicMemory: Send + Sync {
404    /// Record an episode for `run`.
405    async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError>;
406
407    /// Replay all episodes for `run` in order.
408    async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError>;
409
410    /// Replay episodes for many runs.
411    ///
412    /// Returns one entry per requested run, in the requested order. A run with
413    /// no recorded episodes yields an empty `Vec` so the caller's node set
414    /// stays complete.
415    ///
416    /// # Performance
417    ///
418    /// The default implementation issues one `replay` call per run (N+1).
419    /// Stores with a batched read **must** override this method — the SQLite
420    /// impl uses a single `WHERE run_id IN (…)` query.
421    async fn replay_many(&self, runs: &[RunId]) -> Result<Vec<(RunId, Vec<Episode>)>, MemoryError> {
422        let mut out = Vec::with_capacity(runs.len());
423        for &run in runs {
424            out.push((run, self.replay(run).await?));
425        }
426        Ok(out)
427    }
428
429    /// List run summaries matching `filter`.
430    async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError>;
431}
432
433/// Resolved trio of memory handles ready to drop into an
434/// [`crate::agent::AgentContext`] or an `App`.
435///
436/// Impl crates (`klieo-memory-sqlite`, `klieo-memory-neo4j`,
437/// `klieo-memory-qdrant`) provide `From` conversions where they cover
438/// the full trio. Crates that only carry a subset (Neo4j covers
439/// short + episodic; Qdrant covers long) compose via the `App` builder's
440/// per-trait setters rather than a direct `From`.
441#[derive(Clone)]
442pub struct MemoryHandles {
443    /// Short-term conversation memory.
444    pub short_term: std::sync::Arc<dyn ShortTermMemory>,
445    /// Long-term semantic memory.
446    pub long_term: std::sync::Arc<dyn LongTermMemory>,
447    /// Episodic event log.
448    pub episodic: std::sync::Arc<dyn EpisodicMemory>,
449}
450
451impl MemoryHandles {
452    /// Build directly from three already-`Arc`-wrapped handles.
453    /// Most callers go through an impl crate's `From` instead.
454    pub fn new(
455        short_term: std::sync::Arc<dyn ShortTermMemory>,
456        long_term: std::sync::Arc<dyn LongTermMemory>,
457        episodic: std::sync::Arc<dyn EpisodicMemory>,
458    ) -> Self {
459        Self {
460            short_term,
461            long_term,
462            episodic,
463        }
464    }
465}
466
467#[cfg(test)]
468mod fact_ctor_tests {
469    use super::*;
470
471    #[test]
472    fn fact_new_defaults_metadata_null() {
473        let f = Fact::new("alice likes tea");
474        assert_eq!(f.text, "alice likes tea");
475        assert_eq!(f.metadata, serde_json::Value::Null);
476        let f2 = Fact::new("x").with_metadata(serde_json::json!({"k":"v"}));
477        assert_eq!(f2.metadata, serde_json::json!({"k":"v"}));
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[allow(dead_code)]
486    fn _assert_dyn_short(_: &dyn ShortTermMemory) {}
487    #[allow(dead_code)]
488    fn _assert_dyn_long(_: &dyn LongTermMemory) {}
489    #[allow(dead_code)]
490    fn _assert_dyn_episodic(_: &dyn EpisodicMemory) {}
491
492    /// Maps each variant to its published snake_case wire discriminant.
493    ///
494    /// The exhaustive match is the drift guard: a new `Episode` variant
495    /// fails to compile here until its discriminant is added, which is the
496    /// signal to also publish a payload schema under `docs/schemas/runlog/`
497    /// and a fixture in `tests/schema_drift.rs`. `#[non_exhaustive]` does not
498    /// force a wildcard inside the defining crate, so this stays exhaustive.
499    fn kind_discriminant(episode: &Episode) -> &'static str {
500        match episode {
501            Episode::Started { .. } => "started",
502            Episode::LlmCall { .. } => "llm_call",
503            Episode::ToolCall { .. } => "tool_call",
504            Episode::BusPublish { .. } => "bus_publish",
505            Episode::BusReceive { .. } => "bus_receive",
506            Episode::BusCausalLink { .. } => "bus_causal_link",
507            Episode::Completed => "completed",
508            Episode::Failed { .. } => "failed",
509            Episode::SummaryCheckpoint { .. } => "summary_checkpoint",
510            Episode::Ops(_) => "ops",
511            Episode::RunAttributed { .. } => "run_attributed",
512            Episode::RunOrigin { .. } => "run_origin",
513            Episode::MemoryRecall { .. } => "memory_recall",
514        }
515    }
516
517    fn one_sample_per_variant() -> Vec<Episode> {
518        vec![
519            Episode::Started {
520                agent: String::new(),
521            },
522            Episode::LlmCall {
523                tokens: 0,
524                latency_ms: 0,
525                provider: None,
526                model: None,
527                prompt_tokens: None,
528                completion_tokens: None,
529            },
530            Episode::ToolCall {
531                name: String::new(),
532                args: serde_json::Value::Null,
533                result: ToolResult::Ok {
534                    value: serde_json::Value::Null,
535                },
536            },
537            Episode::BusPublish {
538                subject: String::new(),
539            },
540            Episode::BusReceive {
541                subject: String::new(),
542            },
543            Episode::BusCausalLink {
544                subject: String::new(),
545                caused_by_run: String::new(),
546            },
547            Episode::Completed,
548            Episode::Failed {
549                error: String::new(),
550            },
551            Episode::SummaryCheckpoint {
552                input_message_count: 0,
553                summary_chars: 0,
554                latency_ms: 0,
555                tokens: 0,
556            },
557            Episode::Ops(serde_json::Value::Null),
558            Episode::RunAttributed {
559                tenant_label: String::new(),
560            },
561            Episode::RunOrigin {
562                parent_anchor: String::new(),
563            },
564            Episode::MemoryRecall {
565                query: String::new(),
566                k: 0,
567                returned_fact_ids: Vec::new(),
568            },
569        ]
570    }
571
572    /// The set of Rust `Episode` discriminants must equal the `kind` enum
573    /// published in the envelope schema. Compile-time exhaustiveness of
574    /// [`kind_discriminant`] plus this runtime equality close the drift loop
575    /// from the type side; `tests/schema_drift.rs` validates payload shapes.
576    #[test]
577    fn episode_discriminants_match_published_envelope_schema() {
578        let mut discriminants: Vec<&str> = one_sample_per_variant()
579            .iter()
580            .map(kind_discriminant)
581            .collect();
582        discriminants.sort_unstable();
583
584        let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
585            .join("../../docs/schemas/runlog/episode.schema.json");
586        let text = std::fs::read_to_string(&schema_path)
587            .unwrap_or_else(|err| panic!("read {}: {err}", schema_path.display()));
588        let schema: serde_json::Value =
589            serde_json::from_str(&text).expect("envelope schema parses");
590        let mut published: Vec<&str> = schema["properties"]["kind"]["enum"]
591            .as_array()
592            .expect("envelope schema declares a kind enum")
593            .iter()
594            .map(|value| value.as_str().expect("kind enum is strings"))
595            .collect();
596        published.sort_unstable();
597
598        assert_eq!(
599            discriminants, published,
600            "Episode variants have drifted from the published envelope kind enum",
601        );
602    }
603
604    /// Episodes serialised under klieo 0.6.x carry only `tokens` and
605    /// `latency_ms`. The four 0.7 fields must decode as `None` via
606    /// `#[serde(default)]`. Without that attribute, every persisted
607    /// 0.6 row breaks replay on upgrade.
608    #[test]
609    fn legacy_llm_call_json_deserialises_with_none_for_new_fields() {
610        let legacy = serde_json::json!({
611            "LlmCall": {
612                "tokens": 42,
613                "latency_ms": 17
614            }
615        });
616        let ep: Episode = serde_json::from_value(legacy).expect("legacy LlmCall decodes");
617        match ep {
618            Episode::LlmCall {
619                tokens,
620                latency_ms,
621                provider,
622                model,
623                prompt_tokens,
624                completion_tokens,
625            } => {
626                assert_eq!(tokens, 42);
627                assert_eq!(latency_ms, 17);
628                assert!(provider.is_none());
629                assert!(model.is_none());
630                assert!(prompt_tokens.is_none());
631                assert!(completion_tokens.is_none());
632            }
633            other => panic!("expected LlmCall, got {other:?}"),
634        }
635    }
636
637    /// [`Episode::llm_call`] returns the struct variant with all four
638    /// 0.7-added enrichment fields as `None`, preserving the legacy
639    /// emit shape for callers that don't have provider/model split.
640    #[test]
641    fn llm_call_ctor_leaves_enrichment_fields_none() {
642        match Episode::llm_call(42, 17) {
643            Episode::LlmCall {
644                tokens,
645                latency_ms,
646                provider,
647                model,
648                prompt_tokens,
649                completion_tokens,
650            } => {
651                assert_eq!(tokens, 42);
652                assert_eq!(latency_ms, 17);
653                assert!(provider.is_none());
654                assert!(model.is_none());
655                assert!(prompt_tokens.is_none());
656                assert!(completion_tokens.is_none());
657            }
658            other => panic!("expected LlmCall, got {other:?}"),
659        }
660    }
661
662    /// 0.7 emit sites with the full field set round-trip through serde
663    /// unchanged.
664    #[test]
665    fn enriched_llm_call_round_trips() {
666        let original = Episode::LlmCall {
667            tokens: 60,
668            latency_ms: 17,
669            provider: Some("ollama".into()),
670            model: Some("qwen2.5:14b".into()),
671            prompt_tokens: Some(40),
672            completion_tokens: Some(20),
673        };
674        let json = serde_json::to_value(&original).expect("serialises");
675        let back: Episode = serde_json::from_value(json).expect("deserialises");
676        match back {
677            Episode::LlmCall {
678                provider, model, ..
679            } => {
680                assert_eq!(provider.as_deref(), Some("ollama"));
681                assert_eq!(model.as_deref(), Some("qwen2.5:14b"));
682            }
683            other => panic!("expected LlmCall, got {other:?}"),
684        }
685    }
686
687    /// [`Episode::MemoryRecall`] round-trips through serde, and a legacy
688    /// record missing `k` / `returned_fact_ids` (recorded before either
689    /// field existed) decodes via `#[serde(default)]` rather than failing
690    /// replay.
691    #[test]
692    fn memory_recall_round_trips_and_defaults_legacy() {
693        let ep = Episode::MemoryRecall {
694            query: "q".into(),
695            k: 5,
696            returned_fact_ids: vec![FactId::new("fact_1")],
697        };
698        let json = serde_json::to_value(&ep).expect("serialises");
699        let back: Episode = serde_json::from_value(json).expect("deserialises");
700        match back {
701            Episode::MemoryRecall {
702                query,
703                k,
704                returned_fact_ids,
705            } => {
706                assert_eq!(query, "q");
707                assert_eq!(k, 5);
708                assert_eq!(returned_fact_ids, vec![FactId::new("fact_1")]);
709            }
710            other => panic!("expected MemoryRecall, got {other:?}"),
711        }
712
713        let legacy = serde_json::json!({ "MemoryRecall": { "query": "q" } });
714        let ep: Episode = serde_json::from_value(legacy).expect("legacy MemoryRecall decodes");
715        match ep {
716            Episode::MemoryRecall {
717                k,
718                returned_fact_ids,
719                ..
720            } => {
721                assert_eq!(k, 0);
722                assert!(returned_fact_ids.is_empty());
723            }
724            other => panic!("expected MemoryRecall, got {other:?}"),
725        }
726    }
727}