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
26/// Conversation buffer scoped to a single thread.
27///
28/// ```
29/// # tokio_test::block_on(async {
30/// use klieo_core::test_utils::InMemoryShortTerm;
31/// use klieo_core::{ShortTermMemory, Message, Role, ThreadId};
32///
33/// let m = InMemoryShortTerm::default();
34/// let thread = ThreadId::new("t1");
35/// m.append(thread.clone(), Message {
36/// role: Role::User, content: "hi".into(),
37/// tool_calls: vec![], tool_call_id: None,
38/// }).await.unwrap();
39/// let loaded = m.load(thread, 1024).await.unwrap();
40/// assert_eq!(loaded.len(), 1);
41/// # });
42/// ```
43#[async_trait]
44pub trait ShortTermMemory: Send + Sync {
45 /// Append a message to the thread's history.
46 async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError>;
47
48 /// Load up to `max_tokens` of the most-recent messages, oldest first.
49 /// Implementations approximate token counts (provider-specific).
50 async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError>;
51
52 /// Drop all messages for `thread`.
53 async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError>;
54}
55
56/// Namespacing for long-term memory facts.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub enum Scope {
59 /// Workspace-scoped (multi-agent shared).
60 Workspace(String),
61 /// Per-agent scoped.
62 Agent(String),
63 /// Process-global (use sparingly).
64 Global,
65}
66
67/// One stored fact in long-term memory.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Fact {
70 /// Plain-text body, embedded for retrieval.
71 pub text: String,
72 /// Caller-supplied metadata, opaque to the store.
73 #[serde(default)]
74 pub metadata: serde_json::Value,
75}
76
77/// Long-term semantic memory.
78///
79/// ```
80/// # tokio_test::block_on(async {
81/// use klieo_core::test_utils::InMemoryLongTerm;
82/// use klieo_core::{Fact, LongTermMemory, Scope};
83///
84/// let m = InMemoryLongTerm::default();
85/// let scope = Scope::Workspace("ws".into());
86/// m.remember(scope.clone(), Fact {
87/// text: "the sky is blue".into(),
88/// metadata: serde_json::Value::Null,
89/// }).await.unwrap();
90/// let hits = m.recall(scope, "sky", 1).await.unwrap();
91/// assert_eq!(hits.len(), 1);
92/// # });
93/// ```
94#[async_trait]
95pub trait LongTermMemory: Send + Sync {
96 /// Store a fact under `scope`. Returns a stable id.
97 async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError>;
98
99 /// Top-`k` semantic recall under `scope` for the supplied query.
100 async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError>;
101
102 /// Remove a stored fact.
103 async fn forget(&self, id: FactId) -> Result<(), MemoryError>;
104}
105
106/// One event in the episodic event stream of a single agent run.
107///
108/// Marked `#[non_exhaustive]` so additive variants (e.g.
109/// [`Self::SummaryCheckpoint`]) can be introduced without forcing a
110/// SemVer-major bump. Match arms in downstream crates must include a
111/// fallback `_ => …`.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[non_exhaustive]
114pub enum Episode {
115 /// Run started.
116 Started {
117 /// Agent name.
118 agent: String,
119 },
120 /// LLM call completed.
121 LlmCall {
122 /// Total tokens used.
123 tokens: u32,
124 /// Wall-clock latency.
125 latency_ms: u32,
126 },
127 /// Tool call completed.
128 ToolCall {
129 /// Tool name.
130 name: String,
131 /// JSON arguments.
132 args: serde_json::Value,
133 /// Tool outcome.
134 result: ToolResult,
135 },
136 /// Agent published a bus message.
137 BusPublish {
138 /// Subject.
139 subject: String,
140 },
141 /// Agent received a bus message.
142 BusReceive {
143 /// Subject.
144 subject: String,
145 },
146 /// Run completed successfully.
147 Completed,
148 /// Run failed.
149 Failed {
150 /// Error message.
151 error: String,
152 },
153 /// Summarizer checkpoint completed.
154 ///
155 /// Emitted by [`crate::summarize::summarize_history`] in lieu of
156 /// [`Self::LlmCall`] so the audit trail can distinguish summarizer
157 /// overhead from substantive agent reasoning. Downstream
158 /// observability (e.g. `klieo-runlog`) typically projects this as
159 /// a separate step kind so cost / latency attribution stays
160 /// faithful.
161 SummaryCheckpoint {
162 /// Number of older messages folded into the summary call.
163 input_message_count: u32,
164 /// Length of the resulting summary, in Unicode scalar values.
165 summary_chars: u32,
166 /// Wall-clock latency of the summarizer call.
167 latency_ms: u32,
168 /// Total tokens reported by the summarizer LLM (prompt +
169 /// completion).
170 tokens: u32,
171 },
172 /// Operational-layer event (klieo-ops). Body is an opaque
173 /// `serde_json::Value` to keep klieo-core free of an ops dependency.
174 /// klieo-ops provides typed serde conversion helpers via `OpsEvent`.
175 Ops(serde_json::Value),
176}
177
178/// Filter passed to `EpisodicMemory::list_runs`.
179#[derive(Debug, Clone, Default)]
180pub struct RunFilter {
181 /// Filter by agent name (substring match).
182 pub agent: Option<String>,
183 /// Inclusive lower bound on `started_at`.
184 pub since: Option<DateTime<Utc>>,
185 /// Inclusive upper bound on `started_at`.
186 pub until: Option<DateTime<Utc>>,
187 /// Maximum rows returned.
188 pub limit: Option<usize>,
189}
190
191/// Index-row summary returned by `list_runs`.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct RunSummary {
194 /// Run id.
195 pub run_id: RunId,
196 /// Agent name.
197 pub agent: String,
198 /// First-event timestamp.
199 pub started_at: DateTime<Utc>,
200 /// Last-event timestamp, if completed/failed.
201 pub finished_at: Option<DateTime<Utc>>,
202 /// Number of episodes.
203 pub episode_count: u32,
204}
205
206/// Append-only event log of agent runs.
207///
208/// ```
209/// # tokio_test::block_on(async {
210/// use klieo_core::test_utils::InMemoryEpisodic;
211/// use klieo_core::{Episode, EpisodicMemory, RunId};
212///
213/// let m = InMemoryEpisodic::default();
214/// let run = RunId::new();
215/// m.record(run, Episode::Started { agent: "a".into() }).await.unwrap();
216/// let events = m.replay(run).await.unwrap();
217/// assert_eq!(events.len(), 1);
218/// # });
219/// ```
220#[async_trait]
221pub trait EpisodicMemory: Send + Sync {
222 /// Record an episode for `run`.
223 async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError>;
224
225 /// Replay all episodes for `run` in order.
226 async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError>;
227
228 /// List run summaries matching `filter`.
229 async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError>;
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[allow(dead_code)]
237 fn _assert_dyn_short(_: &dyn ShortTermMemory) {}
238 #[allow(dead_code)]
239 fn _assert_dyn_long(_: &dyn LongTermMemory) {}
240 #[allow(dead_code)]
241 fn _assert_dyn_episodic(_: &dyn EpisodicMemory) {}
242}