gemini_memory_rs/runtime/
turn_extractor.rs1use 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
37pub const MEMORY_EXTRACTOR_NAME: &str = "memory";
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct MemorySlot {
43 pub predicate: CanonicalPredicate,
45 pub state_key: String,
47}
48
49impl MemorySlot {
50 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 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
100const WRITABLE_SCOPES: [&str; 6] = ["user", "app", "session", "turn", "bg", "temp"];
107
108#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum MemorySlotError {
111 #[error("a memory slot needs a predicate to look for")]
113 EmptyPredicate,
114 #[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 #[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 key: String,
131 scope: String,
133 expected: String,
135 },
136 #[error("memory slot key `{0}` has a scope but no name after it")]
138 EmptyName(String),
139}
140
141fn 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
163pub 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 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 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 pub fn min_words(mut self, words: usize) -> Self {
213 self.min_words = words;
214 self
215 }
216
217 pub fn window(mut self, turns: usize) -> Self {
219 self.window = turns;
220 self
221 }
222
223 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 let scheduled = self
277 .session
278 .on_turn_complete(turn_id)
279 .await
280 .map_err(|e| LlmError::Other(e.to_string()))?;
281
282 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 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 #[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 let _ = MemorySlot::new("dietary_identity", "user.diet");
433 }
434
435 #[test]
436 fn a_predicate_absent_from_the_corpus_is_still_valid() {
437 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 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 extractor
561 .extract(&[turn(2, "what do you remember about my dietary preferences")])
562 .await
563 .unwrap();
564
565 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 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 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}