use crate::error::MemoryError;
use crate::ids::{FactId, RunId, ThreadId};
use crate::llm::Message;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum ToolResult {
Ok {
value: serde_json::Value,
},
Err {
message: String,
},
}
impl ToolResult {
pub fn ok(value: serde_json::Value) -> Self {
Self::Ok { value }
}
pub fn err(message: impl Into<String>) -> Self {
Self::Err {
message: message.into(),
}
}
}
#[cfg(test)]
mod tool_result_factories_tests {
use super::*;
#[test]
fn ok_factory_builds_ok_variant() {
let v = serde_json::json!({"hit": true});
let r = ToolResult::ok(v.clone());
match r {
ToolResult::Ok { value } => assert_eq!(value, v),
_ => panic!("expected ToolResult::Ok variant"),
}
}
#[test]
fn err_factory_builds_err_variant() {
let r = ToolResult::err("boom");
match r {
ToolResult::Err { message } => assert_eq!(message, "boom"),
_ => panic!("expected ToolResult::Err variant"),
}
}
}
#[async_trait]
pub trait ShortTermMemory: Send + Sync {
async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError>;
async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError>;
async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Scope {
Workspace(String),
Agent(String),
Global,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
pub text: String,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[async_trait]
pub trait LongTermMemory: Send + Sync {
async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError>;
async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError>;
async fn forget(&self, id: FactId) -> Result<(), MemoryError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Episode {
Started {
agent: String,
},
LlmCall {
tokens: u32,
latency_ms: u32,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
prompt_tokens: Option<u32>,
#[serde(default)]
completion_tokens: Option<u32>,
},
ToolCall {
name: String,
args: serde_json::Value,
result: ToolResult,
},
BusPublish {
subject: String,
},
BusReceive {
subject: String,
},
Completed,
Failed {
error: String,
},
SummaryCheckpoint {
input_message_count: u32,
summary_chars: u32,
latency_ms: u32,
tokens: u32,
},
Ops(serde_json::Value),
}
impl Episode {
pub fn llm_call(tokens: u32, latency_ms: u32) -> Self {
Episode::LlmCall {
tokens,
latency_ms,
provider: None,
model: None,
prompt_tokens: None,
completion_tokens: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RunFilter {
pub agent: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSummary {
pub run_id: RunId,
pub agent: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub episode_count: u32,
}
#[async_trait]
pub trait EpisodicMemory: Send + Sync {
async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError>;
async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError>;
async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError>;
}
#[derive(Clone)]
pub struct MemoryHandles {
pub short_term: std::sync::Arc<dyn ShortTermMemory>,
pub long_term: std::sync::Arc<dyn LongTermMemory>,
pub episodic: std::sync::Arc<dyn EpisodicMemory>,
}
impl MemoryHandles {
pub fn new(
short_term: std::sync::Arc<dyn ShortTermMemory>,
long_term: std::sync::Arc<dyn LongTermMemory>,
episodic: std::sync::Arc<dyn EpisodicMemory>,
) -> Self {
Self {
short_term,
long_term,
episodic,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
fn _assert_dyn_short(_: &dyn ShortTermMemory) {}
#[allow(dead_code)]
fn _assert_dyn_long(_: &dyn LongTermMemory) {}
#[allow(dead_code)]
fn _assert_dyn_episodic(_: &dyn EpisodicMemory) {}
#[test]
fn legacy_llm_call_json_deserialises_with_none_for_new_fields() {
let legacy = serde_json::json!({
"LlmCall": {
"tokens": 42,
"latency_ms": 17
}
});
let ep: Episode = serde_json::from_value(legacy).expect("legacy LlmCall decodes");
match ep {
Episode::LlmCall {
tokens,
latency_ms,
provider,
model,
prompt_tokens,
completion_tokens,
} => {
assert_eq!(tokens, 42);
assert_eq!(latency_ms, 17);
assert!(provider.is_none());
assert!(model.is_none());
assert!(prompt_tokens.is_none());
assert!(completion_tokens.is_none());
}
other => panic!("expected LlmCall, got {other:?}"),
}
}
#[test]
fn llm_call_ctor_leaves_enrichment_fields_none() {
match Episode::llm_call(42, 17) {
Episode::LlmCall {
tokens,
latency_ms,
provider,
model,
prompt_tokens,
completion_tokens,
} => {
assert_eq!(tokens, 42);
assert_eq!(latency_ms, 17);
assert!(provider.is_none());
assert!(model.is_none());
assert!(prompt_tokens.is_none());
assert!(completion_tokens.is_none());
}
other => panic!("expected LlmCall, got {other:?}"),
}
}
#[test]
fn enriched_llm_call_round_trips() {
let original = Episode::LlmCall {
tokens: 60,
latency_ms: 17,
provider: Some("ollama".into()),
model: Some("qwen2.5:14b".into()),
prompt_tokens: Some(40),
completion_tokens: Some(20),
};
let json = serde_json::to_value(&original).expect("serialises");
let back: Episode = serde_json::from_value(json).expect("deserialises");
match back {
Episode::LlmCall {
provider, model, ..
} => {
assert_eq!(provider.as_deref(), Some("ollama"));
assert_eq!(model.as_deref(), Some("qwen2.5:14b"));
}
other => panic!("expected LlmCall, got {other:?}"),
}
}
}