use std::sync::Arc;
use async_trait::async_trait;
use klieo_core::{
AuditRedactor, Episode, EpisodicMemory, Fact, FactId, LongTermMemory, MemoryError, RunId, Scope,
};
pub const MAX_RECALL_QUERY_CHARS: usize = 512;
#[non_exhaustive]
pub struct RecallRecorder {
inner: Arc<dyn LongTermMemory>,
episodic: Arc<dyn EpisodicMemory>,
run_id: RunId,
redactor: Arc<dyn AuditRedactor>,
max_query_chars: usize,
}
impl RecallRecorder {
pub fn new(
inner: Arc<dyn LongTermMemory>,
episodic: Arc<dyn EpisodicMemory>,
run_id: RunId,
redactor: Arc<dyn AuditRedactor>,
max_query_chars: usize,
) -> Self {
Self {
inner,
episodic,
run_id,
redactor,
max_query_chars,
}
}
}
const NON_STRING_REDACTION_MARKER: &str = "[redacted]";
fn redact_and_bound_query(redactor: &dyn AuditRedactor, query: &str, max_chars: usize) -> String {
let redacted = match redactor.redact(&serde_json::Value::String(query.to_string())) {
serde_json::Value::String(s) => s,
_ => NON_STRING_REDACTION_MARKER.to_string(),
};
if redacted.chars().count() <= max_chars {
redacted
} else {
redacted.chars().take(max_chars).collect()
}
}
#[async_trait]
impl LongTermMemory for RecallRecorder {
async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError> {
let facts = self.inner.recall(scope, query, k).await?;
let redacted_query =
redact_and_bound_query(self.redactor.as_ref(), query, self.max_query_chars);
let episode = Episode::MemoryRecall {
query: redacted_query,
k: k as u32,
returned_fact_ids: Vec::new(),
};
if let Err(err) = self.episodic.record(self.run_id, episode).await {
tracing::warn!(
%err,
run_id = %self.run_id,
"failed to record MemoryRecall episode; recall still returned",
);
}
Ok(facts)
}
async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError> {
self.inner.remember(scope, fact).await
}
async fn forget(&self, id: FactId) -> Result<(), MemoryError> {
self.inner.forget(id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use klieo_core::test_utils::{InMemoryEpisodic, InMemoryLongTerm};
const SECRET_TOKEN: &str = "AKIA1234567890";
struct TokenScrubbingRedactor;
impl AuditRedactor for TokenScrubbingRedactor {
fn redact(&self, value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => {
serde_json::Value::String(s.replace(SECRET_TOKEN, "[REDACTED]"))
}
other => other.clone(),
}
}
}
fn test_redactor() -> Arc<dyn AuditRedactor> {
Arc::new(TokenScrubbingRedactor)
}
struct NullRedactor;
impl AuditRedactor for NullRedactor {
fn redact(&self, _value: &serde_json::Value) -> serde_json::Value {
serde_json::Value::Null
}
}
struct FailingEpisodic;
#[async_trait]
impl EpisodicMemory for FailingEpisodic {
async fn record(&self, _run: RunId, _event: Episode) -> Result<(), MemoryError> {
Err(MemoryError::Store("boom".to_string()))
}
async fn replay(&self, _run: RunId) -> Result<Vec<Episode>, MemoryError> {
Ok(Vec::new())
}
async fn list_runs(
&self,
_filter: klieo_core::RunFilter,
) -> Result<Vec<klieo_core::RunSummary>, MemoryError> {
Ok(Vec::new())
}
}
fn memory_recalls(episodes: &[Episode]) -> Vec<&Episode> {
episodes
.iter()
.filter(|e| matches!(e, Episode::MemoryRecall { .. }))
.collect()
}
#[tokio::test]
async fn records_one_redacted_memory_recall_per_recall() {
let inner = Arc::new(InMemoryLongTerm::default());
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let rec = RecallRecorder::new(
inner.clone(),
episodic.clone(),
run,
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
rec.recall(Scope::Global, "secret token AKIA1234567890", 3)
.await
.unwrap();
let episodes = episodic.replay(run).await.unwrap();
let recalls = memory_recalls(&episodes);
assert_eq!(
recalls.len(),
1,
"exactly one MemoryRecall must be recorded"
);
match recalls[0] {
Episode::MemoryRecall {
query,
k,
returned_fact_ids,
} => {
assert!(
!query.contains(SECRET_TOKEN),
"query must be redacted before storage"
);
assert_eq!(*k, 3);
assert!(
returned_fact_ids.is_empty(),
"Fact carries no id at recall time"
);
}
other => panic!("expected MemoryRecall, got {other:?}"),
}
}
#[tokio::test]
async fn no_recall_no_episode() {
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let _rec = RecallRecorder::new(
Arc::new(InMemoryLongTerm::default()),
episodic.clone(),
run,
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
let episodes = episodic.replay(run).await.unwrap();
assert!(episodes.is_empty(), "no recall must record no episode");
}
async fn stored_query_for_recall(query: &str) -> String {
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let rec = RecallRecorder::new(
Arc::new(InMemoryLongTerm::default()),
episodic.clone(),
run,
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
rec.recall(Scope::Global, query, 1).await.unwrap();
let episodes = episodic.replay(run).await.unwrap();
match memory_recalls(&episodes)[0] {
Episode::MemoryRecall { query, .. } => query.clone(),
other => panic!("expected MemoryRecall, got {other:?}"),
}
}
#[tokio::test]
async fn query_is_length_bounded() {
let long_query = "q".repeat(MAX_RECALL_QUERY_CHARS + 100);
let stored = stored_query_for_recall(&long_query).await;
assert!(
stored.chars().count() <= MAX_RECALL_QUERY_CHARS,
"stored query must be bounded to MAX_RECALL_QUERY_CHARS"
);
}
#[tokio::test]
async fn multibyte_query_is_char_bounded_without_panic() {
let long_query = "🦀".repeat(MAX_RECALL_QUERY_CHARS + 100);
let stored = stored_query_for_recall(&long_query).await;
assert!(
stored.chars().count() <= MAX_RECALL_QUERY_CHARS,
"stored query must be bounded by Unicode scalar count, not bytes"
);
}
#[tokio::test]
async fn non_string_redaction_stores_marker_not_literal_null() {
let inner = Arc::new(InMemoryLongTerm::default());
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let rec = RecallRecorder::new(
inner,
episodic.clone(),
run,
Arc::new(NullRedactor),
MAX_RECALL_QUERY_CHARS,
);
rec.recall(Scope::Global, "anything", 1).await.unwrap();
let episodes = episodic.replay(run).await.unwrap();
match memory_recalls(&episodes)[0] {
Episode::MemoryRecall { query, .. } => {
assert_eq!(query, NON_STRING_REDACTION_MARKER);
assert_ne!(query, "null", "must not store the JSON rendering");
}
other => panic!("expected MemoryRecall, got {other:?}"),
}
}
#[tokio::test]
async fn recording_failure_does_not_fail_recall() {
let inner = Arc::new(InMemoryLongTerm::default());
inner
.remember(Scope::Global, Fact::new("the sky is blue"))
.await
.unwrap();
let rec = RecallRecorder::new(
inner,
Arc::new(FailingEpisodic),
RunId::new(),
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
let facts = rec
.recall(Scope::Global, "sky", 1)
.await
.expect("recall must succeed despite the recording failure");
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].text, "the sky is blue");
}
#[tokio::test]
async fn recall_returns_inner_facts_unchanged() {
let inner = Arc::new(InMemoryLongTerm::default());
inner
.remember(Scope::Global, Fact::new("the sky is blue"))
.await
.unwrap();
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let rec = RecallRecorder::new(
inner,
episodic,
run,
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
let facts = rec.recall(Scope::Global, "sky", 1).await.unwrap();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].text, "the sky is blue");
}
#[tokio::test]
async fn remember_and_forget_pass_through_to_inner() {
let inner = Arc::new(InMemoryLongTerm::default());
let episodic = Arc::new(InMemoryEpisodic::default());
let run = RunId::new();
let rec = RecallRecorder::new(
inner.clone(),
episodic.clone(),
run,
test_redactor(),
MAX_RECALL_QUERY_CHARS,
);
let id = rec
.remember(Scope::Global, Fact::new("alice likes tea"))
.await
.unwrap();
assert_eq!(
inner.recall(Scope::Global, "tea", 1).await.unwrap().len(),
1
);
rec.forget(id).await.unwrap();
assert!(inner
.recall(Scope::Global, "tea", 1)
.await
.unwrap()
.is_empty());
assert!(
episodic.replay(run).await.unwrap().is_empty(),
"remember/forget must not record any episode"
);
}
}