use crate::ids::{FactId, ThreadId};
use crate::llm::{Message, Role};
use crate::memory::{Fact, LongTermMemory, Scope, ShortTermMemory};
pub struct Scopes {
pub primary: Scope,
pub other: Scope,
}
impl Scopes {
pub fn agent(primary: impl Into<String>, other: impl Into<String>) -> Self {
Self {
primary: Scope::Agent(primary.into()),
other: Scope::Agent(other.into()),
}
}
}
pub enum ExpectedOrdering {
Recency,
Relevance {
nearer: String,
farther: String,
query: String,
},
Unspecified,
}
const ORDERING_TOKEN: &str = "klieo-conformance-ordering";
const ISOLATION_TOKEN: &str = "klieo-conformance-isolation";
const K_BOUND_TOKEN: &str = "klieo-conformance-kbound";
const FORGET_TOKEN: &str = "klieo-conformance-forget";
const K_BOUND_STORED: usize = 3;
const K_BOUND_REQUESTED: usize = 2;
const RECALL_K: usize = 10;
pub async fn long_term_memory(
store: &dyn LongTermMemory,
scopes: &Scopes,
ordering: ExpectedOrdering,
) {
long_term_empty_store_recalls_nothing(store, scopes).await;
long_term_scopes_are_isolated(store, scopes).await;
long_term_recall_returns_at_most_k(store, scopes).await;
long_term_forget_removes_the_fact(store, scopes).await;
long_term_recall_ordering_matches_declaration(store, scopes, ordering).await;
}
pub async fn long_term_empty_store_recalls_nothing(store: &dyn LongTermMemory, scopes: &Scopes) {
for scope in [&scopes.primary, &scopes.other] {
let found = recall(store, scope, "klieo-conformance-precondition-probe").await;
assert!(
found.is_empty(),
"precondition failed: scope must be empty at entry, found {} fact(s)",
found.len()
);
}
}
pub async fn long_term_scopes_are_isolated(store: &dyn LongTermMemory, scopes: &Scopes) {
let text = format!("{ISOLATION_TOKEN} probe");
remember(store, &scopes.primary, &text).await;
let in_primary = recall(store, &scopes.primary, ISOLATION_TOKEN).await;
assert!(
contains_text(&in_primary, &text),
"a fact stored under the primary scope was not recalled from it"
);
let in_other = recall(store, &scopes.other, ISOLATION_TOKEN).await;
assert!(
!contains_text(&in_other, &text),
"a fact stored under one scope leaked into another"
);
}
pub async fn long_term_recall_returns_at_most_k(store: &dyn LongTermMemory, scopes: &Scopes) {
for i in 0..K_BOUND_STORED {
remember(
store,
&scopes.primary,
&format!("{K_BOUND_TOKEN} entry {i}"),
)
.await;
}
let found = store
.recall(scopes.primary.clone(), K_BOUND_TOKEN, K_BOUND_REQUESTED)
.await
.expect("recall must not error");
assert!(
found.len() <= K_BOUND_REQUESTED,
"recall returned {} facts for k={K_BOUND_REQUESTED}",
found.len()
);
}
pub async fn long_term_forget_removes_the_fact(store: &dyn LongTermMemory, scopes: &Scopes) {
let text = format!("{FORGET_TOKEN} probe");
let id = remember(store, &scopes.primary, &text).await;
assert!(
contains_text(&recall(store, &scopes.primary, FORGET_TOKEN).await, &text),
"a stored fact was not recalled before forgetting it"
);
store
.forget(id.clone())
.await
.expect("forget must not error");
assert!(
!contains_text(&recall(store, &scopes.primary, FORGET_TOKEN).await, &text),
"a forgotten fact was still recalled"
);
store
.forget(id)
.await
.expect("forgetting an id that is no longer present must not error");
}
pub async fn long_term_recall_ordering_matches_declaration(
store: &dyn LongTermMemory,
scopes: &Scopes,
ordering: ExpectedOrdering,
) {
match ordering {
ExpectedOrdering::Unspecified => {}
ExpectedOrdering::Recency => {
let earlier = format!("{ORDERING_TOKEN} earlier");
let later = format!("{ORDERING_TOKEN} later");
remember(store, &scopes.primary, &earlier).await;
remember(store, &scopes.primary, &later).await;
assert_ranks_above(
&recall(store, &scopes.primary, ORDERING_TOKEN).await,
&later,
&earlier,
"declared Recency, but the older fact ranked first",
);
}
ExpectedOrdering::Relevance {
nearer,
farther,
query,
} => {
remember(store, &scopes.primary, &farther).await;
remember(store, &scopes.primary, &nearer).await;
assert_ranks_above(
&recall(store, &scopes.primary, &query).await,
&nearer,
&farther,
"declared Relevance, but the less relevant fact ranked first",
);
}
}
}
async fn remember(store: &dyn LongTermMemory, scope: &Scope, text: &str) -> FactId {
store
.remember(scope.clone(), Fact::new(text))
.await
.expect("remember must not error")
}
async fn recall(store: &dyn LongTermMemory, scope: &Scope, query: &str) -> Vec<Fact> {
store
.recall(scope.clone(), query, RECALL_K)
.await
.expect("recall must not error")
}
fn contains_text(facts: &[Fact], text: &str) -> bool {
facts.iter().any(|f| f.text == text)
}
fn position_of(facts: &[Fact], text: &str) -> Option<usize> {
facts.iter().position(|f| f.text == text)
}
fn assert_ranks_above(facts: &[Fact], above: &str, below: &str, message: &str) {
let above_at = position_of(facts, above)
.unwrap_or_else(|| panic!("{message}: the expected-first fact was not recalled at all"));
if let Some(below_at) = position_of(facts, below) {
assert!(above_at < below_at, "{message}");
}
}
const MESSAGE_COUNT: usize = 10;
const MESSAGE_BODY_CHARS: usize = 200;
const GENEROUS_TOKEN_BUDGET: usize = 8_000;
const TIGHT_TOKEN_BUDGET: usize = 100;
pub async fn short_term_memory(store: &dyn ShortTermMemory) {
short_term_load_honours_max_tokens(store).await;
short_term_budget_is_encoding_independent(store).await;
}
const ASCII_CELL: &str = "aaaa";
const MULTIBYTE_CELL: &str = "日本語だ";
const ENCODING_PROBE_MESSAGES: usize = 6;
const ENCODING_PROBE_BUDGET: usize = 6;
pub async fn short_term_budget_is_encoding_independent(store: &dyn ShortTermMemory) {
let ascii = ThreadId::new("klieo-conformance-encoding-ascii");
let multibyte = ThreadId::new("klieo-conformance-encoding-multibyte");
assert_eq!(
ASCII_CELL.chars().count(),
MULTIBYTE_CELL.chars().count(),
"fixture bug: the two probe strings must be the same character length"
);
for _ in 0..ENCODING_PROBE_MESSAGES {
for (thread, cell) in [(&ascii, ASCII_CELL), (&multibyte, MULTIBYTE_CELL)] {
store
.append(thread.clone(), text_message(cell))
.await
.expect("append must not error");
}
}
let ascii_kept = load(store, &ascii, ENCODING_PROBE_BUDGET).await.len();
let multibyte_kept = load(store, &multibyte, ENCODING_PROBE_BUDGET).await.len();
for thread in [ascii, multibyte] {
store.clear(thread).await.expect("clear must not error");
}
assert_eq!(
ascii_kept, multibyte_kept,
"the same amount of text was charged differently by encoding: kept \
{ascii_kept} ASCII messages but {multibyte_kept} multibyte ones at a \
budget of {ENCODING_PROBE_BUDGET}. Count characters, not UTF-8 bytes"
);
}
fn text_message(content: &str) -> Message {
Message {
role: Role::User,
content: content.to_string(),
tool_calls: vec![],
tool_call_id: None,
}
}
pub async fn short_term_load_honours_max_tokens(store: &dyn ShortTermMemory) {
let thread = ThreadId::new("klieo-conformance-max-tokens");
for i in 0..MESSAGE_COUNT {
store
.append(thread.clone(), padded_message(i))
.await
.expect("append must not error");
}
let generous = load(store, &thread, GENEROUS_TOKEN_BUDGET).await;
assert_eq!(
generous.len(),
MESSAGE_COUNT,
"a generous budget must return the whole history"
);
let tight = load(store, &thread, TIGHT_TOKEN_BUDGET).await;
assert!(
tight.len() < generous.len(),
"load ignored max_tokens: {} messages returned for a budget of {TIGHT_TOKEN_BUDGET} \
tokens, same as for {GENEROUS_TOKEN_BUDGET}",
tight.len()
);
store.clear(thread).await.expect("clear must not error");
}
async fn load(store: &dyn ShortTermMemory, thread: &ThreadId, max_tokens: usize) -> Vec<Message> {
store
.load(thread.clone(), max_tokens)
.await
.expect("load must not error")
}
fn padded_message(index: usize) -> Message {
Message {
role: Role::User,
content: format!("message {index} {}", "x".repeat(MESSAGE_BODY_CHARS)),
tool_calls: vec![],
tool_call_id: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::MemoryError;
use crate::test_utils::{InMemoryLongTerm, InMemoryShortTerm};
use async_trait::async_trait;
use tokio::sync::Mutex;
fn scopes() -> Scopes {
Scopes::agent("conformance-primary", "conformance-other")
}
#[tokio::test]
async fn in_memory_long_term_satisfies_conformance() {
long_term_memory(
&InMemoryLongTerm::default(),
&scopes(),
ExpectedOrdering::Recency,
)
.await;
}
#[tokio::test]
async fn in_memory_short_term_satisfies_conformance() {
short_term_memory(&InMemoryShortTerm::default()).await;
}
#[derive(Default)]
struct OldestFirstLongTerm {
facts: Mutex<Vec<(FactId, Scope, Fact)>>,
}
#[async_trait]
impl LongTermMemory for OldestFirstLongTerm {
async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError> {
let mut facts = self.facts.lock().await;
let id = FactId::new(format!("oldest-first-{}", facts.len()));
facts.push((id.clone(), scope, fact));
Ok(id)
}
async fn recall(
&self,
scope: Scope,
query: &str,
k: usize,
) -> Result<Vec<Fact>, MemoryError> {
let q = query.to_lowercase();
Ok(self
.facts
.lock()
.await
.iter()
.filter(|(_, s, _)| *s == scope)
.filter(|(_, _, f)| f.text.to_lowercase().contains(&q))
.take(k)
.map(|(_, _, f)| f.clone())
.collect())
}
async fn forget(&self, id: FactId) -> Result<(), MemoryError> {
self.facts.lock().await.retain(|(i, _, _)| i != &id);
Ok(())
}
}
#[tokio::test]
#[should_panic(expected = "declared Recency")]
async fn ordering_property_rejects_a_store_that_returns_oldest_first() {
long_term_recall_ordering_matches_declaration(
&OldestFirstLongTerm::default(),
&scopes(),
ExpectedOrdering::Recency,
)
.await;
}
#[derive(Default)]
struct UnboundedShortTerm {
messages: Mutex<Vec<Message>>,
}
#[async_trait]
impl ShortTermMemory for UnboundedShortTerm {
async fn append(&self, _thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
self.messages.lock().await.push(msg);
Ok(())
}
async fn load(
&self,
_thread: ThreadId,
_max_tokens: usize,
) -> Result<Vec<Message>, MemoryError> {
Ok(self.messages.lock().await.clone())
}
async fn clear(&self, _thread: ThreadId) -> Result<(), MemoryError> {
self.messages.lock().await.clear();
Ok(())
}
}
#[tokio::test]
#[should_panic(expected = "load ignored max_tokens")]
async fn budget_property_rejects_a_store_that_ignores_max_tokens() {
short_term_load_honours_max_tokens(&UnboundedShortTerm::default()).await;
}
}
#[cfg(test)]
mod backlog_regression_tests {
#[allow(unused_imports)]
mod reachable_paths {
use crate::llm::LlmError;
use crate::tool::ToolError;
}
#[tokio::test]
async fn fake_llm_step_error_returns_the_chosen_variant() {
use crate::error::LlmError;
use crate::llm::{ChatRequest, LlmClient};
use crate::test_utils::{FakeLlmClient, FakeLlmStep};
let llm =
FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Error(LlmError::RateLimit {
retry_after_secs: 42,
})]);
match llm.complete(ChatRequest::new(vec![])).await {
Err(LlmError::RateLimit {
retry_after_secs: 42,
}) => {}
other => panic!("expected the scripted RateLimit, got {other:?}"),
}
}
}