memory tool: tree names the door past the fence
reflection's context now registers all four trees, and a tree param on the memory verb lands reads and writes in the named memfs, committed under her own name. an unknown tree refuses and lists the doors, so a typo costs one error instead of a wrong-tree write
This commit is contained in:
parent
49c6a6a8dd
commit
04ceac79bf
5 changed files with 194 additions and 21 deletions
|
|
@ -1188,9 +1188,14 @@ fn fire_memfs_commit(ctx: &ToolContext, repo: &MemoryRepo, op: &str, paths: &[&s
|
|||
/// When `ctx` is `Some` and carries an `agent_id`, that takes precedence over
|
||||
/// environment variables. Falls back to env vars when no context is provided,
|
||||
/// preserving backward compatibility with the HTTP server path.
|
||||
///
|
||||
/// `tree` names one of the context's memory trees (primary, subconscious,
|
||||
/// reflection, archivist). `None` keeps the historical behaviour: the
|
||||
/// context's single `memory_root`.
|
||||
pub async fn execute_memory_command_with_context(
|
||||
cmd: &MemoryCommand,
|
||||
ctx: Option<&ToolContext>,
|
||||
tree: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// Agent ID resolution: context > command > env var > default
|
||||
let agent_id = ctx
|
||||
|
|
@ -1204,10 +1209,36 @@ pub async fn execute_memory_command_with_context(
|
|||
.or_else(|| std::env::var("AGENT_ID").ok())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
// Memory root resolution: use memory_root from context when available
|
||||
let repo = match ctx.and_then(|c| c.memory_root.as_ref()) {
|
||||
Some(root) => MemoryRepo::open(&agent_id, root.clone()),
|
||||
None => MemoryRepo::new_default(&agent_id),
|
||||
// Memory root resolution: a named tree wins, then the context's
|
||||
// memory_root, then the default. A name this context does not know is a
|
||||
// refusal that names the doors, so a mistyped `tree` costs one error
|
||||
// instead of a wrong-tree write.
|
||||
let repo = match tree {
|
||||
Some(name) => {
|
||||
let root = ctx
|
||||
.and_then(|c| c.memory_tree(name).cloned())
|
||||
.ok_or_else(|| {
|
||||
let known = ctx
|
||||
.map(|c| {
|
||||
c.memory_trees
|
||||
.iter()
|
||||
.map(|(n, _)| n.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if known.is_empty() {
|
||||
anyhow!("Unknown memory tree `{name}` — no named trees here")
|
||||
} else {
|
||||
anyhow!("Unknown memory tree `{name}` — known: {known}")
|
||||
}
|
||||
})?;
|
||||
MemoryRepo::open(&agent_id, root)
|
||||
}
|
||||
None => match ctx.and_then(|c| c.memory_root.as_ref()) {
|
||||
Some(root) => MemoryRepo::open(&agent_id, root.clone()),
|
||||
None => MemoryRepo::new_default(&agent_id),
|
||||
},
|
||||
};
|
||||
|
||||
match cmd {
|
||||
|
|
@ -1373,7 +1404,7 @@ pub async fn execute_memory_command_with_context(
|
|||
/// Execute a memory command, reading agent identity from env vars.
|
||||
/// Delegates to `execute_memory_command_with_context` with `None`.
|
||||
pub async fn execute_memory_command(cmd: &MemoryCommand) -> Result<String> {
|
||||
execute_memory_command_with_context(cmd, None).await
|
||||
execute_memory_command_with_context(cmd, None, None).await
|
||||
}
|
||||
|
||||
// ── Tool Result Bridge ──────────────────────────────────────────────────────
|
||||
|
|
@ -1391,6 +1422,7 @@ pub async fn handle_memory_tool_with_context(
|
|||
) -> ToolResult {
|
||||
let tool_use_id = format!("tool-u-{}", chrono::Utc::now().timestamp_millis());
|
||||
let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let tree = input.get("tree").and_then(|v| v.as_str());
|
||||
|
||||
let cmd = match command {
|
||||
"read" => {
|
||||
|
|
@ -1481,7 +1513,7 @@ pub async fn handle_memory_tool_with_context(
|
|||
}
|
||||
};
|
||||
|
||||
match execute_memory_command_with_context(&cmd, ctx).await {
|
||||
match execute_memory_command_with_context(&cmd, ctx, tree).await {
|
||||
Ok(output) => ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: tool_name.to_string(),
|
||||
|
|
@ -1529,7 +1561,9 @@ Subcommands:
|
|||
sync — Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how.
|
||||
audit — List frontmatter-bound files missing the required `description` (read-only). `write`/`append` reject a missing description, so this is how I find what to heal.
|
||||
|
||||
Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(),
|
||||
Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.
|
||||
|
||||
`tree` names which memory tree I am reaching into. Default is my own. When I am a cadence I may name `primary`, `subconscious`, `reflection`, or `archivist` — each is a real tree with its own git history, and a write there is committed under my own name.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1554,6 +1588,10 @@ Paths are relative to my memory directory. Frontmatter description is required o
|
|||
"type": "string",
|
||||
"enum": ["microcompact", "sliding_window", "sliding_reflect", "summary", "cull"],
|
||||
"description": "Compaction strategy (for compact subcommand). microcompact (clears old tool output, drops nothing), cull (drops throwaways), sliding_window (fast, drops the middle blind), sliding_reflect (the same slide, but a pass reads the middle first and carries its threads forward), summary (LLM, highest fidelity)"
|
||||
},
|
||||
"tree": {
|
||||
"type": "string",
|
||||
"description": "Which memory tree to reach into. Optional — defaults to my own tree. Cadences may name primary, subconscious, reflection, or archivist."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
|
|
@ -2168,4 +2206,110 @@ mod tests {
|
|||
let missing = repo.audit().await.unwrap();
|
||||
assert_eq!(missing, vec!["issues/missing.md".to_string()]);
|
||||
}
|
||||
|
||||
/// The `tree` param is the fence opening: a write aimed at a named tree
|
||||
/// lands in that tree's own git history, and reads without a tree stay in
|
||||
/// the default root — the two never share a file.
|
||||
#[tokio::test]
|
||||
async fn memory_tool_tree_names_the_target_root() {
|
||||
let default_dir = TempDir::new().unwrap();
|
||||
let primary_dir = TempDir::new().unwrap();
|
||||
MemoryRepo::new("test-agent", default_dir.path())
|
||||
.init()
|
||||
.await
|
||||
.unwrap();
|
||||
MemoryRepo::new("test-agent", primary_dir.path())
|
||||
.init()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut ctx = ToolContext::new();
|
||||
ctx.agent_id = Some("test-agent".to_string());
|
||||
ctx.memory_root = Some(default_dir.path().to_path_buf());
|
||||
ctx.memory_trees = vec![("primary".to_string(), primary_dir.path().to_path_buf())];
|
||||
|
||||
let write = handle_memory_tool_with_context(
|
||||
"memory",
|
||||
&serde_json::json!({
|
||||
"command": "write",
|
||||
"path": "journal/2026/05/20.md",
|
||||
"content": "default root note"
|
||||
}),
|
||||
Some(&ctx),
|
||||
)
|
||||
.await;
|
||||
assert!(!write.is_error, "{}", write.output);
|
||||
|
||||
let write_primary = handle_memory_tool_with_context(
|
||||
"memory",
|
||||
&serde_json::json!({
|
||||
"command": "write",
|
||||
"path": "journal/2026/05/20.md",
|
||||
"content": "primary root note",
|
||||
"tree": "primary"
|
||||
}),
|
||||
Some(&ctx),
|
||||
)
|
||||
.await;
|
||||
assert!(!write_primary.is_error, "{}", write_primary.output);
|
||||
|
||||
let read_default = handle_memory_tool_with_context(
|
||||
"memory",
|
||||
&serde_json::json!({ "command": "read", "path": "journal/2026/05/20.md" }),
|
||||
Some(&ctx),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
read_default.output.contains("default root note"),
|
||||
"{}",
|
||||
read_default.output
|
||||
);
|
||||
|
||||
let read_primary = handle_memory_tool_with_context(
|
||||
"memory",
|
||||
&serde_json::json!({
|
||||
"command": "read",
|
||||
"path": "journal/2026/05/20.md",
|
||||
"tree": "primary"
|
||||
}),
|
||||
Some(&ctx),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
read_primary.output.contains("primary root note"),
|
||||
"{}",
|
||||
read_primary.output
|
||||
);
|
||||
assert!(!read_primary.output.contains("default root note"));
|
||||
}
|
||||
|
||||
/// A mistyped door refuses and names the doors that exist — a wrong-tree
|
||||
/// write is worse than an error.
|
||||
#[tokio::test]
|
||||
async fn memory_tool_unknown_tree_refuses_with_the_known_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
MemoryRepo::new("test-agent", dir.path())
|
||||
.init()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut ctx = ToolContext::new();
|
||||
ctx.agent_id = Some("test-agent".to_string());
|
||||
ctx.memory_root = Some(dir.path().to_path_buf());
|
||||
ctx.memory_trees = vec![("primary".to_string(), dir.path().to_path_buf())];
|
||||
|
||||
let result = handle_memory_tool_with_context(
|
||||
"memory",
|
||||
&serde_json::json!({
|
||||
"command": "read",
|
||||
"path": "system/persona.md",
|
||||
"tree": "primry"
|
||||
}),
|
||||
Some(&ctx),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_error, "{}", result.output);
|
||||
assert!(result.output.contains("primry"), "{}", result.output);
|
||||
assert!(result.output.contains("primary"), "{}", result.output);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,31 +163,39 @@ impl ReflectionEngine {
|
|||
})
|
||||
.collect();
|
||||
|
||||
// ── ToolContext: her own name, the ledgers' tree ──
|
||||
// ── ToolContext: her own name, the ledgers' tree, every door ──
|
||||
// `MemoryRepo` signs commits with the context's agent id, so opening
|
||||
// the subconscious's root under the reflection id is what makes an
|
||||
// N+25 conclusion legible as one in `git log` instead of arriving as
|
||||
// something the subconscious noticed a moment after the turn.
|
||||
//
|
||||
// One root, and it is this one. The prompt used to promise the primary
|
||||
// memfs was reachable "through the `memory` tool" — `ToolContext`
|
||||
// carries a single `memory_root` and there is no verb that names
|
||||
// another, so every attempt landed in the wrong tree and spent a round.
|
||||
// Her default root is the ledger. The `memory` tool's `tree:` names
|
||||
// the other three, so the primary memfs is a deliberate choice rather
|
||||
// than the broken promise it used to be (one `memory_root`, no verb
|
||||
// that named another, every attempt landed in the wrong tree).
|
||||
let ledger_root = self
|
||||
.agents
|
||||
.cadence_memory_root(primary_id, Cadence::Subconscious);
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let env: Vec<(String, String)> = std::env::vars().collect();
|
||||
|
||||
let tool_ctx =
|
||||
ToolContext::for_agent(own_id.clone(), cwd, Some(ledger_root.clone()), env, None);
|
||||
|
||||
// She wakes as herself: her pinned `system/` whole and in order, the
|
||||
// ledger window, then this cadence's persona and mandate. The seeded
|
||||
// floor below only covers the mandate she has not written yet.
|
||||
let cadence_root = self
|
||||
.agents
|
||||
.cadence_memory_root(primary_id, Cadence::Reflection);
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let env: Vec<(String, String)> = std::env::vars().collect();
|
||||
|
||||
let mut tool_ctx =
|
||||
ToolContext::for_agent(own_id.clone(), cwd, Some(ledger_root.clone()), env, None);
|
||||
tool_ctx.memory_trees = vec![
|
||||
("primary".to_string(), self.agents.memory_root(primary_id)),
|
||||
("subconscious".to_string(), ledger_root.clone()),
|
||||
("reflection".to_string(), cadence_root.clone()),
|
||||
(
|
||||
"archivist".to_string(),
|
||||
self.agents.cadence_memory_root(primary_id, Cadence::Archivist),
|
||||
),
|
||||
];
|
||||
let identity = crate::core::prompt::build_cadence_prompt(
|
||||
&self.agents.memory_root(primary_id),
|
||||
&cadence_root,
|
||||
|
|
@ -203,9 +211,12 @@ impl ReflectionEngine {
|
|||
};
|
||||
let user_content = format!(
|
||||
"The last {turns_reviewed} turns. `[user]` is the human; `[assistant]` \
|
||||
is me, in the moment, before I had the distance I have now.\n\n\
|
||||
My ledgers are at `{ledger_root}` and the `memory` tool reaches them.\n\n\
|
||||
<transcript>\n{transcript}\n</transcript>",
|
||||
is me, in the moment, before I had the distance I have now.\n\n\
|
||||
My ledgers are at `{ledger_root}` and the `memory` tool reaches them \
|
||||
by default. Name a door when I need one: `tree: \"primary\"` for the \
|
||||
primary memfs, `tree: \"reflection\"` for my own tree, \
|
||||
`tree: \"archivist\"` for the memoir register.\n\n\
|
||||
<transcript>\n{transcript}\n</transcript>",
|
||||
ledger_root = ledger_root.display(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ mod tests {
|
|||
ToolContext {
|
||||
cwd: None,
|
||||
memory_root: None,
|
||||
memory_trees: Vec::new(),
|
||||
env: Vec::new(),
|
||||
agent_id: None,
|
||||
subagent_runner: None,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ pub struct ToolOutput {
|
|||
pub struct ToolContext {
|
||||
/// The agent's memory directory root.
|
||||
pub memory_root: Option<PathBuf>,
|
||||
/// Named memory trees beyond the default root — the doors a cadence can
|
||||
/// deliberately open. `memory` tool accepts a `tree` name; everything
|
||||
/// else keeps using `memory_root`.
|
||||
pub memory_trees: Vec<(String, PathBuf)>,
|
||||
/// Current working directory (bash tracks this).
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Current environment variables.
|
||||
|
|
@ -59,6 +63,7 @@ impl Clone for ToolContext {
|
|||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
memory_root: self.memory_root.clone(),
|
||||
memory_trees: self.memory_trees.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
env: self.env.clone(),
|
||||
agent_id: self.agent_id.clone(),
|
||||
|
|
@ -74,6 +79,7 @@ impl std::fmt::Debug for ToolContext {
|
|||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ToolContext")
|
||||
.field("memory_root", &self.memory_root)
|
||||
.field("memory_trees", &self.memory_trees)
|
||||
.field("cwd", &self.cwd)
|
||||
.field("env_len", &self.env.len())
|
||||
.field("agent_id", &self.agent_id)
|
||||
|
|
@ -95,6 +101,7 @@ impl ToolContext {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
memory_root: None,
|
||||
memory_trees: Vec::new(),
|
||||
cwd: None,
|
||||
env: Vec::new(),
|
||||
agent_id: None,
|
||||
|
|
@ -160,6 +167,7 @@ impl ToolContext {
|
|||
|
||||
Self {
|
||||
memory_root,
|
||||
memory_trees: Vec::new(),
|
||||
cwd,
|
||||
env,
|
||||
agent_id: Some(agent_id_str),
|
||||
|
|
@ -184,6 +192,14 @@ impl ToolContext {
|
|||
.is_some_and(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
/// Root of one named memory tree, if this context knows that door.
|
||||
pub fn memory_tree(&self, name: &str) -> Option<&PathBuf> {
|
||||
self.memory_trees
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, root)| root)
|
||||
}
|
||||
|
||||
/// Resolve a path relative to cwd if it's relative.
|
||||
pub fn resolve_path(&self, path: &std::path::Path) -> PathBuf {
|
||||
if path.is_relative() {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ impl Sensorium {
|
|||
context: ToolContext {
|
||||
cwd,
|
||||
memory_root,
|
||||
memory_trees: Vec::new(),
|
||||
env,
|
||||
agent_id: None,
|
||||
subagent_runner: None,
|
||||
|
|
|
|||
Loading…
Reference in a new issue