use std::sync::Arc;
use gemini_adk_rs::error::ToolError;
use gemini_adk_rs::tool::TypedTool;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use crate::core::{MemoryKind, MutationIntent, TurnId};
use crate::engine::MemorySession;
pub const RECALL_TOOL: &str = "recall_context";
pub const MANAGE_TOOL: &str = "manage_memory";
pub const MEMORY_TOOLS: [&str; 2] = [RECALL_TOOL, MANAGE_TOOL];
pub const RECALL_DESCRIPTION: &str = "Retrieve relevant private context about this user — their \
preferences, relationships, routines, commitments or previous conversations. Do not use for \
general knowledge, current events, or anything visible in the camera.";
pub const MANAGE_DESCRIPTION: &str = "Use ONLY when the user explicitly asks you to remember, \
correct, forget or delete something about them, or asks what you remember. Never call this to \
store something the user did not ask you to store.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RecallScope {
Recent,
Persistent,
#[default]
All,
}
impl RecallScope {
pub fn kinds(self) -> Vec<MemoryKind> {
match self {
Self::All => Vec::new(),
Self::Recent => vec![MemoryKind::Episodic, MemoryKind::Commitment],
Self::Persistent => vec![
MemoryKind::Identity,
MemoryKind::Preference,
MemoryKind::Relationship,
MemoryKind::RelationshipPreference,
MemoryKind::Routine,
MemoryKind::CommunicationStyle,
MemoryKind::LocationPreference,
],
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RecallArgs {
pub query: String,
#[serde(default)]
pub scope: RecallScope,
#[serde(default)]
pub about: Option<String>,
#[serde(default)]
pub attribute: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ManageArgs {
pub operation: MutationIntent,
#[serde(default)]
pub statement: Option<String>,
}
pub fn recall_context_tool(session: Arc<MemorySession>) -> TypedTool<RecallArgs> {
TypedTool::new(RECALL_TOOL, RECALL_DESCRIPTION, move |args: RecallArgs| {
let session = session.clone();
async move {
if args.query.trim().is_empty() {
return Ok(json!({ "status": "not_found", "facts": [] }));
}
let turn = current_turn(&session);
Ok(session
.recall_scoped(&args.query, turn, args.scope, args.about, args.attribute)
.await)
}
})
}
pub fn manage_memory_tool(session: Arc<MemorySession>) -> TypedTool<ManageArgs> {
TypedTool::new(MANAGE_TOOL, MANAGE_DESCRIPTION, move |args: ManageArgs| {
let session = session.clone();
async move {
let statement = args.statement.unwrap_or_default().trim().to_string();
if statement.is_empty() && args.operation != MutationIntent::List {
return Ok(json!({
"status": "needs_clarification",
"operation": args.operation,
"message": "Ask the user what specifically to act on.",
}));
}
let turn = current_turn(&session);
session
.apply_explicit_command(args.operation, &statement, turn)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))
}
})
}
fn current_turn(session: &MemorySession) -> TurnId {
session.current_turn()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{SessionId, UserId};
use crate::engine::MemoryEngine;
use gemini_adk_rs::tool::ToolFunction;
async fn session() -> Arc<MemorySession> {
let engine = MemoryEngine::in_memory(UserId::new("usr_1"));
let session = Arc::new(engine.begin_session(SessionId::new("ses_1")));
session.begin_turn(TurnId(1));
session
.observe_final_transcript(TurnId(1), "I am pescatarian")
.await
.unwrap();
session
.observe_final_transcript(TurnId(2), "I am meeting Kushal for dinner tonight")
.await
.unwrap();
session
}
#[tokio::test]
async fn recall_serves_a_fact_learned_this_session() {
let tool = recall_context_tool(session().await);
let result = tool
.call(json!({ "query": "dietary preference pescatarian" }))
.await
.unwrap();
assert_eq!(result["status"], "found");
assert!(
result["facts"][0]["statement"]
.as_str()
.unwrap()
.contains("pescatarian")
);
}
#[tokio::test]
async fn scope_restricts_which_kinds_can_come_back() {
let tool = recall_context_tool(session().await);
let recent = tool
.call(json!({ "query": "dinner pescatarian", "scope": "recent" }))
.await
.unwrap();
assert!(
!recent.to_string().contains("pescatarian"),
"a durable preference leaked into a recent-only recall: {recent}"
);
let persistent = tool
.call(json!({ "query": "dinner pescatarian", "scope": "persistent" }))
.await
.unwrap();
assert!(persistent.to_string().contains("pescatarian"));
}
#[tokio::test]
async fn an_omitted_scope_searches_everything() {
let tool = recall_context_tool(session().await);
let result = tool.call(json!({ "query": "pescatarian" })).await.unwrap();
assert_eq!(result["status"], "found");
}
#[tokio::test]
async fn recall_reports_not_found_rather_than_failing() {
let tool = recall_context_tool(session().await);
let result = tool
.call(json!({ "query": "what medication is prescribed" }))
.await
.unwrap();
assert_eq!(result["status"], "not_found");
}
#[tokio::test]
async fn an_empty_recall_query_is_answered_not_searched() {
let tool = recall_context_tool(session().await);
assert_eq!(
tool.call(json!({ "query": " " })).await.unwrap()["status"],
"not_found"
);
}
#[tokio::test]
async fn a_missing_required_argument_is_a_tool_error() {
let tool = recall_context_tool(session().await);
assert!(tool.call(json!({})).await.is_err());
}
#[tokio::test]
async fn an_explicit_remember_takes_effect_in_session_and_commits_later() {
let session = session().await;
let result = manage_memory_tool(session.clone())
.call(json!({
"operation": "remember",
"statement": "The user is allergic to shellfish."
}))
.await
.unwrap();
assert_eq!(result["status"], "accepted");
assert_eq!(result["effective_in_session"], true);
assert_eq!(result["durable_commit"], "pending");
let recall = recall_context_tool(session)
.call(json!({ "query": "allergic shellfish" }))
.await
.unwrap();
assert_eq!(recall["status"], "found");
}
#[tokio::test]
async fn listing_returns_what_is_currently_known() {
let tool = manage_memory_tool(session().await);
let result = tool.call(json!({ "operation": "list" })).await.unwrap();
assert_eq!(result["operation"], "list");
assert!(
result["facts"]
.as_array()
.unwrap()
.iter()
.any(|f| f.as_str().unwrap_or_default().contains("pescatarian"))
);
}
#[tokio::test]
async fn an_unnamed_deletion_target_asks_rather_than_guesses() {
let tool = manage_memory_tool(session().await);
let result = tool.call(json!({ "operation": "forget" })).await.unwrap();
assert_eq!(result["status"], "needs_clarification");
}
#[tokio::test]
async fn an_operation_outside_the_schema_is_a_tool_error() {
let tool = manage_memory_tool(session().await);
assert!(
tool.call(json!({ "operation": "obliterate" }))
.await
.is_err()
);
}
#[tokio::test]
async fn the_generated_schemas_match_the_handlers() {
let schema = recall_context_tool(session().await)
.parameters()
.expect("recall has parameters");
assert!(schema["properties"]["query"].is_object());
assert!(schema["properties"]["scope"].is_object());
let rendered = manage_memory_tool(session().await)
.parameters()
.expect("manage has parameters")
.to_string();
for operation in ["remember", "correct", "forget", "delete", "list"] {
assert!(rendered.contains(operation), "schema omits `{operation}`");
}
}
#[tokio::test]
async fn what_each_scope_means_survives_into_the_schema() {
let scope = recall_context_tool(session().await)
.parameters()
.expect("recall has parameters")["properties"]["scope"]["description"]
.as_str()
.expect("the scope argument is described")
.to_lowercase();
for value in ["recent", "persistent", "all"] {
assert!(
scope.contains(value),
"`{value}` is a value the model must choose between, and this \
description is the only place it can learn what the value \
means: {scope}"
);
}
assert!(
scope.contains("excludes"),
"the description must say what narrowing leaves out, or the model \
cannot tell that it costs the answer: {scope}"
);
assert!(
scope.contains("omit unless"),
"the description must tell the model to leave the scope unset by \
default: {scope}"
);
}
#[test]
fn the_tool_descriptions_steer_away_from_indiscriminate_calls() {
assert!(RECALL_DESCRIPTION.contains("Do not use for"));
assert!(MANAGE_DESCRIPTION.contains("ONLY when the user explicitly asks"));
}
#[test]
fn recall_scopes_partition_durable_from_episodic() {
assert!(RecallScope::All.kinds().is_empty());
assert!(RecallScope::Recent.kinds().contains(&MemoryKind::Episodic));
assert!(
RecallScope::Persistent
.kinds()
.contains(&MemoryKind::Preference)
);
assert!(
!RecallScope::Persistent
.kinds()
.contains(&MemoryKind::Episodic)
);
}
}