1use chrono::Utc;
7
8use crate::auth::TenantScope;
9use crate::memory::in_memory::InMemoryStore;
10use crate::memory::{Confidentiality, Memory, MemoryEntry, MemoryQuery, MemoryType};
11
12const DEFAULT_MAX_ENTRIES: usize = 256;
14
15pub const SNIPPET_CHARS: usize = 280;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RecallHit {
24 pub r#ref: String,
26 pub tool_name: String,
28 pub snippet: String,
30}
31
32pub struct ContextRecallStore {
34 inner: InMemoryStore,
35 scope: TenantScope,
36}
37
38impl Default for ContextRecallStore {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl ContextRecallStore {
45 pub fn new() -> Self {
47 Self {
48 inner: InMemoryStore::new().with_max_entries(DEFAULT_MAX_ENTRIES),
49 scope: TenantScope::default(),
50 }
51 }
52
53 pub async fn index(&self, tool_call_id: &str, tool_name: &str, content: &str) {
57 let entry = make_entry(tool_call_id, tool_name, content);
58 let _ = self.inner.store(&self.scope, entry).await;
59 }
60
61 pub async fn get(&self, tool_call_id: &str) -> Option<String> {
63 self.inner.get(tool_call_id).map(|e| e.content)
64 }
65
66 pub async fn recall(&self, query: &str, limit: usize) -> Vec<RecallHit> {
70 let q = MemoryQuery {
71 text: Some(query.to_string()),
72 limit,
73 reinforce: false,
74 ..Default::default()
75 };
76 let entries = self.inner.recall(&self.scope, q).await.unwrap_or_default();
77 entries
78 .into_iter()
79 .map(|e| RecallHit {
80 r#ref: e.id,
81 tool_name: e.tags.first().cloned().unwrap_or_default(),
82 snippet: e.content.chars().take(SNIPPET_CHARS).collect(),
83 })
84 .collect()
85 }
86}
87
88fn make_entry(id: &str, tool_name: &str, content: &str) -> MemoryEntry {
90 let now = Utc::now();
91 MemoryEntry {
92 id: id.to_string(),
93 agent: "context_recall".into(),
94 content: content.to_string(),
95 category: "tool_output".into(),
96 tags: vec![tool_name.to_string()],
97 created_at: now,
98 last_accessed: now,
99 access_count: 0,
100 importance: 5,
101 memory_type: MemoryType::Episodic,
102 keywords: Vec::new(),
103 summary: None,
104 strength: 1.0,
105 related_ids: Vec::new(),
106 source_ids: Vec::new(),
107 embedding: None,
108 confidentiality: Confidentiality::Public,
109 author_user_id: None,
110 author_tenant_id: None,
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[tokio::test]
119 async fn index_then_get_roundtrips_exact_content() {
120 let store = ContextRecallStore::new();
121 store
122 .index("tc_1", "bash", "the full untruncated output")
123 .await;
124 assert_eq!(
125 store.get("tc_1").await.as_deref(),
126 Some("the full untruncated output")
127 );
128 assert_eq!(store.get("nope").await, None);
129 }
130
131 #[tokio::test]
132 async fn recall_ranks_a_matching_output_above_noise_and_caps_snippet() {
133 let store = ContextRecallStore::new();
134 store
135 .index(
136 "tc_match",
137 "bash",
138 "cargo test failed: assertion error in parser module",
139 )
140 .await;
141 store
142 .index(
143 "tc_noise",
144 "read",
145 "the quick brown fox jumps over the lazy dog",
146 )
147 .await;
148
149 let hits = store.recall("test failure parser", 5).await;
150 assert!(!hits.is_empty(), "expected at least one hit");
151 assert_eq!(
152 hits[0].r#ref, "tc_match",
153 "the matching output must rank first"
154 );
155 assert_eq!(hits[0].tool_name, "bash");
156 assert!(hits[0].snippet.chars().count() <= SNIPPET_CHARS);
157 }
158
159 #[tokio::test]
160 async fn recall_on_empty_store_is_empty() {
161 let store = ContextRecallStore::new();
162 assert!(store.recall("anything", 5).await.is_empty());
163 }
164
165 #[tokio::test]
166 async fn recall_snippet_is_truncated_to_the_cap() {
167 let store = ContextRecallStore::new();
168 let long = "alpha ".repeat(200); store.index("tc_long", "bash", &long).await;
170 let hits = store.recall("alpha", 1).await;
171 assert_eq!(hits.len(), 1);
172 assert_eq!(
173 hits[0].snippet.chars().count(),
174 SNIPPET_CHARS,
175 "a long output's snippet must be capped at exactly SNIPPET_CHARS"
176 );
177 }
178
179 #[tokio::test]
180 async fn pruned_then_restored_by_marker_ref_roundtrips() {
181 use crate::agent::pruner::{SessionPruneConfig, prune_old_tool_results};
182 use crate::llm::types::{ContentBlock, Message, Role};
183
184 let store = ContextRecallStore::new();
186 let big = "RESTORE_ME ".repeat(500); store.index("tc_abc", "bash", &big).await;
188
189 let messages = vec![
195 Message::user("task"),
196 Message {
197 role: Role::User,
198 content: vec![ContentBlock::ToolResult {
199 tool_use_id: "tc_abc".into(),
200 content: big.clone(),
201 is_error: false,
202 }],
203 },
204 Message::user("r1"),
205 Message::assistant("r2"),
206 Message::user("r3"),
207 Message::assistant("r4"),
208 ];
209 let (pruned, stats) = prune_old_tool_results(&messages, &SessionPruneConfig::default());
210 assert!(stats.did_prune());
211 let marker: String = pruned
212 .iter()
213 .flat_map(|m| m.content.iter())
214 .filter_map(|b| match b {
215 ContentBlock::ToolResult { content, .. } => Some(content.clone()),
216 _ => None,
217 })
218 .collect();
219 assert!(
220 marker.contains("tc_abc"),
221 "the pruned marker must name the ref"
222 );
223 assert!(
224 marker.len() < big.len(),
225 "the result was actually shortened"
226 );
227
228 assert_eq!(
230 store.get("tc_abc").await.as_deref(),
231 Some(big.as_str()),
232 "the full content is restorable by the marker's ref"
233 );
234 }
235}