lc_agents/
memory_extractor.rs1use async_trait::async_trait;
23use lc_core::json_parse::parse_llm_json;
24use lc_core::language_models::BaseChatModel;
25use lc_memory::{MemoryError, MemoryExtractor, MemoryItem};
26use lc_schema::Message;
27use std::sync::Arc;
28
29const DEFAULT_IMPORTANCE: f64 = 0.5;
31const MAX_EXTRACTIONS_PER_TURN: usize = 10;
33const KEY_MAX_CHARS: usize = 60;
35
36const SYSTEM_PROMPT: &str = "You are a memory extraction engine for a personal AI assistant. \
37From the conversation turn, extract only durable facts worth remembering across future sessions: \
38user preferences, identities, project context, goals, constraints, explicitly stated corrections. \
39Do NOT extract ephemeral task chatter, greetings, or information already obvious in the current reply. \
40Respond with a single JSON array and nothing else. Each element must be an object: \
41{\"key\": \"short_stable_snake_case_id\", \"text\": \"one self-contained fact\", \"importance\": 0.0-1.0}. \
42Higher importance means stable and broadly useful. If nothing is worth remembering, output [].";
43
44pub struct LlmMemoryExtractor<M: BaseChatModel + Send + Sync> {
46 model: Arc<M>,
47}
48
49impl<M: BaseChatModel + Send + Sync> LlmMemoryExtractor<M> {
50 pub fn new(model: Arc<M>) -> Self {
52 Self { model }
53 }
54
55 pub fn from_model(model: M) -> Self {
57 Self {
58 model: Arc::new(model),
59 }
60 }
61
62 fn user_prompt(namespace: &str, user_input: &str, assistant_output: &str) -> String {
63 format!(
64 "Memory namespace: {namespace}\n\nUser: {user_input}\n\nAssistant: {assistant_output}\n\n\
65 JSON array of durable facts:"
66 )
67 }
68
69 fn parse_items(raw: &str) -> Vec<MemoryItem> {
70 let values: Vec<serde_json::Value> = match parse_llm_json::<serde_json::Value>(raw) {
72 Ok(serde_json::Value::Array(v)) => v,
73 Ok(serde_json::Value::Object(map)) => map
74 .get("memories")
75 .or_else(|| map.get("items"))
76 .and_then(|v| v.as_array())
77 .cloned()
78 .unwrap_or_default(),
79 Ok(_) => Vec::new(),
80 Err(_) => Vec::new(),
81 };
82
83 let mut items = Vec::new();
84 for value in values.into_iter().take(MAX_EXTRACTIONS_PER_TURN) {
85 let Some(map) = value.as_object() else {
86 continue;
87 };
88 let Some(text) = map.get("text").and_then(|v| v.as_str()) else {
89 continue;
90 };
91 if text.trim().is_empty() {
92 continue;
93 }
94 let importance = map
95 .get("importance")
96 .and_then(|v| v.as_f64())
97 .unwrap_or(DEFAULT_IMPORTANCE);
98 let key = match map.get("key").and_then(|v| v.as_str()) {
99 Some(k) if !k.trim().is_empty() => k.trim().to_string(),
100 _ => derive_key(text),
101 };
102 items.push(MemoryItem::new(key, text.trim().to_string()).with_importance(importance));
103 }
104 items
105 }
106}
107
108#[async_trait]
109impl<M> MemoryExtractor for LlmMemoryExtractor<M>
110where
111 M: BaseChatModel + Send + Sync,
112{
113 async fn extract(
114 &self,
115 namespace: &str,
116 user_input: &str,
117 assistant_output: &str,
118 ) -> Result<Vec<MemoryItem>, MemoryError> {
119 if assistant_output.trim().is_empty() {
120 return Ok(Vec::new());
121 }
122 let messages = vec![Message::human(Self::user_prompt(
123 namespace,
124 user_input,
125 assistant_output,
126 ))];
127 let result = self
128 .model
129 .chat_with_system(SYSTEM_PROMPT.to_string(), messages)
130 .await
131 .map_err(|e| {
132 MemoryError::Other(format!("memory extraction model call failed: {e:?}"))
133 })?;
134 Ok(Self::parse_items(&result.content))
135 }
136}
137
138fn derive_key(text: &str) -> String {
141 let mut key = String::new();
142 let mut prev_sep = true;
143 for ch in text.chars() {
144 if ch.is_alphanumeric() {
145 key.extend(ch.to_lowercase());
146 prev_sep = false;
147 } else if !prev_sep {
148 key.push('_');
149 prev_sep = true;
150 }
151 if key.chars().count() >= KEY_MAX_CHARS {
152 break;
153 }
154 }
155 let key = key.trim_end_matches('_').to_string();
156 if key.is_empty() {
157 "memory".to_string()
158 } else {
159 key
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn parses_bare_array_fenced_with_prose() {
169 let raw = "Here are the facts:\n```json\n[\
170 {\"key\": \"lang\", \"text\": \"User prefers Rust.\", \"importance\": 0.9},\
171 {\"text\": \"Works on the agent framework.\"}\
172 ]\n```";
173 let items = LlmMemoryExtractor::<FakeModel>::parse_items(raw);
174 assert_eq!(items.len(), 2);
175 assert_eq!(items[0].key, "lang");
176 assert!((items[0].importance - 0.9).abs() < 1e-9);
177 assert!(items[1].key.starts_with("works_on_the_agent"));
179 assert!((items[1].importance - 0.5).abs() < 1e-9);
180 }
181
182 #[test]
183 fn parses_envelope_and_skips_bad_entries() {
184 let raw = r#"{"memories":[
185 {"key":"ok","text":"Team uses trunk-based development.","importance":0.7},
186 {"key":"no_text"},
187 "garbage",
188 {"key":"empty","text":" "}
189 ]}"#;
190 let items = LlmMemoryExtractor::<FakeModel>::parse_items(raw);
191 assert_eq!(items.len(), 1);
192 assert_eq!(items[0].key, "ok");
193 }
194
195 #[test]
196 fn non_json_reply_yields_no_items_not_error() {
197 assert!(
198 LlmMemoryExtractor::<FakeModel>::parse_items("I could not find any facts.").is_empty()
199 );
200 assert!(LlmMemoryExtractor::<FakeModel>::parse_items("[]").is_empty());
201 }
202
203 #[test]
204 fn derived_key_is_stable_and_bounded() {
205 let a = derive_key("User's favorite IDE is (NeoVim)!");
206 let b = derive_key("user's favorite IDE is (NeoVim)!!");
207 assert_eq!(a, b);
208 assert!(a.chars().count() <= KEY_MAX_CHARS);
209 assert_eq!(derive_key("!!! ???"), "memory");
210 }
211
212 #[tokio::test]
213 async fn extract_calls_model_and_converts_reply() {
214 let model = FakeModel::new(json_array());
215 let extractor = LlmMemoryExtractor::from_model(model);
216 let items = extractor
217 .extract("user-1", "I work on langchainrust", "Noted.")
218 .await
219 .unwrap();
220 assert_eq!(items.len(), 1);
221 assert_eq!(items[0].text, "User works on langchainrust.");
222 }
223
224 #[tokio::test]
225 async fn empty_output_short_circuits_without_model_call() {
226 let model = FakeModel::new(json_array());
227 let extractor = LlmMemoryExtractor::from_model(model);
228 let items = extractor.extract("ns", "hi", " ").await.unwrap();
229 assert!(items.is_empty());
230 }
231
232 fn json_array() -> String {
233 r#"[{"key":"project","text":"User works on langchainrust.","importance":0.8}]"#.into()
234 }
235
236 #[derive(Clone)]
238 struct FakeModel {
239 reply: String,
240 }
241
242 impl FakeModel {
243 fn new(reply: String) -> Self {
244 Self { reply }
245 }
246 }
247
248 #[async_trait]
249 impl lc_core::runnables::Runnable<Vec<Message>, lc_core::language_models::LLMResult> for FakeModel {
250 type Error = lc_core::LcelError;
251
252 async fn invoke(
253 &self,
254 _messages: Vec<Message>,
255 _config: Option<lc_core::runnables::RunnableConfig>,
256 ) -> Result<lc_core::language_models::LLMResult, Self::Error> {
257 Ok(lc_core::language_models::LLMResult {
258 content: self.reply.clone(),
259 ..Default::default()
260 })
261 }
262 }
263
264 impl
265 lc_core::language_models::BaseLanguageModel<
266 Vec<Message>,
267 lc_core::language_models::LLMResult,
268 > for FakeModel
269 {
270 fn model_name(&self) -> &str {
271 "fake-extractor"
272 }
273 fn get_num_tokens(&self, _text: &str) -> usize {
274 0
275 }
276 fn with_temperature(self, _temp: f32) -> Self {
277 self
278 }
279 fn with_max_tokens(self, _max: usize) -> Self {
280 self
281 }
282 }
283
284 #[async_trait]
285 impl BaseChatModel for FakeModel {
286 async fn chat(
287 &self,
288 _messages: Vec<Message>,
289 _config: Option<lc_core::runnables::RunnableConfig>,
290 ) -> Result<lc_core::language_models::LLMResult, lc_core::LcelError> {
291 Ok(lc_core::language_models::LLMResult {
292 content: self.reply.clone(),
293 ..Default::default()
294 })
295 }
296
297 async fn stream_chat(
298 &self,
299 _messages: Vec<Message>,
300 _config: Option<lc_core::runnables::RunnableConfig>,
301 ) -> Result<
302 std::pin::Pin<
303 Box<
304 dyn futures_util::Stream<
305 Item = Result<
306 lc_core::language_models::StreamChunk,
307 lc_core::LcelError,
308 >,
309 > + Send,
310 >,
311 >,
312 lc_core::LcelError,
313 > {
314 unimplemented!("extractor never streams")
315 }
316 }
317}