gemini_memory_rs/runtime/
tools.rs1use 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
25pub const RECALL_TOOL: &str = "recall_context";
27
28pub const MANAGE_TOOL: &str = "manage_memory";
30
31pub const MEMORY_TOOLS: [&str; 2] = [RECALL_TOOL, MANAGE_TOOL];
41
42pub 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
50pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
70#[serde(rename_all = "snake_case")]
71pub enum RecallScope {
72 Recent,
75 Persistent,
79 #[default]
82 All,
83}
84
85impl RecallScope {
86 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#[derive(Debug, Deserialize, JsonSchema)]
106pub struct RecallArgs {
107 pub query: String,
109 #[serde(default)]
126 pub scope: RecallScope,
127 #[serde(default)]
138 pub about: Option<String>,
139 #[serde(default)]
145 pub attribute: Option<String>,
146}
147
148#[derive(Debug, Deserialize, JsonSchema)]
150pub struct ManageArgs {
151 pub operation: MutationIntent,
153 #[serde(default)]
155 pub statement: Option<String>,
156}
157
158pub 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
177pub 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 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 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 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}