Skip to main content

gemini_memory_rs/runtime/
tools.rs

1//! The two tools the model sees.
2//!
3//! Memory reaches the model through function calls rather than injected
4//! context, for three reasons: the model's use of a memory is visible in the
5//! transcript, retrieved text is unambiguously data rather than instruction,
6//! and nothing is spent on turns that never needed memory at all.
7//!
8//! Both are [`TypedTool`]s over argument structs, so the JSON Schema the model
9//! is constrained by is generated from the types the handler actually decodes.
10//! `manage_memory`'s `operation` is the domain's own [`MutationIntent`], which
11//! means the tool contract and the ledger cannot disagree about what operations
12//! exist.
13
14use std::sync::Arc;
15
16use gemini_adk_rs::error::ToolError;
17use gemini_adk_rs::tool::TypedTool;
18use schemars::JsonSchema;
19use serde::Deserialize;
20use serde_json::json;
21
22use crate::core::{MemoryKind, MutationIntent, TurnId};
23use crate::engine::MemorySession;
24
25/// The recall tool's name.
26pub const RECALL_TOOL: &str = "recall_context";
27
28/// The memory-management tool's name.
29pub const MANAGE_TOOL: &str = "manage_memory";
30
31/// Every tool the memory subsystem installs.
32///
33/// These serve the conversation rather than any one step of it, so a governed
34/// [`Flow`](gemini_adk_rs::flow::Flow) should treat them as
35/// [ambient](gemini_adk_rs::flow::Flow::ambient) — otherwise a step that
36/// whitelists its own tools silently switches memory off for its duration.
37/// [`with_memory`](super::LiveMemoryExt::with_memory) registers them for you;
38/// pass them to [`Flow::ambient`](gemini_adk_rs::flow::FlowBuilder::ambient)
39/// directly when driving the engine without the `Live` builder.
40pub const MEMORY_TOOLS: [&str; 2] = [RECALL_TOOL, MANAGE_TOOL];
41
42/// The recall tool's description.
43///
44/// Deliberately narrow: a description that invites the model to call it for
45/// anything produces a tool call on every turn, most of them useless.
46pub const RECALL_DESCRIPTION: &str = "Retrieve relevant private context about this user — their \
47preferences, relationships, routines, commitments or previous conversations. Do not use for \
48general knowledge, current events, or anything visible in the camera.";
49
50/// The management tool's description.
51pub const MANAGE_DESCRIPTION: &str = "Use ONLY when the user explicitly asks you to remember, \
52correct, forget or delete something about them, or asks what you remember. Never call this to \
53store something the user did not ask you to store.";
54
55/// Which slice of memory a recall should search.
56///
57/// These doc comments are not documentation for a reader; they are the schema
58/// descriptions the *model* chooses from, and the choice is a hard filter. A
59/// model that picks the wrong slice does not get a worse answer, it gets no
60/// answer — while lower-relevance records from the slice it did pick come back
61/// looking like the best memory has. So each variant says what it *excludes*,
62/// in the vocabulary of a question rather than of this crate's taxonomy.
63///
64/// Observed before that was true: asked "what did I promise to bring to the
65/// housewarming", the model chose `persistent` — a promise feels like a durable
66/// fact — which filters out `Commitment`, the kind the answer was filed under.
67/// The commitment was excluded, another guest's plans came back instead, and
68/// the model correctly reported that it did not know.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
70#[serde(rename_all = "snake_case")]
71pub enum RecallScope {
72    /// Only memories with a time attached: things that happened, plans,
73    /// promises and commitments. Excludes standing facts about the person.
74    Recent,
75    /// Only timeless facts: identity, preferences, relationships, routines,
76    /// how they like to be spoken to. Excludes anything that happened, and
77    /// excludes every promise, plan and commitment.
78    Persistent,
79    /// Everything. Choose this unless the question is explicitly limited to one
80    /// of the other two.
81    #[default]
82    All,
83}
84
85impl RecallScope {
86    /// The memory kinds this scope admits; empty means no restriction.
87    pub fn kinds(self) -> Vec<MemoryKind> {
88        match self {
89            Self::All => Vec::new(),
90            Self::Recent => vec![MemoryKind::Episodic, MemoryKind::Commitment],
91            Self::Persistent => vec![
92                MemoryKind::Identity,
93                MemoryKind::Preference,
94                MemoryKind::Relationship,
95                MemoryKind::RelationshipPreference,
96                MemoryKind::Routine,
97                MemoryKind::CommunicationStyle,
98                MemoryKind::LocationPreference,
99            ],
100        }
101    }
102}
103
104/// Arguments to `recall_context`.
105#[derive(Debug, Deserialize, JsonSchema)]
106pub struct RecallArgs {
107    /// What to look for, in the user's own terms.
108    pub query: String,
109    /// Which slice of memory to search.
110    ///
111    /// Every value is spelled out here rather than on the variants because
112    /// **per-variant descriptions do not reach the model**: narrowing the
113    /// derived schema to the API's subset flattens `oneOf`-of-`enum` down to a
114    /// bare `{"enum": [...]}`, and the doc comment on each variant goes with
115    /// it. This field's description is the only text the model actually reads
116    /// about what the values mean, so it carries all of it.
117    ///
118    /// Omit unless the question is explicitly about one slice — narrowing
119    /// wrongly excludes the answer outright rather than ranking it lower, and
120    /// plausible records from the chosen slice arrive in its place. `recent`:
121    /// only memories with a time attached — things that happened, plans,
122    /// promises, commitments. `persistent`: only timeless facts — identity,
123    /// preferences, relationships, routines; excludes everything that happened
124    /// and every promise. `all`: everything, and the right choice by default.
125    #[serde(default)]
126    pub scope: RecallScope,
127    /// Whose fact this is.
128    ///
129    /// Use a value from the memory map in your instructions, or omit it. This
130    /// narrows nothing away — a record that does not match is ranked lower, not
131    /// removed — so a wrong guess costs about one result, while a right one is
132    /// worth several. Guessing is better than omitting.
133    ///
134    /// Distinct from who the question *mentions*: "where am I collecting
135    /// Priya's cake" is a fact about the user that mentions Priya, so `about`
136    /// is the user.
137    #[serde(default)]
138    pub about: Option<String>,
139    /// Which attribute of them — for example a coffee order, a barber, an
140    /// allergy.
141    ///
142    /// Use a value from the memory map in your instructions, or omit it. Same
143    /// soft behaviour as `about`, and the more useful of the two.
144    #[serde(default)]
145    pub attribute: Option<String>,
146}
147
148/// Arguments to `manage_memory`.
149#[derive(Debug, Deserialize, JsonSchema)]
150pub struct ManageArgs {
151    /// What the user asked for.
152    pub operation: MutationIntent,
153    /// What to remember, correct or forget, as the user put it.
154    #[serde(default)]
155    pub statement: Option<String>,
156}
157
158/// Build the `recall_context` tool for a session.
159///
160/// The handler is a state read on the happy path: by the time the model asks,
161/// the answer was prepared while it was speaking.
162pub fn recall_context_tool(session: Arc<MemorySession>) -> TypedTool<RecallArgs> {
163    TypedTool::new(RECALL_TOOL, RECALL_DESCRIPTION, move |args: RecallArgs| {
164        let session = session.clone();
165        async move {
166            if args.query.trim().is_empty() {
167                return Ok(json!({ "status": "not_found", "facts": [] }));
168            }
169            let turn = current_turn(&session);
170            Ok(session
171                .recall_scoped(&args.query, turn, args.scope, args.about, args.attribute)
172                .await)
173        }
174    })
175}
176
177/// Build the `manage_memory` tool for a session.
178pub fn manage_memory_tool(session: Arc<MemorySession>) -> TypedTool<ManageArgs> {
179    TypedTool::new(MANAGE_TOOL, MANAGE_DESCRIPTION, move |args: ManageArgs| {
180        let session = session.clone();
181        async move {
182            let statement = args.statement.unwrap_or_default().trim().to_string();
183
184            // Every operation but `list` needs something to act on, and
185            // guessing at a deletion target is not recoverable.
186            if statement.is_empty() && args.operation != MutationIntent::List {
187                return Ok(json!({
188                    "status": "needs_clarification",
189                    "operation": args.operation,
190                    "message": "Ask the user what specifically to act on.",
191                }));
192            }
193
194            let turn = current_turn(&session);
195            session
196                .apply_explicit_command(args.operation, &statement, turn)
197                .await
198                .map_err(|e| ToolError::ExecutionFailed(e.to_string()))
199        }
200    })
201}
202
203fn current_turn(session: &MemorySession) -> TurnId {
204    session.current_turn()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::core::{SessionId, UserId};
211    use crate::engine::MemoryEngine;
212    use gemini_adk_rs::tool::ToolFunction;
213
214    async fn session() -> Arc<MemorySession> {
215        let engine = MemoryEngine::in_memory(UserId::new("usr_1"));
216        let session = Arc::new(engine.begin_session(SessionId::new("ses_1")));
217        session.begin_turn(TurnId(1));
218        session
219            .observe_final_transcript(TurnId(1), "I am pescatarian")
220            .await
221            .unwrap();
222        session
223            .observe_final_transcript(TurnId(2), "I am meeting Kushal for dinner tonight")
224            .await
225            .unwrap();
226        session
227    }
228
229    #[tokio::test]
230    async fn recall_serves_a_fact_learned_this_session() {
231        let tool = recall_context_tool(session().await);
232        let result = tool
233            .call(json!({ "query": "dietary preference pescatarian" }))
234            .await
235            .unwrap();
236        assert_eq!(result["status"], "found");
237        assert!(
238            result["facts"][0]["statement"]
239                .as_str()
240                .unwrap()
241                .contains("pescatarian")
242        );
243    }
244
245    #[tokio::test]
246    async fn scope_restricts_which_kinds_can_come_back() {
247        let tool = recall_context_tool(session().await);
248
249        let recent = tool
250            .call(json!({ "query": "dinner pescatarian", "scope": "recent" }))
251            .await
252            .unwrap();
253        assert!(
254            !recent.to_string().contains("pescatarian"),
255            "a durable preference leaked into a recent-only recall: {recent}"
256        );
257
258        let persistent = tool
259            .call(json!({ "query": "dinner pescatarian", "scope": "persistent" }))
260            .await
261            .unwrap();
262        assert!(persistent.to_string().contains("pescatarian"));
263    }
264
265    #[tokio::test]
266    async fn an_omitted_scope_searches_everything() {
267        let tool = recall_context_tool(session().await);
268        let result = tool.call(json!({ "query": "pescatarian" })).await.unwrap();
269        assert_eq!(result["status"], "found");
270    }
271
272    #[tokio::test]
273    async fn recall_reports_not_found_rather_than_failing() {
274        let tool = recall_context_tool(session().await);
275        let result = tool
276            .call(json!({ "query": "what medication is prescribed" }))
277            .await
278            .unwrap();
279        assert_eq!(result["status"], "not_found");
280    }
281
282    #[tokio::test]
283    async fn an_empty_recall_query_is_answered_not_searched() {
284        let tool = recall_context_tool(session().await);
285        assert_eq!(
286            tool.call(json!({ "query": "   " })).await.unwrap()["status"],
287            "not_found"
288        );
289    }
290
291    #[tokio::test]
292    async fn a_missing_required_argument_is_a_tool_error() {
293        let tool = recall_context_tool(session().await);
294        assert!(tool.call(json!({})).await.is_err());
295    }
296
297    #[tokio::test]
298    async fn an_explicit_remember_takes_effect_in_session_and_commits_later() {
299        let session = session().await;
300        let result = manage_memory_tool(session.clone())
301            .call(json!({
302                "operation": "remember",
303                "statement": "The user is allergic to shellfish."
304            }))
305            .await
306            .unwrap();
307
308        assert_eq!(result["status"], "accepted");
309        assert_eq!(result["effective_in_session"], true);
310        assert_eq!(result["durable_commit"], "pending");
311
312        let recall = recall_context_tool(session)
313            .call(json!({ "query": "allergic shellfish" }))
314            .await
315            .unwrap();
316        assert_eq!(recall["status"], "found");
317    }
318
319    #[tokio::test]
320    async fn listing_returns_what_is_currently_known() {
321        let tool = manage_memory_tool(session().await);
322        let result = tool.call(json!({ "operation": "list" })).await.unwrap();
323        assert_eq!(result["operation"], "list");
324        assert!(
325            result["facts"]
326                .as_array()
327                .unwrap()
328                .iter()
329                .any(|f| f.as_str().unwrap_or_default().contains("pescatarian"))
330        );
331    }
332
333    #[tokio::test]
334    async fn an_unnamed_deletion_target_asks_rather_than_guesses() {
335        let tool = manage_memory_tool(session().await);
336        let result = tool.call(json!({ "operation": "forget" })).await.unwrap();
337        assert_eq!(result["status"], "needs_clarification");
338    }
339
340    #[tokio::test]
341    async fn an_operation_outside_the_schema_is_a_tool_error() {
342        let tool = manage_memory_tool(session().await);
343        assert!(
344            tool.call(json!({ "operation": "obliterate" }))
345                .await
346                .is_err()
347        );
348    }
349
350    #[tokio::test]
351    async fn the_generated_schemas_match_the_handlers() {
352        let schema = recall_context_tool(session().await)
353            .parameters()
354            .expect("recall has parameters");
355        assert!(schema["properties"]["query"].is_object());
356        assert!(schema["properties"]["scope"].is_object());
357
358        // The operation enum is the domain's, so the tool contract and the
359        // ledger cannot disagree about what operations exist.
360        let rendered = manage_memory_tool(session().await)
361            .parameters()
362            .expect("manage has parameters")
363            .to_string();
364        for operation in ["remember", "correct", "forget", "delete", "list"] {
365            assert!(rendered.contains(operation), "schema omits `{operation}`");
366        }
367    }
368
369    #[tokio::test]
370    async fn what_each_scope_means_survives_into_the_schema() {
371        // Schema narrowing flattens `oneOf`-of-`enum` to a bare `enum`, which
372        // drops the doc comment on every variant — so anything said about a
373        // value on the variant itself never reaches the model. It has to live
374        // in the field description, and this is what says so.
375        //
376        // It matters because a wrong `scope` is a hard filter, not a worse
377        // ranking: the answer is excluded while plausible records from the
378        // chosen slice arrive in its place. A live run lost a commitment
379        // exactly this way, the model having reasoned that a promise is a
380        // durable fact.
381        let scope = recall_context_tool(session().await)
382            .parameters()
383            .expect("recall has parameters")["properties"]["scope"]["description"]
384            .as_str()
385            .expect("the scope argument is described")
386            .to_lowercase();
387
388        for value in ["recent", "persistent", "all"] {
389            assert!(
390                scope.contains(value),
391                "`{value}` is a value the model must choose between, and this \
392                 description is the only place it can learn what the value \
393                 means: {scope}"
394            );
395        }
396        assert!(
397            scope.contains("excludes"),
398            "the description must say what narrowing leaves out, or the model \
399             cannot tell that it costs the answer: {scope}"
400        );
401        assert!(
402            scope.contains("omit unless"),
403            "the description must tell the model to leave the scope unset by \
404             default: {scope}"
405        );
406    }
407
408    #[test]
409    fn the_tool_descriptions_steer_away_from_indiscriminate_calls() {
410        assert!(RECALL_DESCRIPTION.contains("Do not use for"));
411        assert!(MANAGE_DESCRIPTION.contains("ONLY when the user explicitly asks"));
412    }
413
414    #[test]
415    fn recall_scopes_partition_durable_from_episodic() {
416        assert!(RecallScope::All.kinds().is_empty());
417        assert!(RecallScope::Recent.kinds().contains(&MemoryKind::Episodic));
418        assert!(
419            RecallScope::Persistent
420                .kinds()
421                .contains(&MemoryKind::Preference)
422        );
423        assert!(
424            !RecallScope::Persistent
425                .kinds()
426                .contains(&MemoryKind::Episodic)
427        );
428    }
429}