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}