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 /// Run completed successfully.
241 Completed,
242 /// Run failed.
243 Failed {
244 /// Error message.
245 error: String,
246 },
247 /// Summarizer checkpoint completed.
248 ///
249 /// Emitted by [`crate::summarize::summarize_history`] in lieu of
250 /// [`Self::LlmCall`] so the audit trail can distinguish summarizer
251 /// overhead from substantive agent reasoning. Downstream
252 /// observability (e.g. `klieo-runlog`) typically projects this as
253 /// a separate step kind so cost / latency attribution stays
254 /// faithful.
255 SummaryCheckpoint {
256 /// Number of older messages folded into the summary call.
257 input_message_count: u32,
258 /// Length of the resulting summary, in Unicode scalar values.
259 summary_chars: u32,
260 /// Wall-clock latency of the summarizer call.
261 latency_ms: u32,
262 /// Total tokens reported by the summarizer LLM (prompt +
263 /// completion).
264 tokens: u32,
265 },
266 /// Operational-layer event (klieo-ops). Body is an opaque
267 /// `serde_json::Value` to keep klieo-core free of an ops dependency.
268 /// klieo-ops provides typed serde conversion helpers via `OpsEvent`.
269 Ops(serde_json::Value),
270 /// Non-PII tenant attribution stamped at run entry when an external
271 /// caller drives the run.
272 ///
273 /// `tenant_label` is a derived identifier (e.g. truncated SHA-256
274 /// of the caller's `sub`) — never the raw principal, which lives
275 /// only in server-side tracing/authorization. Emitted at most once
276 /// per run, adjacent to [`Self::Started`], so the audit trail can
277 /// attribute each run to its driving tenant without admitting PII
278 /// into agent memory or LLM-visible context.
279 RunAttributed {
280 /// Derived non-PII attribution label for the driving caller.
281 tenant_label: String,
282 },
283 /// Cross-hop provenance origin stamped at run entry when an
284 /// authenticated external caller supplies a parent-chain anchor.
285 ///
286 /// `parent_anchor` is the caller's own provenance chain-entry id (or
287 /// its run's episodic-root hash) — recorded **verbatim** so the value
288 /// equals the caller's identifier and downstream tooling can stitch
289 /// klieo→klieo lineage across deployments. It is a **caller-asserted,
290 /// unverified** claim (klieo does not own or validate the caller's
291 /// chain); it is co-recorded with [`Self::RunAttributed`] so the
292 /// claim is attributable to the authenticated principal that made it.
293 /// Emitted at most once per run, adjacent to [`Self::Started`]; never
294 /// admitted into agent memory or LLM-visible context.
295 RunOrigin {
296 /// Verbatim caller-supplied cross-hop provenance anchor.
297 parent_anchor: String,
298 },
299}
300
301impl Episode {
302 /// Construct an [`Episode::LlmCall`] with the legacy two-field shape
303 /// (`tokens` + `latency_ms`), leaving the 0.7-added
304 /// `provider`/`model`/`prompt_tokens`/`completion_tokens` fields as
305 /// `None`.
306 ///
307 /// Use this when the emit site does not have the enriched fields to
308 /// hand — e.g. test fixtures, providers that only report total
309 /// tokens, or call sites being migrated incrementally. For full
310 /// 0.7 emit semantics, construct the struct variant directly.
311 ///
312 /// ```
313 /// use klieo_core::Episode;
314 /// let ep = Episode::llm_call(42, 17);
315 /// match ep {
316 /// Episode::LlmCall { tokens, latency_ms, provider, .. } => {
317 /// assert_eq!(tokens, 42);
318 /// assert_eq!(latency_ms, 17);
319 /// assert!(provider.is_none());
320 /// }
321 /// _ => unreachable!(),
322 /// }
323 /// ```
324 pub fn llm_call(tokens: u32, latency_ms: u32) -> Self {
325 Episode::LlmCall {
326 tokens,
327 latency_ms,
328 provider: None,
329 model: None,
330 prompt_tokens: None,
331 completion_tokens: None,
332 }
333 }
334}
335
336/// Filter passed to `EpisodicMemory::list_runs`.
337#[derive(Debug, Clone, Default)]
338pub struct RunFilter {
339 /// Filter by agent name (substring match).
340 pub agent: Option<String>,
341 /// Inclusive lower bound on `started_at`.
342 pub since: Option<DateTime<Utc>>,
343 /// Inclusive upper bound on `started_at`.
344 pub until: Option<DateTime<Utc>>,
345 /// Maximum rows returned.
346 pub limit: Option<usize>,
347}
348
349/// Index-row summary returned by `list_runs`.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct RunSummary {
352 /// Run id.
353 pub run_id: RunId,
354 /// Agent name.
355 pub agent: String,
356 /// First-event timestamp.
357 pub started_at: DateTime<Utc>,
358 /// Last-event timestamp, if completed/failed.
359 pub finished_at: Option<DateTime<Utc>>,
360 /// Number of episodes.
361 pub episode_count: u32,
362}
363
364/// Append-only event log of agent runs.
365///
366/// ```
367/// # tokio_test::block_on(async {
368/// use klieo_core::test_utils::InMemoryEpisodic;
369/// use klieo_core::{Episode, EpisodicMemory, RunId};
370///
371/// let m = InMemoryEpisodic::default();
372/// let run = RunId::new();
373/// m.record(run, Episode::Started { agent: "a".into() }).await.unwrap();
374/// let events = m.replay(run).await.unwrap();
375/// assert_eq!(events.len(), 1);
376/// # });
377/// ```
378#[async_trait]
379pub trait EpisodicMemory: Send + Sync {
380 /// Record an episode for `run`.
381 async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError>;
382
383 /// Replay all episodes for `run` in order.
384 async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError>;
385
386 /// List run summaries matching `filter`.
387 async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError>;
388}
389
390/// Resolved trio of memory handles ready to drop into an
391/// [`crate::agent::AgentContext`] or an `App`.
392///
393/// Impl crates (`klieo-memory-sqlite`, `klieo-memory-neo4j`,
394/// `klieo-memory-qdrant`) provide `From` conversions where they cover
395/// the full trio. Crates that only carry a subset (Neo4j covers
396/// short + episodic; Qdrant covers long) compose via the `App` builder's
397/// per-trait setters rather than a direct `From`.
398#[derive(Clone)]
399pub struct MemoryHandles {
400 /// Short-term conversation memory.
401 pub short_term: std::sync::Arc<dyn ShortTermMemory>,
402 /// Long-term semantic memory.
403 pub long_term: std::sync::Arc<dyn LongTermMemory>,
404 /// Episodic event log.
405 pub episodic: std::sync::Arc<dyn EpisodicMemory>,
406}
407
408impl MemoryHandles {
409 /// Build directly from three already-`Arc`-wrapped handles.
410 /// Most callers go through an impl crate's `From` instead.
411 pub fn new(
412 short_term: std::sync::Arc<dyn ShortTermMemory>,
413 long_term: std::sync::Arc<dyn LongTermMemory>,
414 episodic: std::sync::Arc<dyn EpisodicMemory>,
415 ) -> Self {
416 Self {
417 short_term,
418 long_term,
419 episodic,
420 }
421 }
422}
423
424#[cfg(test)]
425mod fact_ctor_tests {
426 use super::*;
427
428 #[test]
429 fn fact_new_defaults_metadata_null() {
430 let f = Fact::new("alice likes tea");
431 assert_eq!(f.text, "alice likes tea");
432 assert_eq!(f.metadata, serde_json::Value::Null);
433 let f2 = Fact::new("x").with_metadata(serde_json::json!({"k":"v"}));
434 assert_eq!(f2.metadata, serde_json::json!({"k":"v"}));
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[allow(dead_code)]
443 fn _assert_dyn_short(_: &dyn ShortTermMemory) {}
444 #[allow(dead_code)]
445 fn _assert_dyn_long(_: &dyn LongTermMemory) {}
446 #[allow(dead_code)]
447 fn _assert_dyn_episodic(_: &dyn EpisodicMemory) {}
448
449 /// Maps each variant to its published snake_case wire discriminant.
450 ///
451 /// The exhaustive match is the drift guard: a new `Episode` variant
452 /// fails to compile here until its discriminant is added, which is the
453 /// signal to also publish a payload schema under `docs/schemas/runlog/`
454 /// and a fixture in `tests/schema_drift.rs`. `#[non_exhaustive]` does not
455 /// force a wildcard inside the defining crate, so this stays exhaustive.
456 fn kind_discriminant(episode: &Episode) -> &'static str {
457 match episode {
458 Episode::Started { .. } => "started",
459 Episode::LlmCall { .. } => "llm_call",
460 Episode::ToolCall { .. } => "tool_call",
461 Episode::BusPublish { .. } => "bus_publish",
462 Episode::BusReceive { .. } => "bus_receive",
463 Episode::Completed => "completed",
464 Episode::Failed { .. } => "failed",
465 Episode::SummaryCheckpoint { .. } => "summary_checkpoint",
466 Episode::Ops(_) => "ops",
467 Episode::RunAttributed { .. } => "run_attributed",
468 Episode::RunOrigin { .. } => "run_origin",
469 }
470 }
471
472 fn one_sample_per_variant() -> Vec<Episode> {
473 vec![
474 Episode::Started {
475 agent: String::new(),
476 },
477 Episode::LlmCall {
478 tokens: 0,
479 latency_ms: 0,
480 provider: None,
481 model: None,
482 prompt_tokens: None,
483 completion_tokens: None,
484 },
485 Episode::ToolCall {
486 name: String::new(),
487 args: serde_json::Value::Null,
488 result: ToolResult::Ok {
489 value: serde_json::Value::Null,
490 },
491 },
492 Episode::BusPublish {
493 subject: String::new(),
494 },
495 Episode::BusReceive {
496 subject: String::new(),
497 },
498 Episode::Completed,
499 Episode::Failed {
500 error: String::new(),
501 },
502 Episode::SummaryCheckpoint {
503 input_message_count: 0,
504 summary_chars: 0,
505 latency_ms: 0,
506 tokens: 0,
507 },
508 Episode::Ops(serde_json::Value::Null),
509 Episode::RunAttributed {
510 tenant_label: String::new(),
511 },
512 Episode::RunOrigin {
513 parent_anchor: String::new(),
514 },
515 ]
516 }
517
518 /// The set of Rust `Episode` discriminants must equal the `kind` enum
519 /// published in the envelope schema. Compile-time exhaustiveness of
520 /// [`kind_discriminant`] plus this runtime equality close the drift loop
521 /// from the type side; `tests/schema_drift.rs` validates payload shapes.
522 #[test]
523 fn episode_discriminants_match_published_envelope_schema() {
524 let mut discriminants: Vec<&str> = one_sample_per_variant()
525 .iter()
526 .map(kind_discriminant)
527 .collect();
528 discriminants.sort_unstable();
529
530 let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
531 .join("../../docs/schemas/runlog/episode.schema.json");
532 let text = std::fs::read_to_string(&schema_path)
533 .unwrap_or_else(|err| panic!("read {}: {err}", schema_path.display()));
534 let schema: serde_json::Value =
535 serde_json::from_str(&text).expect("envelope schema parses");
536 let mut published: Vec<&str> = schema["properties"]["kind"]["enum"]
537 .as_array()
538 .expect("envelope schema declares a kind enum")
539 .iter()
540 .map(|value| value.as_str().expect("kind enum is strings"))
541 .collect();
542 published.sort_unstable();
543
544 assert_eq!(
545 discriminants, published,
546 "Episode variants have drifted from the published envelope kind enum",
547 );
548 }
549
550 /// Episodes serialised under klieo 0.6.x carry only `tokens` and
551 /// `latency_ms`. The four 0.7 fields must decode as `None` via
552 /// `#[serde(default)]`. Without that attribute, every persisted
553 /// 0.6 row breaks replay on upgrade.
554 #[test]
555 fn legacy_llm_call_json_deserialises_with_none_for_new_fields() {
556 let legacy = serde_json::json!({
557 "LlmCall": {
558 "tokens": 42,
559 "latency_ms": 17
560 }
561 });
562 let ep: Episode = serde_json::from_value(legacy).expect("legacy LlmCall decodes");
563 match ep {
564 Episode::LlmCall {
565 tokens,
566 latency_ms,
567 provider,
568 model,
569 prompt_tokens,
570 completion_tokens,
571 } => {
572 assert_eq!(tokens, 42);
573 assert_eq!(latency_ms, 17);
574 assert!(provider.is_none());
575 assert!(model.is_none());
576 assert!(prompt_tokens.is_none());
577 assert!(completion_tokens.is_none());
578 }
579 other => panic!("expected LlmCall, got {other:?}"),
580 }
581 }
582
583 /// [`Episode::llm_call`] returns the struct variant with all four
584 /// 0.7-added enrichment fields as `None`, preserving the legacy
585 /// emit shape for callers that don't have provider/model split.
586 #[test]
587 fn llm_call_ctor_leaves_enrichment_fields_none() {
588 match Episode::llm_call(42, 17) {
589 Episode::LlmCall {
590 tokens,
591 latency_ms,
592 provider,
593 model,
594 prompt_tokens,
595 completion_tokens,
596 } => {
597 assert_eq!(tokens, 42);
598 assert_eq!(latency_ms, 17);
599 assert!(provider.is_none());
600 assert!(model.is_none());
601 assert!(prompt_tokens.is_none());
602 assert!(completion_tokens.is_none());
603 }
604 other => panic!("expected LlmCall, got {other:?}"),
605 }
606 }
607
608 /// 0.7 emit sites with the full field set round-trip through serde
609 /// unchanged.
610 #[test]
611 fn enriched_llm_call_round_trips() {
612 let original = Episode::LlmCall {
613 tokens: 60,
614 latency_ms: 17,
615 provider: Some("ollama".into()),
616 model: Some("qwen2.5:14b".into()),
617 prompt_tokens: Some(40),
618 completion_tokens: Some(20),
619 };
620 let json = serde_json::to_value(&original).expect("serialises");
621 let back: Episode = serde_json::from_value(json).expect("deserialises");
622 match back {
623 Episode::LlmCall {
624 provider, model, ..
625 } => {
626 assert_eq!(provider.as_deref(), Some("ollama"));
627 assert_eq!(model.as_deref(), Some("qwen2.5:14b"));
628 }
629 other => panic!("expected LlmCall, got {other:?}"),
630 }
631 }
632}