use std::sync::Arc;
use async_trait::async_trait;
use nexo_llm::ToolDef;
use nexo_memory_snapshot::{MemorySnapshotter, SnapshotRequest};
use serde_json::{json, Value};
use super::context::AgentContext;
use super::tool_registry::ToolHandler;
const TOOL_NAME: &str = "memory_snapshot";
const DEFAULT_TENANT: &str = "default";
pub struct MemorySnapshotTool {
snapshotter: Arc<dyn MemorySnapshotter>,
tenant: String,
redact_secrets_default: bool,
}
impl MemorySnapshotTool {
pub fn new(snapshotter: Arc<dyn MemorySnapshotter>) -> Self {
Self {
snapshotter,
tenant: DEFAULT_TENANT.to_string(),
redact_secrets_default: true,
}
}
pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
self.tenant = tenant.into();
self
}
pub fn with_redact_secrets_default(mut self, redact: bool) -> Self {
self.redact_secrets_default = redact;
self
}
pub fn tool_def() -> ToolDef {
ToolDef {
name: TOOL_NAME.into(),
description: "Capture a point-in-time snapshot of this agent's memory \
(git memdir + SQLite stores + state). Returns the new bundle's id, \
size, and SHA-256. Operators run `nexo memory list` / `verify` / \
`restore` against the result. Restore is operator-only and is NOT \
callable from this tool. Use sparingly — each call writes a \
full bundle to disk."
.into(),
parameters: json!({
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Free-form short label (e.g. 'pre-rollout'). Stored in the manifest.",
"maxLength": 200
},
"redact_secrets": {
"type": "boolean",
"description": "Override the runtime default for secret redaction over text artifacts."
}
},
"additionalProperties": false
}),
}
}
pub fn is_deferred() -> bool {
true
}
}
#[async_trait]
impl ToolHandler for MemorySnapshotTool {
async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let label = args
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let redact_secrets = args
.get("redact_secrets")
.and_then(|v| v.as_bool())
.unwrap_or(self.redact_secrets_default);
let req = SnapshotRequest {
agent_id: ctx.agent_id.clone(),
tenant: self.tenant.clone(),
label,
redact_secrets,
encrypt: None,
created_by: "tool".into(),
};
let meta = self
.snapshotter
.snapshot(req)
.await
.map_err(|e| anyhow::anyhow!("memory_snapshot failed: {e}"))?;
Ok(json!({
"snapshot_id": meta.id.to_string(),
"bundle_path": meta.bundle_path.display().to_string(),
"bundle_size_bytes": meta.bundle_size_bytes,
"bundle_sha256": meta.bundle_sha256,
"created_at_ms": meta.created_at_ms,
"redactions_applied": meta.redactions_applied,
"encrypted": meta.encrypted,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SessionManager;
use async_trait::async_trait;
use nexo_broker::AnyBroker;
use nexo_config::types::agents::{
AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
};
use nexo_memory_snapshot::{
AgentId, RestoreReport, RestoreRequest, SnapshotDiff, SnapshotError, SnapshotId,
SnapshotMeta, VerifyReport,
};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
struct CapturingSnapshotter {
last_label: Mutex<Option<String>>,
last_redact: Mutex<Option<bool>>,
last_created_by: Mutex<Option<String>>,
}
#[async_trait]
impl MemorySnapshotter for CapturingSnapshotter {
async fn snapshot(&self, req: SnapshotRequest) -> Result<SnapshotMeta, SnapshotError> {
*self.last_label.lock().unwrap() = req.label.clone();
*self.last_redact.lock().unwrap() = Some(req.redact_secrets);
*self.last_created_by.lock().unwrap() = Some(req.created_by.clone());
Ok(SnapshotMeta {
id: SnapshotId::new(),
agent_id: req.agent_id,
tenant: req.tenant,
label: req.label,
created_at_ms: 1_700_000_000_000,
bundle_path: PathBuf::from("/tmp/x.tar.zst"),
bundle_size_bytes: 4096,
bundle_sha256: "ab".repeat(32),
git_oid: None,
schema_versions: nexo_memory_snapshot::SchemaVersions::CURRENT,
encrypted: false,
redactions_applied: req.redact_secrets,
})
}
async fn restore(&self, _req: RestoreRequest) -> Result<RestoreReport, SnapshotError> {
unimplemented!()
}
async fn list(
&self,
_agent_id: &AgentId,
_tenant: &str,
) -> Result<Vec<SnapshotMeta>, SnapshotError> {
Ok(Vec::new())
}
async fn diff(
&self,
_agent_id: &AgentId,
_tenant: &str,
_a: SnapshotId,
_b: SnapshotId,
) -> Result<SnapshotDiff, SnapshotError> {
unimplemented!()
}
async fn verify(&self, _bundle: &Path) -> Result<VerifyReport, SnapshotError> {
unimplemented!()
}
async fn delete(
&self,
_agent_id: &AgentId,
_tenant: &str,
_id: SnapshotId,
) -> Result<(), SnapshotError> {
Ok(())
}
async fn export(
&self,
_agent_id: &AgentId,
_tenant: &str,
_id: SnapshotId,
target: &Path,
) -> Result<PathBuf, SnapshotError> {
Ok(target.to_path_buf())
}
}
fn ctx() -> AgentContext {
let cfg = Arc::new(AgentConfig {
id: "ana".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: vec![],
skills_dir: "./skills".into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: Default::default(),
workspace_git: Default::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
outbound_allowlist: Default::default(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: Vec::new(),
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
});
let broker = AnyBroker::local();
let sessions = Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 20));
AgentContext::new("ana", cfg, broker, sessions)
}
#[tokio::test]
async fn tool_def_is_stable() {
let def = MemorySnapshotTool::tool_def();
assert_eq!(def.name, "memory_snapshot");
assert!(def.description.contains("snapshot"));
let schema = def.parameters.to_string();
assert!(schema.contains("\"label\""));
assert!(schema.contains("\"redact_secrets\""));
assert!(schema.contains("\"additionalProperties\":false"));
}
#[tokio::test]
async fn is_deferred_returns_true_for_token_budget() {
assert!(MemorySnapshotTool::is_deferred());
}
#[tokio::test]
async fn call_dispatches_with_label_and_redact_default() {
let inner = Arc::new(CapturingSnapshotter {
last_label: Mutex::new(None),
last_redact: Mutex::new(None),
last_created_by: Mutex::new(None),
});
let tool = MemorySnapshotTool::new(inner.clone() as Arc<dyn MemorySnapshotter>);
let out = tool
.call(&ctx(), json!({ "label": "manual" }))
.await
.unwrap();
assert!(out.get("snapshot_id").is_some());
assert_eq!(
out.get("bundle_size_bytes").and_then(|v| v.as_u64()),
Some(4096)
);
assert_eq!(inner.last_label.lock().unwrap().as_deref(), Some("manual"));
assert_eq!(*inner.last_redact.lock().unwrap(), Some(true));
assert_eq!(
inner.last_created_by.lock().unwrap().as_deref(),
Some("tool")
);
}
#[tokio::test]
async fn call_honors_redact_override_from_args() {
let inner = Arc::new(CapturingSnapshotter {
last_label: Mutex::new(None),
last_redact: Mutex::new(None),
last_created_by: Mutex::new(None),
});
let tool = MemorySnapshotTool::new(inner.clone() as Arc<dyn MemorySnapshotter>);
tool.call(&ctx(), json!({ "redact_secrets": false }))
.await
.unwrap();
assert_eq!(*inner.last_redact.lock().unwrap(), Some(false));
}
#[tokio::test]
async fn with_tenant_threads_through_to_request() {
let inner = Arc::new(CapturingSnapshotter {
last_label: Mutex::new(None),
last_redact: Mutex::new(None),
last_created_by: Mutex::new(None),
});
struct TenantCapture {
last_tenant: Mutex<Option<String>>,
}
#[async_trait]
impl MemorySnapshotter for TenantCapture {
async fn snapshot(&self, req: SnapshotRequest) -> Result<SnapshotMeta, SnapshotError> {
*self.last_tenant.lock().unwrap() = Some(req.tenant.clone());
Ok(SnapshotMeta {
id: SnapshotId::new(),
agent_id: req.agent_id,
tenant: req.tenant,
label: None,
created_at_ms: 0,
bundle_path: PathBuf::from("/x"),
bundle_size_bytes: 0,
bundle_sha256: String::new(),
git_oid: None,
schema_versions: nexo_memory_snapshot::SchemaVersions::CURRENT,
encrypted: false,
redactions_applied: false,
})
}
async fn restore(&self, _: RestoreRequest) -> Result<RestoreReport, SnapshotError> {
unimplemented!()
}
async fn list(&self, _: &AgentId, _: &str) -> Result<Vec<SnapshotMeta>, SnapshotError> {
Ok(Vec::new())
}
async fn diff(
&self,
_: &AgentId,
_: &str,
_: SnapshotId,
_: SnapshotId,
) -> Result<SnapshotDiff, SnapshotError> {
unimplemented!()
}
async fn verify(&self, _: &Path) -> Result<VerifyReport, SnapshotError> {
unimplemented!()
}
async fn delete(
&self,
_: &AgentId,
_: &str,
_: SnapshotId,
) -> Result<(), SnapshotError> {
Ok(())
}
async fn export(
&self,
_: &AgentId,
_: &str,
_: SnapshotId,
t: &Path,
) -> Result<PathBuf, SnapshotError> {
Ok(t.to_path_buf())
}
}
let _ = inner; let cap = Arc::new(TenantCapture {
last_tenant: Mutex::new(None),
});
let tool =
MemorySnapshotTool::new(cap.clone() as Arc<dyn MemorySnapshotter>).with_tenant("acme");
tool.call(&ctx(), json!({})).await.unwrap();
assert_eq!(cap.last_tenant.lock().unwrap().as_deref(), Some("acme"));
}
}