gemini_memory_rs/runtime/
turn_extractor.rs

1//! Memory as a first-class [`TurnExtractor`].
2//!
3//! This is the whole runtime integration. The Live runtime already has an
4//! out-of-band extraction pipeline that accumulates transcripts, segments them
5//! by turn boundary, fires extractors under a trigger policy, and **promotes
6//! their fields into governed `State`**. Memory is exactly that shape, so it
7//! rides that pipeline instead of running a second one beside it.
8//!
9//! The promotion step is what makes memory useful to an application rather than
10//! merely present. A remembered fact projected into a `State` slot is read by
11//! everything the platform already has:
12//!
13//! - `phase.needs(&["user:diet"])` — satisfied from memory, so a returning user
14//!   is not asked again for something they already said last week;
15//! - `phase.requires(&["user:diet"])` — a hard gate a memory can open;
16//! - `Flow` guards, `done(captured(["user:diet"]))`;
17//! - `P::show_state(&["user:diet"])` — the value in the phase instruction;
18//! - watchers and repair, which read the same keys.
19//!
20//! Each turn the extractor does three things: ingest the finalized utterance,
21//! prepare the next turn's retrieval snapshot, and project what memory knows
22//! into slots.
23
24use async_trait::async_trait;
25use serde_json::{Map, Value, json};
26use std::sync::Arc;
27
28use gemini_adk_rs::live::extractor::{ExtractionTrigger, FieldPromotion, TurnExtractor};
29use gemini_adk_rs::live::transcript::TranscriptTurn;
30use gemini_adk_rs::llm::LlmError;
31use gemini_adk_rs::state::State;
32
33use crate::core::{CanonicalPredicate, TurnId};
34use crate::engine::MemorySession;
35use crate::ingestion::LedgerOutcome;
36
37/// The `State` key the pipeline stores this extractor's raw summary under.
38pub const MEMORY_EXTRACTOR_NAME: &str = "memory";
39
40/// A mapping from a memory predicate to the governed `State` slot it fills.
41#[derive(Debug, Clone, PartialEq)]
42pub struct MemorySlot {
43    /// The canonical predicate to look for, e.g. `dietary_identity`.
44    pub predicate: CanonicalPredicate,
45    /// The `State` key to fill, e.g. `user:diet`.
46    pub state_key: String,
47}
48
49impl MemorySlot {
50    /// Map `predicate` onto `state_key`.
51    ///
52    /// The key must use the platform's `scope:key` convention — `user:diet`,
53    /// not `user.diet`. The gates themselves do not care: `needs`, `requires`
54    /// and `Guard::is_set` route through `State::contains`, which treats the key
55    /// as an opaque string. What the colon buys is composition with the prefix
56    /// scopes, so `state.user().get::<String>("diet")` finds the slot. A dotted
57    /// key would read back `None` there — silently, for a developer doing
58    /// exactly what the platform documentation says.
59    ///
60    /// That is a programming error knowable at construction, so this **panics**
61    /// on a malformed key rather than documenting the trap and handing it over.
62    /// Use [`try_new`](Self::try_new) where the key is not a literal.
63    ///
64    /// The predicate is *not* checked against the corpus: a slot naming a fact
65    /// the user has not stated yet is the normal case, and for a brand-new user
66    /// every slot is.
67    ///
68    /// ```
69    /// # use gemini_memory_rs::runtime::MemorySlot;
70    /// MemorySlot::new("dietary_identity", "user:diet");            // fine
71    /// assert!(MemorySlot::try_new("dietary_identity", "user.diet").is_err());
72    /// assert!(MemorySlot::try_new("dietary_identity", "derived:diet").is_err());
73    /// ```
74    pub fn new(predicate: impl AsRef<str>, state_key: impl Into<String>) -> Self {
75        Self::try_new(predicate, state_key).unwrap_or_else(|e| panic!("{e}"))
76    }
77
78    /// [`new`](Self::new) without the panic, for keys built at runtime.
79    ///
80    /// Use this when the slot comes from configuration or user input; use
81    /// `new` for the literals in application code, where a bad key is a
82    /// programming error worth failing on immediately.
83    pub fn try_new(
84        predicate: impl AsRef<str>,
85        state_key: impl Into<String>,
86    ) -> Result<Self, MemorySlotError> {
87        let predicate = predicate.as_ref();
88        let state_key = state_key.into();
89        if predicate.trim().is_empty() {
90            return Err(MemorySlotError::EmptyPredicate);
91        }
92        validate_state_key(&state_key)?;
93        Ok(Self {
94            predicate: CanonicalPredicate::new(predicate),
95            state_key,
96        })
97    }
98}
99
100/// The `State` scopes a slot key may be written under.
101///
102/// `derived:` is deliberately absent: its fallback lives only in `get`/`with`,
103/// and `contains` — which backs `needs`, `requires` and `Guard::is_set` — has
104/// none, so a `derived:` slot would be invisible to exactly the gates memory
105/// exists to satisfy.
106const WRITABLE_SCOPES: [&str; 6] = ["user", "app", "session", "turn", "bg", "temp"];
107
108/// Why a [`MemorySlot`] could not be built.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum MemorySlotError {
111    /// A slot with no predicate matches nothing.
112    #[error("a memory slot needs a predicate to look for")]
113    EmptyPredicate,
114    /// The key carries no `scope:` prefix at all.
115    #[error(
116        "memory slot key `{0}` has no scope prefix — use `scope:key` (e.g. `user:diet`). \
117         A key without one is readable via `state.get(..)` but never composes with \
118         `state.user()`, so a developer following the platform's prefix conventions \
119         reads `None` and is given no hint why."
120    )]
121    MissingScope(String),
122    /// The prefix is not one of the platform's scopes.
123    #[error(
124        "memory slot key `{key}` uses unknown scope `{scope}:` — expected one of {expected}. \
125         (`derived:` is excluded on purpose: `State::contains` has no `derived` fallback, \
126         so such a slot is invisible to `needs`, `requires` and `Guard::is_set`.)"
127    )]
128    UnknownScope {
129        /// The offending key.
130        key: String,
131        /// The scope that was not recognised.
132        scope: String,
133        /// The scopes that are.
134        expected: String,
135    },
136    /// A `scope:` with nothing after it.
137    #[error("memory slot key `{0}` has a scope but no name after it")]
138    EmptyName(String),
139}
140
141/// Enforce the `scope:key` convention the gates depend on.
142fn validate_state_key(key: &str) -> Result<(), MemorySlotError> {
143    let Some((scope, name)) = key.split_once(':') else {
144        return Err(MemorySlotError::MissingScope(key.to_string()));
145    };
146    if !WRITABLE_SCOPES.contains(&scope) {
147        return Err(MemorySlotError::UnknownScope {
148            key: key.to_string(),
149            scope: scope.to_string(),
150            expected: WRITABLE_SCOPES
151                .iter()
152                .map(|s| format!("`{s}:`"))
153                .collect::<Vec<_>>()
154                .join(", "),
155        });
156    }
157    if name.trim().is_empty() {
158        return Err(MemorySlotError::EmptyName(key.to_string()));
159    }
160    Ok(())
161}
162
163/// Drives memory from the runtime's turn-boundary extraction pipeline.
164pub struct MemoryTurnExtractor {
165    session: Arc<MemorySession>,
166    slots: Vec<MemorySlot>,
167    promotions: Vec<FieldPromotion>,
168    min_words: usize,
169    window: usize,
170}
171
172impl MemoryTurnExtractor {
173    /// Ingest from `session`, skipping turns shorter than three words.
174    ///
175    /// The floor exists because "ok", "yeah" and "mm hmm" are most of a voice
176    /// conversation and none of them are evidence.
177    pub fn new(session: Arc<MemorySession>) -> Self {
178        Self {
179            session,
180            slots: Vec::new(),
181            promotions: Vec::new(),
182            min_words: 3,
183            window: 3,
184        }
185    }
186
187    /// Project memory facts into governed `State` slots.
188    ///
189    /// Each entry maps a canonical predicate to the state key a phase or flow
190    /// reads. A slot filled from memory satisfies `needs`/`requires` exactly as
191    /// one filled by the user would, which is the point: the application does
192    /// not have to know whether it learned something now or last month.
193    ///
194    /// Slots are promoted with `KeepKnown`, so anything the current
195    /// conversation established wins over what memory recalls.
196    pub fn slots(mut self, slots: impl IntoIterator<Item = MemorySlot>) -> Self {
197        self.slots = slots.into_iter().collect();
198        self.promotions = self
199            .slots
200            .iter()
201            .map(|slot| FieldPromotion {
202                field: slot.state_key.clone(),
203                state_key: slot.state_key.clone(),
204                merge: gemini_adk_rs::live::extractor::MergePolicy::KeepKnown,
205                accept: None,
206            })
207            .collect();
208        self
209    }
210
211    /// Require at least `words` in the user's utterance before extracting.
212    pub fn min_words(mut self, words: usize) -> Self {
213        self.min_words = words;
214        self
215    }
216
217    /// How many recent turns the extractor asks the pipeline for.
218    pub fn window(mut self, turns: usize) -> Self {
219        self.window = turns;
220        self
221    }
222
223    /// The slot values memory can currently fill.
224    fn slot_values(&self) -> Map<String, Value> {
225        let mut out = Map::new();
226        if self.slots.is_empty() {
227            return out;
228        }
229        for (predicate, value) in self.session.known_values() {
230            if let Some(slot) = self.slots.iter().find(|s| s.predicate == predicate) {
231                out.entry(slot.state_key.clone()).or_insert(value);
232            }
233        }
234        out
235    }
236}
237
238#[async_trait]
239impl TurnExtractor for MemoryTurnExtractor {
240    fn name(&self) -> &str {
241        MEMORY_EXTRACTOR_NAME
242    }
243
244    fn window_size(&self) -> usize {
245        self.window
246    }
247
248    fn trigger(&self) -> ExtractionTrigger {
249        ExtractionTrigger::EveryTurn
250    }
251
252    fn promotion_rules(&self) -> &[FieldPromotion] {
253        &self.promotions
254    }
255
256    fn should_extract(&self, window: &[TranscriptTurn]) -> bool {
257        window
258            .last()
259            .is_some_and(|turn| turn.user.split_whitespace().count() >= self.min_words)
260    }
261
262    async fn extract(&self, window: &[TranscriptTurn]) -> Result<Value, LlmError> {
263        let Some(turn) = window.last() else {
264            return Ok(json!({}));
265        };
266        let turn_id = TurnId(u64::from(turn.turn_number));
267
268        let outcomes = self
269            .session
270            .observe_final_transcript(turn_id, &turn.user)
271            .await
272            .map_err(|e| LlmError::Other(e.to_string()))?;
273
274        // Turn completion drives the reconciliation cadence, so the pipeline
275        // firing this extractor is enough to keep the session ticking.
276        let scheduled = self
277            .session
278            .on_turn_complete(turn_id)
279            .await
280            .map_err(|e| LlmError::Other(e.to_string()))?;
281
282        // Prepare the *next* turn's context now, while the model is speaking.
283        // This is the "prepare asynchronously, consume synchronously" rule: by
284        // the time a `recall_context` call arrives, the answer is already sat
285        // in the session.
286        //
287        // Prepare *then* begin, in that order. `begin_turn` promotes whatever
288        // `prepare` wrote last, so beginning first published the speculation
289        // from the previous round and left this one sitting unread in
290        // `prepared` for a whole turn: the snapshot serving turn N was built
291        // from the transcript of turn N−2. That is the difference between
292        // someone saying "I'm meeting Rhea for dinner" and being understood on
293        // the next sentence, or on the one after that.
294        let next = TurnId(turn_id.0 + 1);
295        let _ = self.session.prepare(next, &turn.user).await;
296        self.session.begin_turn(next);
297
298        let mut payload = self.slot_values();
299        payload.insert("turn".into(), json!(turn.turn_number));
300        payload.insert(
301            "created".into(),
302            json!(
303                outcomes
304                    .iter()
305                    .filter(|o| matches!(o, LedgerOutcome::Created(_)))
306                    .count()
307            ),
308        );
309        payload.insert(
310            "reinforced".into(),
311            json!(
312                outcomes
313                    .iter()
314                    .filter(|o| matches!(o, LedgerOutcome::Reinforced { .. }))
315                    .count()
316            ),
317        );
318        payload.insert(
319            "rejected".into(),
320            json!(
321                outcomes
322                    .iter()
323                    .filter(|o| matches!(o, LedgerOutcome::Rejected(_)))
324                    .count()
325            ),
326        );
327        payload.insert(
328            "session_facts".into(),
329            json!(self.session.ledger().usable_candidates().len()),
330        );
331        payload.insert(
332            "scheduled".into(),
333            json!(
334                scheduled
335                    .iter()
336                    .map(|w| format!("{w:?}"))
337                    .collect::<Vec<_>>()
338            ),
339        );
340        Ok(Value::Object(payload))
341    }
342
343    async fn extract_with_state(
344        &self,
345        window: &[TranscriptTurn],
346        state: &State,
347    ) -> Result<Value, LlmError> {
348        let value = self.extract(window).await?;
349
350        // Slots are also written directly, not only returned for promotion:
351        // an application that registered no promotion rules still gets its
352        // slots, and a phase evaluated in the same turn sees them.
353        for slot in &self.slots {
354            if state.contains(&slot.state_key) {
355                continue;
356            }
357            if let Some(filled) = value.get(&slot.state_key) {
358                let _ = state.set(slot.state_key.clone(), filled.clone());
359            }
360        }
361        Ok(value)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::core::{SessionId, UserId};
369    use crate::engine::MemoryEngine;
370    use std::time::Instant;
371
372    // ─── slot key validation ────────────────────────────────────────────────
373    //
374    // This module used to explain the dotted-key trap in a doc comment and then
375    // accept one anyway. The failure it described is silent and remote from its
376    // cause: the slot is written, `state.get(..)` finds it, and only
377    // `state.user().get(..)` — the composition the platform documents — comes
378    // back `None`. Cheaper to refuse at construction.
379
380    #[test]
381    fn a_well_formed_slot_is_accepted() {
382        for scope in WRITABLE_SCOPES {
383            let key = format!("{scope}:diet");
384            assert!(
385                MemorySlot::try_new("dietary_identity", &key).is_ok(),
386                "`{key}` is a writable scope and must be accepted"
387            );
388        }
389    }
390
391    #[test]
392    fn a_dotted_key_is_refused_with_the_fix_in_the_message() {
393        let err = MemorySlot::try_new("dietary_identity", "user.diet")
394            .expect_err("a dotted key never composes with `state.user()`");
395        assert!(matches!(err, MemorySlotError::MissingScope(_)));
396        let msg = err.to_string();
397        assert!(
398            msg.contains("scope:key") && msg.contains("user:diet"),
399            "the error must show the shape that works: {msg}"
400        );
401    }
402
403    #[test]
404    fn the_derived_scope_is_refused_because_contains_has_no_fallback() {
405        let err = MemorySlot::try_new("dietary_identity", "derived:diet")
406            .expect_err("`derived:` is invisible to the gates memory exists to satisfy");
407        assert!(matches!(err, MemorySlotError::UnknownScope { .. }));
408        assert!(
409            err.to_string().contains("contains"),
410            "the error should say why, not just that: {err}"
411        );
412    }
413
414    #[test]
415    fn empty_pieces_are_refused() {
416        assert!(matches!(
417            MemorySlot::try_new("", "user:diet"),
418            Err(MemorySlotError::EmptyPredicate)
419        ));
420        assert!(matches!(
421            MemorySlot::try_new("dietary_identity", "user:"),
422            Err(MemorySlotError::EmptyName(_))
423        ));
424    }
425
426    #[test]
427    #[should_panic(expected = "scope:key")]
428    fn new_panics_on_a_malformed_literal() {
429        // `new` is for literals in application code, where a bad key is a
430        // programming error and failing on first run beats failing silently
431        // for the lifetime of the deployment.
432        let _ = MemorySlot::new("dietary_identity", "user.diet");
433    }
434
435    #[test]
436    fn a_predicate_absent_from_the_corpus_is_still_valid() {
437        // Every slot is aspirational for a new user; validating predicates
438        // against the corpus would make memory unusable on day one.
439        assert!(MemorySlot::try_new("never_stated_yet", "user:whatever").is_ok());
440    }
441
442    fn turn(number: u32, user: &str) -> TranscriptTurn {
443        TranscriptTurn {
444            turn_number: number,
445            user: user.to_string(),
446            model: String::new(),
447            tool_calls: Vec::new(),
448            timestamp: Instant::now(),
449        }
450    }
451
452    fn session() -> Arc<MemorySession> {
453        let engine = MemoryEngine::in_memory(UserId::new("usr_1"));
454        Arc::new(engine.begin_session(SessionId::new("ses_1")))
455    }
456
457    #[tokio::test]
458    async fn a_finalized_turn_becomes_a_session_candidate() {
459        let session = session();
460        let extractor = MemoryTurnExtractor::new(session.clone());
461        let window = [turn(1, "I am pescatarian")];
462
463        assert!(extractor.should_extract(&window));
464        let summary = extractor.extract(&window).await.unwrap();
465
466        assert_eq!(summary["created"], 1);
467        assert_eq!(session.ledger().usable_candidates().len(), 1);
468    }
469
470    #[tokio::test]
471    async fn a_remembered_fact_fills_the_slot_a_phase_gates_on() {
472        let session = session();
473        let extractor = MemoryTurnExtractor::new(session.clone())
474            .slots([MemorySlot::new("dietary_identity", "user:diet")]);
475        let state = State::new();
476
477        extractor
478            .extract_with_state(&[turn(1, "I am pescatarian")], &state)
479            .await
480            .unwrap();
481
482        // This is the key `phase.needs(..)` and a `Flow` guard read; that they
483        // really do read it is asserted against a driven `PhaseMachine` and
484        // `FlowMonitor` in `tests/governed_integration.rs`.
485        assert_eq!(
486            state.get::<String>("user:diet").as_deref(),
487            Some("pescatarian"),
488            "memory did not fill the slot the application gates on"
489        );
490    }
491
492    #[tokio::test]
493    async fn what_the_conversation_established_wins_over_what_memory_recalls() {
494        let session = session();
495        let extractor = MemoryTurnExtractor::new(session.clone())
496            .slots([MemorySlot::new("dietary_identity", "user:diet")]);
497        let state = State::new();
498        state.set("user:diet", "vegan").unwrap();
499
500        extractor
501            .extract_with_state(&[turn(1, "I am pescatarian")], &state)
502            .await
503            .unwrap();
504
505        assert_eq!(
506            state.get::<String>("user:diet").as_deref(),
507            Some("vegan"),
508            "memory overwrote a slot the live conversation had already set"
509        );
510    }
511
512    #[tokio::test]
513    async fn promotion_rules_are_declared_for_every_slot() {
514        let extractor = MemoryTurnExtractor::new(session()).slots([
515            MemorySlot::new("dietary_identity", "user:diet"),
516            MemorySlot::new("venue_preference", "user:venue"),
517        ]);
518        let keys: Vec<&str> = extractor
519            .promotion_rules()
520            .iter()
521            .map(|r| r.state_key.as_str())
522            .collect();
523        assert_eq!(keys, vec!["user:diet", "user:venue"]);
524    }
525
526    #[tokio::test]
527    async fn a_session_with_no_slots_configured_promotes_nothing() {
528        let extractor = MemoryTurnExtractor::new(session());
529        assert!(extractor.promotion_rules().is_empty());
530        let state = State::new();
531        extractor
532            .extract_with_state(&[turn(1, "I am pescatarian")], &state)
533            .await
534            .unwrap();
535        assert!(state.keys().iter().all(|k| !k.starts_with("user.")));
536    }
537
538    #[tokio::test]
539    async fn backchannel_turns_never_reach_an_extraction() {
540        let extractor = MemoryTurnExtractor::new(session());
541        for filler in ["ok", "mm hmm", "yeah"] {
542            assert!(
543                !extractor.should_extract(&[turn(1, filler)]),
544                "`{filler}` should not be worth extracting"
545            );
546        }
547        assert!(extractor.should_extract(&[turn(1, "I am pescatarian now")]));
548    }
549
550    #[tokio::test]
551    async fn the_next_turns_context_is_prepared_before_it_is_asked_for() {
552        let session = session();
553        let extractor = MemoryTurnExtractor::new(session.clone());
554
555        extractor
556            .extract(&[turn(1, "I am pescatarian")])
557            .await
558            .unwrap();
559        // A turn that asks something: this is where preparation pays.
560        extractor
561            .extract(&[turn(2, "what do you remember about my dietary preferences")])
562            .await
563            .unwrap();
564
565        // The `recall_context` handler reads this; it must already be filled
566        // by the time the model asks.
567        assert!(
568            !session.prepared_snapshot().is_empty(),
569            "the next turn's context was not prepared during this turn"
570        );
571    }
572
573    #[tokio::test]
574    async fn a_turn_that_only_states_something_prepares_that_something() {
575        // Preparation runs on every turn, because a local BM25 pass costs tens
576        // of microseconds and guessing which utterances "deserve" one costs
577        // recall. What a self-statement retrieves is its own fact — which is
578        // what the model should have in hand on the turn after it was told.
579        let session = session();
580        MemoryTurnExtractor::new(session.clone())
581            .extract(&[turn(1, "I am pescatarian")])
582            .await
583            .unwrap();
584        let prepared = session.prepared_snapshot();
585        assert!(
586            prepared
587                .facts
588                .iter()
589                .any(|f| f.statement.to_lowercase().contains("pescatarian"))
590        );
591    }
592
593    #[tokio::test]
594    async fn a_turn_with_no_content_words_prepares_nothing() {
595        // The one skip the planner can make without understanding language:
596        // there is nothing to search *with*.
597        let session = session();
598        MemoryTurnExtractor::new(session.clone())
599            .extract(&[turn(1, "what do you think")])
600            .await
601            .unwrap();
602        assert!(session.prepared_snapshot().is_empty());
603    }
604
605    #[tokio::test]
606    async fn restating_a_fact_reinforces_rather_than_duplicating() {
607        let session = session();
608        let extractor = MemoryTurnExtractor::new(session.clone());
609
610        extractor
611            .extract(&[turn(1, "I am pescatarian")])
612            .await
613            .unwrap();
614        let second = extractor
615            .extract(&[turn(4, "I am pescatarian")])
616            .await
617            .unwrap();
618
619        assert_eq!(second["reinforced"], 1);
620        assert_eq!(session.ledger().usable_candidates().len(), 1);
621    }
622
623    #[tokio::test]
624    async fn an_empty_window_is_a_no_op() {
625        let extractor = MemoryTurnExtractor::new(session());
626        assert_eq!(extractor.extract(&[]).await.unwrap(), json!({}));
627        assert!(!extractor.should_extract(&[]));
628    }
629
630    #[test]
631    fn it_registers_under_a_stable_name_and_fires_every_turn() {
632        let extractor = MemoryTurnExtractor::new(session());
633        assert_eq!(extractor.name(), MEMORY_EXTRACTOR_NAME);
634        assert_eq!(extractor.trigger(), ExtractionTrigger::EveryTurn);
635    }
636}