gemini_memory_rs/runtime/
live.rs

1//! Installing memory onto a `Live` session.
2//!
3//! Memory has exactly two touchpoints on a live conversation, and both are
4//! mechanisms the runtime already owns:
5//!
6//! - **the extraction pipeline**, which runs [`MemoryTurnExtractor`] at each
7//!   turn boundary and promotes its fields into governed `State`;
8//! - **the tool dispatcher**, which serves `recall_context` and
9//!   `manage_memory`.
10//!
11//! There is deliberately no third mechanism. An earlier revision of this module
12//! carried its own channel, control loop and state keys; all of it duplicated
13//! what `Live` already does, and every duplicated mechanism is a second place
14//! for turn bookkeeping to drift.
15
16use std::sync::Arc;
17
18use gemini_adk_fluent_rs::compose::tools::ToolComposite;
19use gemini_adk_fluent_rs::live::Live;
20
21use super::tools::{MEMORY_TOOLS, manage_memory_tool, recall_context_tool};
22use super::turn_extractor::{MemorySlot, MemoryTurnExtractor};
23use crate::engine::MemorySession;
24
25/// Both memory tools, as a composite that can be combined with `|`.
26///
27/// ```no_run
28/// # use std::sync::Arc;
29/// # use gemini_memory_rs::runtime::memory_tools;
30/// # use gemini_adk_fluent_rs::compose::T;
31/// # fn demo(session: Arc<gemini_memory_rs::engine::MemorySession>) {
32/// let tools = memory_tools(session) | T::google_search();
33/// # let _ = tools;
34/// # }
35/// ```
36pub fn memory_tools(session: Arc<MemorySession>) -> ToolComposite {
37    ToolComposite::from_function(Arc::new(recall_context_tool(session.clone())))
38        | ToolComposite::from_function(Arc::new(manage_memory_tool(session)))
39}
40
41/// Installs the memory subsystem onto a `Live` builder.
42pub trait LiveMemoryExt: Sized {
43    /// Wire memory in: ingestion, retrieval preparation, and the two tools.
44    ///
45    /// ```no_run
46    /// # use std::sync::Arc;
47    /// # use gemini_adk_fluent_rs::live::Live;
48    /// # use gemini_memory_rs::runtime::LiveMemoryExt;
49    /// # fn demo(session: Arc<gemini_memory_rs::engine::MemorySession>) {
50    /// Live::builder().with_memory(session);
51    /// # }
52    /// ```
53    fn with_memory(self, session: Arc<MemorySession>) -> Self;
54
55    /// Wire memory in, projecting remembered facts into governed `State` slots.
56    ///
57    /// A slot filled from memory satisfies `phase.needs(..)`,
58    /// `phase.requires(..)` and `Flow` guards exactly as one filled by the user
59    /// would — which is the point. The application asks for what it needs and
60    /// does not have to care whether the answer arrived this minute or last
61    /// month.
62    ///
63    /// ```no_run
64    /// # use std::sync::Arc;
65    /// # use gemini_adk_fluent_rs::live::Live;
66    /// # use gemini_memory_rs::runtime::{LiveMemoryExt, MemorySlot};
67    /// # fn demo(session: Arc<gemini_memory_rs::engine::MemorySession>) {
68    /// Live::builder()
69    ///     .with_memory_slots(
70    ///         session,
71    ///         [
72    ///             MemorySlot::new("dietary_identity", "user:diet"),
73    ///             MemorySlot::new("venue_preference", "user:venue"),
74    ///         ],
75    ///     )
76    ///     // Satisfied from memory for a returning user, so they are not
77    ///     // asked again for something they already told us.
78    ///     .phase("plan_dinner")
79    ///         .needs(&["user:diet", "user:venue"])
80    ///         .done();
81    /// # }
82    /// ```
83    fn with_memory_slots(
84        self,
85        session: Arc<MemorySession>,
86        slots: impl IntoIterator<Item = MemorySlot>,
87    ) -> Self;
88}
89
90impl LiveMemoryExt for Live {
91    fn with_memory(self, session: Arc<MemorySession>) -> Self {
92        self.with_memory_slots(session, Vec::new())
93    }
94
95    fn with_memory_slots(
96        self,
97        session: Arc<MemorySession>,
98        slots: impl IntoIterator<Item = MemorySlot>,
99    ) -> Self {
100        let extractor = MemoryTurnExtractor::new(session.clone()).slots(slots);
101        let vocabulary = session.clone();
102        let teardown_session = session.clone();
103        self.tools(memory_tools(session))
104            // Memory serves the whole conversation, not one step of it. A step
105            // that whitelists its own tools — `.allow(["book_table"])` — is
106            // saying "book here, don't search the catalogue"; it is not asking
107            // to stop remembering who the caller is. Without this, a governed
108            // flow would switch recall off for the duration of any such step,
109            // and silently: the model simply stops being told what it knows.
110            //
111            // Registered rather than merged into the flow directly, so this
112            // composes with `.govern(..)` written either side of it.
113            .ambient_tools(MEMORY_TOOLS)
114            // Reconcile at end of session, or nothing said in it is durable.
115            //
116            // Until this existed, `finish()` had no production call site: the
117            // engine ingested every turn into the session ledger, served it from
118            // the overlay for the rest of the conversation, and dropped the lot
119            // on disconnect. A session remembered perfectly and forgot on
120            // hang-up, which is the one thing a memory subsystem must not do —
121            // and it presented as working, because within a single session it
122            // did.
123            //
124            // `on_teardown` rather than `on_disconnected` because the latter is
125            // a single slot the application also wants; registering there would
126            // mean whichever of us was written second silently won.
127            .on_teardown(move || {
128                let session = teardown_session.clone();
129                async move {
130                    // `finish` recompiles the canonical index itself, so the
131                    // next session sees these facts.
132                    if let Err(e) = session.finish().await {
133                        tracing::error!(
134                            error = %e,
135                            "memory reconciliation failed; this session's facts were not persisted"
136                        );
137                    }
138                }
139            })
140            // Registering an extractor also enables transcription, so callers
141            // need not remember to turn it on.
142            .extractor(Arc::new(extractor))
143            // The memory map, delivered as an amendment rather than folded into
144            // the caller's instruction.
145            //
146            // `recall_context` takes `about` and `attribute`, and those are
147            // worth nothing unless the model can name the values. Measured over
148            // 93 questions, a model asked cold names the right predicate 2% of
149            // the time — below the 8% at which a soft filter starts paying for
150            // itself — and 69% when shown this list. End to end that is an
151            // expected 78.3 of 93 against 84.3.
152            //
153            // An amendment, for three reasons. It composes with whatever
154            // instruction the caller wrote instead of replacing it. It is
155            // re-evaluated, so a corpus that grows mid-session is reflected
156            // rather than frozen at connect — which matters because Live fixes
157            // tool declarations at connect and this could not live in the
158            // schema for that reason. And it returns `None` for an empty
159            // corpus, so a new user is not handed an empty list to filter by.
160            .instruction_amendment(move |_state| {
161                let map = vocabulary.memory_map();
162                (!map.is_empty()).then_some(map)
163            })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::core::{SessionId, UserId};
171    use crate::engine::MemoryEngine;
172
173    fn session() -> Arc<MemorySession> {
174        let engine = MemoryEngine::in_memory(UserId::new("usr_1"));
175        Arc::new(engine.begin_session(SessionId::new("ses_1")))
176    }
177
178    #[test]
179    fn the_composite_exposes_both_tools_and_composes_with_others() {
180        let composite = memory_tools(session());
181        assert_eq!(composite.len(), 2);
182
183        let names: Vec<String> = composite
184            .entries
185            .iter()
186            .filter_map(|e| match e {
187                gemini_adk_fluent_rs::compose::tools::ToolCompositeEntry::Function(f) => {
188                    Some(gemini_adk_rs::tool::ToolFunction::name(f.as_ref()).to_string())
189                }
190                _ => None,
191            })
192            .collect();
193        assert!(names.contains(&super::super::tools::RECALL_TOOL.to_string()));
194        assert!(names.contains(&super::super::tools::MANAGE_TOOL.to_string()));
195
196        let combined = memory_tools(session()) | gemini_adk_fluent_rs::compose::T::google_search();
197        assert_eq!(combined.len(), 3);
198    }
199
200    #[test]
201    fn installation_registers_an_end_of_session_reconcile() {
202        // Without this the engine ingests everything into the session ledger,
203        // serves it from the overlay for the rest of the conversation, and
204        // drops the lot on disconnect — remembering perfectly right up to the
205        // moment it matters. Asserted through the builder so removing the
206        // registration fails here rather than passing quietly.
207        let live = Live::builder().with_memory(session());
208        assert_eq!(
209            live.teardown_hook_count(),
210            1,
211            "`with_memory` must install exactly one reconcile-on-disconnect hook"
212        );
213    }
214
215    #[test]
216    fn reconciliation_does_not_displace_the_application_disconnect_handler() {
217        // `on_disconnected` is a single slot the application also wants. If
218        // memory registered there, whichever of the two was written second
219        // would silently win — so both orders must keep both.
220        let before = Live::builder()
221            .with_memory(session())
222            .on_disconnected(|_reason| async {});
223        let after = Live::builder()
224            .on_disconnected(|_reason| async {})
225            .with_memory(session());
226        for live in [before, after] {
227            assert_eq!(live.teardown_hook_count(), 1);
228        }
229    }
230
231    #[test]
232    fn installation_composes_with_the_rest_of_the_builder() {
233        // The point of riding the platform's own mechanisms: memory is just
234        // another extractor and another tool, so nothing else has to move.
235        let _live = Live::builder()
236            .instruction("You are a companion.")
237            .with_memory_slots(
238                session(),
239                [MemorySlot::new("dietary_identity", "user:diet")],
240            )
241            .phase("plan")
242            .needs(&["user:diet"])
243            .done()
244            .initial_phase("plan");
245    }
246}