use crate::agent::messages::{Content, Message};
use crate::memory::clock::Clock;
use crate::memory::config::MemoryConfig;
use crate::memory::embedding::EmbeddingProvider;
use crate::memory::error::MemoryError;
use crate::memory::retrieval::recall;
use crate::memory::store::VectorStore;
use crate::memory::tokens::{budget_after_margin, estimate_tokens};
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct AssembledContext {
pub messages: Vec<Message>,
pub used_tokens: usize,
pub notices: Vec<String>,
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub async fn assemble_selective(
store: &dyn VectorStore,
embedder: &dyn EmbeddingProvider,
clock: &dyn Clock,
cfg: &MemoryConfig,
system: &str,
profile: &str,
current_turn: &Message,
scope: &str,
) -> Result<AssembledContext, MemoryError> {
let cpt = cfg.chars_per_token;
let budget = budget_after_margin(
cfg.context_budget_tokens,
cfg.response_headroom_tokens,
cfg.safety_margin_ratio,
);
let sys_t = estimate_tokens(system, cpt);
let prof_t = estimate_tokens(profile, cpt);
let sep_t = estimate_tokens(PREAMBLE_SEP, cpt);
let sys_prof_sep = if !system.is_empty() && !profile.is_empty() {
sep_t
} else {
0
};
if sys_t + prof_t + sys_prof_sep > budget {
return Err(MemoryError::BudgetUnsatisfiable);
}
let space = budget - sys_t - prof_t - sys_prof_sep;
let mut turn_text = extract_turn_text(current_turn);
let turn_t = estimate_tokens(&turn_text, cpt);
let mut notices = Vec::new();
let recall_space;
if turn_t > space {
if cfg.oversized_turn_policy.eq_ignore_ascii_case("truncate") {
turn_text = truncate_to_tokens(&turn_text, space, cpt);
if turn_text.is_empty() {
return Err(MemoryError::BudgetUnsatisfiable);
}
notices.push("current turn truncated to fit context budget".to_string());
recall_space = 0;
} else {
return Err(MemoryError::BudgetUnsatisfiable);
}
} else {
recall_space = space - turn_t;
}
let ranked = if recall_space > 0 {
recall(store, embedder, clock, cfg, &turn_text, recall_space, scope).await?
} else {
vec![]
};
let mut first_content_added = !system.is_empty() || !profile.is_empty();
let mut kept_texts: Vec<String> = Vec::new();
let mut recall_tokens_used: usize = 0;
let mut recall_space_left = recall_space;
for rm in &ranked {
let recall_t = estimate_tokens(&rm.memory.text, cpt);
let sep_cost = if first_content_added { sep_t } else { 0 };
let t = recall_t + sep_cost;
if t <= recall_space_left {
kept_texts.push(rm.memory.text.clone());
recall_tokens_used += t;
recall_space_left -= t;
first_content_added = true;
}
}
let mut parts: Vec<&str> = Vec::new();
if !system.is_empty() {
parts.push(system);
}
if !profile.is_empty() {
parts.push(profile);
}
for t in &kept_texts {
parts.push(t.as_str());
}
let preamble = parts.join(PREAMBLE_SEP);
let mut messages: Vec<Message> = Vec::new();
if !preamble.is_empty() {
messages.push(Message::user(&preamble));
}
messages.push(Message::user(&turn_text));
let final_turn_t = estimate_tokens(&turn_text, cpt);
let used_tokens = sys_t + prof_t + sys_prof_sep + recall_tokens_used + final_turn_t;
if used_tokens > budget {
return Err(MemoryError::BudgetUnsatisfiable);
}
Ok(AssembledContext {
messages,
used_tokens,
notices,
})
}
const PREAMBLE_SEP: &str = "\n\n";
fn extract_turn_text(msg: &Message) -> String {
msg.content
.iter()
.filter_map(|c| {
if let Content::Text { text } = c {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn truncate_to_tokens(text: &str, max_tokens: usize, cpt: f64) -> String {
if max_tokens == 0 {
return String::new();
}
let cpt = if cpt > 0.0 && cpt.is_finite() {
cpt
} else {
1.0
};
let max_chars = (max_tokens as f64 * cpt).floor() as usize;
text.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::messages::{Content, Role};
use crate::memory::clock::FixedClock;
use crate::memory::config::MemoryConfig;
use crate::memory::embedding::EmbeddingProvider;
use crate::memory::error::{EmbeddingError, MemoryError};
use crate::memory::store::{Memory, SqliteVectorStore};
use crate::memory::MemoryKind;
use crate::system::database::EncryptedSqliteMemory;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
fn bow(text: &str, dim: usize) -> Vec<f32> {
let mut v = vec![0f32; dim];
for w in text.to_lowercase().split_whitespace() {
let h = w
.bytes()
.fold(0usize, |a, b| a.wrapping_mul(31).wrapping_add(b as usize))
% dim;
v[h] += 1.0;
}
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if n > 0.0 {
for x in &mut v {
*x /= n;
}
}
v
}
struct FakeEmbedder {
dim: usize,
model: String,
}
struct CountingEmbedder {
call_count: Arc<Mutex<usize>>,
dim: usize,
model: String,
}
#[async_trait]
impl EmbeddingProvider for CountingEmbedder {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
*self.call_count.lock().unwrap() += 1;
Ok(texts.iter().map(|_| vec![0.0f32; self.dim]).collect())
}
fn model_id(&self) -> &str {
&self.model
}
fn dim(&self) -> usize {
self.dim
}
fn query_prefix(&self) -> &str {
""
}
fn document_prefix(&self) -> &str {
""
}
}
#[async_trait]
impl EmbeddingProvider for FakeEmbedder {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Ok(texts.iter().map(|t| bow(t, self.dim)).collect())
}
fn model_id(&self) -> &str {
&self.model
}
fn dim(&self) -> usize {
self.dim
}
fn query_prefix(&self) -> &str {
""
}
fn document_prefix(&self) -> &str {
""
}
}
fn make_test_store() -> (tempfile::NamedTempFile, SqliteVectorStore) {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mem = EncryptedSqliteMemory::new(tmp.path().to_path_buf(), "pw".into()).unwrap();
let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key()).unwrap();
(tmp, store)
}
async fn insert_mem(
store: &SqliteVectorStore,
id: &str,
text: &str,
embedder: &FakeEmbedder,
created_at: i64,
last_accessed_at: i64,
salience: f64,
) {
let emb = bow(text, embedder.dim);
let m = Memory {
id: id.into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: text.into(),
embedding: emb,
model_id: embedder.model_id().into(),
dim: embedder.dim(),
created_at,
salience,
access_count: 0,
last_accessed_at,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&m).await.unwrap();
}
fn preamble_text(ctx: &AssembledContext) -> String {
ctx.messages
.first()
.and_then(|m| {
m.content.iter().find_map(|c| {
if let Content::Text { text } = c {
Some(text.clone())
} else {
None
}
})
})
.unwrap_or_default()
}
fn last_turn_text(ctx: &AssembledContext) -> String {
ctx.messages
.last()
.and_then(|m| {
m.content.iter().find_map(|c| {
if let Content::Text { text } = c {
Some(text.clone())
} else {
None
}
})
})
.unwrap_or_default()
}
#[tokio::test]
async fn test_context_never_exceeds_budget_and_keeps_profile_and_turn() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 200,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
top_k: 20,
..MemoryConfig::default()
};
for i in 0..20 {
insert_mem(
&store,
&format!("m{i}"),
"context budget token assembly recall memory relevant window",
&emb,
1000,
1000,
0.5,
)
.await;
}
let turn = Message::user("What is the context budget?");
let result = assemble_selective(
&store,
&emb,
&clock,
&cfg,
"system prompt",
"user profile preferences",
&turn,
"root",
)
.await
.unwrap();
assert!(
result.used_tokens <= 200,
"used_tokens={} must not exceed budget=200 (SC-16)",
result.used_tokens
);
let preamble = preamble_text(&result);
assert!(
preamble.contains("user profile preferences"),
"preamble must include the profile (SC-16)"
);
assert_eq!(
result.messages.last().unwrap().role,
Role::User,
"final message must be a user message (current turn, SC-16)"
);
let lt = last_turn_text(&result);
assert!(
lt.contains("context budget"),
"final message must contain the current turn text (SC-16)"
);
}
#[tokio::test]
async fn test_higher_score_recalls_included_rest_excluded() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 20,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
top_k: 20,
..MemoryConfig::default()
};
insert_mem(
&store,
"rel1",
"context budget assembly relevant",
&emb,
1000,
1000,
0.5,
)
.await;
insert_mem(
&store,
"rel2",
"budget context memory relevant",
&emb,
1000,
1000,
0.5,
)
.await;
for i in 0..8 {
insert_mem(
&store,
&format!("dist{i}"),
&format!("unrelated cat dog fish bird {i}"),
&emb,
900,
900,
0.2,
)
.await;
}
let turn = Message::user("context budget");
let result =
assemble_selective(&store, &emb, &clock, &cfg, "sys", "profile", &turn, "root")
.await
.unwrap();
assert!(
result.used_tokens <= 20,
"used_tokens={} must not exceed budget=20 (SC-17)",
result.used_tokens
);
let preamble = preamble_text(&result);
assert!(
preamble.contains("profile"),
"profile must always be present (SC-17)"
);
assert!(
preamble.contains("assembly relevant") || preamble.contains("memory relevant"),
"at least one relevant recall must be in the preamble (SC-17)"
);
assert!(
!preamble.contains("unrelated cat dog fish"),
"distractors must be excluded when budget is exhausted (SC-17)"
);
let lt = last_turn_text(&result);
assert!(
lt.contains("context budget"),
"current turn must be present (SC-17)"
);
}
#[tokio::test]
async fn test_bounded_growth_independent_of_history_size() {
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let budget = 300usize;
let cfg = MemoryConfig {
context_budget_tokens: budget,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
top_k: 12,
..MemoryConfig::default()
};
let turn = Message::user("context budget assembly");
let sizes = [10usize, 100, 1000];
for &n in &sizes {
let (_tmp, store) = make_test_store();
for i in 0..n {
insert_mem(
&store,
&format!("m{i}"),
"context budget token recall memory assembly",
&emb,
1000,
1000,
0.5,
)
.await;
}
let result = assemble_selective(
&store, &emb, &clock, &cfg, "system", "profile", &turn, "root",
)
.await
.unwrap();
assert!(
result.used_tokens <= budget,
"used_tokens={} must be <= budget={} with {} memories (SC-18)",
result.used_tokens,
budget,
n
);
}
}
#[tokio::test]
async fn test_oversized_turn_truncates_with_notice() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 30,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
oversized_turn_policy: "truncate".into(),
..MemoryConfig::default()
};
let system = "sys";
let profile = "profile";
let long_turn: String = "abcdefghij ".repeat(32);
assert!(
long_turn.len() > 100,
"turn must be large enough to trigger truncation"
);
let turn = Message::user(&long_turn);
let result = assemble_selective(&store, &emb, &clock, &cfg, system, profile, &turn, "root")
.await
.unwrap();
assert!(
result.used_tokens <= 30,
"used_tokens={} must not exceed budget=30 even with oversized turn (SC-35)",
result.used_tokens
);
assert!(
!result.notices.is_empty(),
"a truncation notice must be present (SC-35)"
);
let preamble = preamble_text(&result);
assert!(
preamble.contains(profile),
"profile must be preserved after truncation (SC-35)"
);
let lt = last_turn_text(&result);
assert!(
lt.len() < long_turn.len(),
"turn must be shorter after truncation: got {} chars, expected < {} (SC-35)",
lt.len(),
long_turn.len()
);
}
#[tokio::test]
async fn test_empty_truncation_returns_budget_unsatisfiable() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 2,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
chars_per_token: 1.0,
oversized_turn_policy: "truncate".into(),
..MemoryConfig::default()
};
let turn = Message::user("hello");
let result = assemble_selective(&store, &emb, &clock, &cfg, "s", "p", &turn, "root").await;
assert!(
matches!(result, Err(MemoryError::BudgetUnsatisfiable)),
"M2: empty truncated turn must return BudgetUnsatisfiable (D-17), got: {result:?}"
);
}
#[tokio::test]
async fn test_system_plus_profile_unsatisfiable_errors() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 1,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
..MemoryConfig::default()
};
let system = "This is a system prompt that is definitely longer than one token here";
let profile = "User profile data that also adds tokens";
let turn = Message::user("hello");
let result =
assemble_selective(&store, &emb, &clock, &cfg, system, profile, &turn, "root").await;
assert!(
matches!(result, Err(MemoryError::BudgetUnsatisfiable)),
"must return BudgetUnsatisfiable when system+profile don't fit (SC-35), got: {result:?}"
);
}
#[tokio::test]
async fn test_separator_tokens_counted_in_used_tokens() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 5,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
chars_per_token: 1.0,
..MemoryConfig::default()
};
let turn = Message::user("c");
let result = assemble_selective(&store, &emb, &clock, &cfg, "a", "b", &turn, "root")
.await
.unwrap();
assert_eq!(
result.used_tokens, 5,
"F1: used_tokens must include separator tokens; \
expected 5 (1+2+1+1), got {}",
result.used_tokens
);
assert!(
result.used_tokens <= 5,
"F1: used_tokens must not exceed budget=5"
);
}
#[tokio::test]
async fn test_recall_skipped_when_recall_space_is_zero() {
let (_tmp, store) = make_test_store();
let call_count = Arc::new(Mutex::new(0usize));
let emb = CountingEmbedder {
call_count: Arc::clone(&call_count),
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 30,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
oversized_turn_policy: "truncate".into(),
..MemoryConfig::default()
};
let long_turn: String = "abcdefghij ".repeat(32); let turn = Message::user(&long_turn);
let result =
assemble_selective(&store, &emb, &clock, &cfg, "sys", "profile", &turn, "root")
.await
.unwrap();
assert!(
!result.notices.is_empty(),
"F2 pre-condition: turn must have been truncated"
);
let count = *call_count.lock().unwrap();
assert_eq!(
count,
0,
"F2: recall must not be called (embed count must be 0) when recall_space == 0; got {count}"
);
}
#[tokio::test]
async fn test_oversized_turn_policy_case_insensitive() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 30,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
oversized_turn_policy: "Truncate".into(), ..MemoryConfig::default()
};
let long_turn: String = "abcdefghij ".repeat(32); let turn = Message::user(&long_turn);
let result =
assemble_selective(&store, &emb, &clock, &cfg, "sys", "profile", &turn, "root").await;
assert!(
result.is_ok(),
"G5(a): 'Truncate' (title-case) policy must trigger truncation not error; \
got: {result:?}"
);
let assembled = result.unwrap();
assert!(
!assembled.notices.is_empty(),
"G5(a): a truncation notice must have fired for 'Truncate' policy"
);
}
#[tokio::test]
async fn test_budget_guard_does_not_fire_on_valid_assembly() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig::default();
let turn = Message::user("hello");
let result =
assemble_selective(&store, &emb, &clock, &cfg, "sys", "prof", &turn, "root").await;
assert!(
result.is_ok(),
"F4: release-safe budget guard must not fire on a valid assembly; got: {result:?}"
);
}
#[tokio::test]
async fn test_empty_system_and_profile_with_recalls_no_phantom_separator() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
context_budget_tokens: 50,
response_headroom_tokens: 0,
safety_margin_ratio: 0.0,
chars_per_token: 1.0,
top_k: 10,
..MemoryConfig::default()
};
insert_mem(&store, "r1", "alpha", &emb, 1000, 1000, 0.5).await;
insert_mem(&store, "r2", "beta", &emb, 1000, 1000, 0.5).await;
let turn = Message::user("t");
let result = assemble_selective(&store, &emb, &clock, &cfg, "", "", &turn, "root")
.await
.unwrap();
assert!(
result.used_tokens <= 50,
"H1/H3: used_tokens={} must not exceed budget=50",
result.used_tokens
);
let preamble = preamble_text(&result);
let turn_txt = last_turn_text(&result);
let preamble_t_estimated = result
.used_tokens
.saturating_sub(estimate_tokens(&turn_txt, 1.0));
let preamble_t_actual = estimate_tokens(&preamble, 1.0);
assert_eq!(
preamble_t_estimated,
preamble_t_actual,
"H1/H3: estimated preamble tokens ({preamble_t_estimated}) must match \
actual preamble token count ({preamble_t_actual}); \
before fix the phantom leading separator inflates by {}",
estimate_tokens("\n\n", 1.0)
);
}
}