Skip to main content

lc_agents/
memory_extractor.rs

1// lc-agents/src/memory_extractor.rs
2//! B4 (v0.22.4): LLM-backed [`lc_memory::MemoryExtractor`].
3//!
4//! [`LlmMemoryExtractor`] asks any [`lc_core::language_models::BaseChatModel`] to
5//! distill a completed turn into durable facts as a strict JSON array, then converts
6//! the reply into [`lc_memory::MemoryItem`]s for the two-tier semantic memory (see
7//! `lc_memory::semantic`).
8//!
9//! Design notes:
10//! - **Parsing is tolerant, extraction is lossy-safe.** Fenced code blocks and prose
11//!   around the JSON are stripped via [`lc_core::json_parse::parse_llm_json`]; both a bare array and an
12//!   object envelope (`{"memories": [...]}`) are accepted; malformed individual
13//!   entries are skipped, never fatal to the turn.
14//! - **The model chooses importance.** The prompt requests a `[0, 1]` float; missing
15//!   values default to 0.5, out-of-range values are clamped by the store.
16//! - **Keys are stable.** A missing key is derived from the fact text itself
17//!   (normalized, truncated), so the same fact extracted twice updates one entry
18//!   instead of duplicating.
19//! - **No network in tests.** The extractor is generic over the model; the module
20//!   tests use a fixed-reply fake.
21
22use 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
29/// Default importance assigned when the model omits the field.
30const DEFAULT_IMPORTANCE: f64 = 0.5;
31/// Maximum number of memories accepted from one turn (guards a runaway reply).
32const MAX_EXTRACTIONS_PER_TURN: usize = 10;
33/// Truncation length for derived keys.
34const 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
44/// `lc_memory::MemoryExtractor` backed by any chat model.
45pub struct LlmMemoryExtractor<M: BaseChatModel + Send + Sync> {
46    model: Arc<M>,
47}
48
49impl<M: BaseChatModel + Send + Sync> LlmMemoryExtractor<M> {
50    /// Wraps an `Arc`-shared model.
51    pub fn new(model: Arc<M>) -> Self {
52        Self { model }
53    }
54
55    /// Wraps an owned model.
56    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        // Accept a bare array first, then an object envelope carrying the array.
71        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
138/// Derives a stable key from fact text: lowercase, non-alphanumeric runs → `_`,
139/// truncated to [`KEY_MAX_CHARS`] (chars, not bytes).
140fn 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        // Missing key → derived from text; missing importance → 0.5.
178        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    /// Minimal fixed-reply chat model (only `chat` is exercised by the extractor).
237    #[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}