Skip to content

memoryTool — file-backed memory

Rust · crate toolnexus · SPEC §7E · rust/src/agents/home.rs

pub fn memory_tool(dir: impl AsRef<Path>) -> Tool // toolnexus::agents
// input: { action: "add" | "replace" | "remove", target?: "self" | "user", text: string, with?: string }

memory_tool returns a single memory tool with three actions — add, replace, remove — over two files in dir: MEMORY.md (target: "self", the default) and USER.md (target: "user"). Every action writes to disk immediately. A replace/remove whose text substring is not found in the target file is a loud is_error: true. Crucially, writing does not change the current session’s soul — it takes effect at the start of the next session, when compose_soul re-reads the directory. This is the frozen-snapshot rule that keeps a long-lived persona cache-stable within a run.

Reach for memory_tool directly when you’re assembling a persona’s tool list by hand (via agents::new rather than from_dir) and want the same file-backed memory builtin from_dir wires automatically. It’s also the tool to point at when writing a HEARTBEAT.md that asks the agent to persist facts — “fold notes into MEMORY.md via the memory tool” is the entire recipe behind dream/consolidation patterns.

1. The smallest useful call — add an entry, read it back from disk

Section titled “1. The smallest useful call — add an entry, read it back from disk”
use serde_json::json;
use toolnexus::agents::memory_tool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join(format!("memtool-mt1-{}", std::process::id()));
std::fs::create_dir_all(&dir)?;
let mem = memory_tool(&dir);
assert_eq!(mem.name, "memory");
let res = mem.call(json!({"action": "add", "text": "prefers metric units"}), Default::default()).await;
assert!(!res.is_error, "{}", res.output);
assert_eq!(std::fs::read_to_string(dir.join("MEMORY.md"))?, "- prefers metric units\n");
std::fs::remove_dir_all(&dir)?;
println!("ok: {}", res.output);
Ok(())
}

2. The realistic case — replace, remove, and the loud miss

Section titled “2. The realistic case — replace, remove, and the loud miss”

replace/remove on text that isn’t present is a real is_error, not a silent no-op — the model sees the miss and can retry with the right substring.

use serde_json::json;
use toolnexus::agents::memory_tool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join(format!("memtool-mt2-{}", std::process::id()));
std::fs::create_dir_all(&dir)?;
let mem = memory_tool(&dir);
mem.call(json!({"action": "add", "text": "timezone: UTC"}), Default::default()).await;
let replaced = mem
.call(json!({"action": "replace", "text": "UTC", "with": "IST"}), Default::default())
.await;
assert!(!replaced.is_error, "{}", replaced.output);
let on_disk = std::fs::read_to_string(dir.join("MEMORY.md"))?;
assert_eq!(on_disk, "- timezone: IST\n");
let miss = mem.call(json!({"action": "remove", "text": "nonexistent phrase"}), Default::default()).await;
assert!(miss.is_error, "a remove miss must be a loud is_error");
assert_eq!(std::fs::read_to_string(dir.join("MEMORY.md"))?, on_disk, "file unchanged on a miss");
std::fs::remove_dir_all(&dir)?;
println!("ok: replace -> {} | miss -> {}", on_disk.trim(), miss.output);
Ok(())
}

3. The full surface — target: "user" writes USER.md, and next-session-only semantics

Section titled “3. The full surface — target: "user" writes USER.md, and next-session-only semantics”

Writing memory during a run does not retroactively change that run’s soul — a fresh compose_soul call (the next session) is what picks it up.

use serde_json::json;
use toolnexus::agents::{compose_soul, memory_tool};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join(format!("memtool-mt3-{}", std::process::id()));
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("SOUL.md"), "You are Kip.")?;
// Session 1: compose the soul BEFORE any memory write — MEMORY.md does not exist yet.
let (soul_before, found_before) = compose_soul(&dir);
assert!(!found_before.iter().any(|f| f == "MEMORY.md"));
let mem = memory_tool(&dir);
mem.call(json!({"action": "add", "target": "user", "text": "name is Muthu"}), Default::default()).await;
mem.call(json!({"action": "add", "text": "always reply tersely"}), Default::default()).await;
// The write does NOT retroactively change soul_before — it's a frozen snapshot.
assert!(!soul_before.contains("MEMORY.md"));
assert_eq!(std::fs::read_to_string(dir.join("USER.md"))?, "- name is Muthu\n");
// Session 2: composing again picks up both new files.
let (soul_after, found_after) = compose_soul(&dir);
assert_eq!(found_after, vec!["SOUL.md", "USER.md", "MEMORY.md"]);
assert!(soul_after.contains("always reply tersely") && soul_after.contains("name is Muthu"));
std::fs::remove_dir_all(&dir)?;
println!("ok: memory writes land on disk immediately, load next session: {found_after:?}");
Ok(())
}
action Effect Target file (target)
add Appends "- <text>\n" to the file. self ⇒ MEMORY.md (default); user ⇒ USER.md
replace Swaps the first occurrence of text with with. is_error if text is absent. same
remove Deletes the first occurrence of text. is_error if text is absent. same
  • compose_soul — Build a persona’s system prompt from its home directory: identity, memory, skills.
  • from_dir — Point at an agent home directory and get a configured agent back — wires this tool for you.