refactor(tinycortex): W7 — shim memory_tools over the crate tool_memory engine (#4789)

This commit is contained in:
Steven Enamakel
2026-07-11 15:48:16 -07:00
committed by GitHub
parent 4a4d0f32b1
commit 3fdd411da1
11 changed files with 198 additions and 1115 deletions
@@ -1,7 +1,7 @@
//! Utility helpers used during agent construction.
use crate::openhuman::memory::Memory;
use crate::openhuman::memory_tools::{ToolMemoryRule, ToolMemoryStore};
use crate::openhuman::memory_tools::{tool_memory_store, ToolMemoryRule, ToolMemoryStore};
use std::sync::Arc;
/// (#1400) Best-effort synchronous prefetch of eager tool-scoped rules.
@@ -33,7 +33,7 @@ pub(super) fn prefetch_tool_memory_rules_blocking(
let tool_names = tool_names.to_vec();
tokio::task::block_in_place(|| {
handle.block_on(async move {
let store = ToolMemoryStore::new(memory);
let store = tool_memory_store(memory);
match store.rules_for_prompt(&tool_names).await {
Ok(grouped) => {
let mut flat: Vec<_> = grouped.into_values().flatten().collect();
+2 -2
View File
@@ -10,7 +10,7 @@ use serde::Deserialize;
use serde_json::Value;
use crate::openhuman::memory_tools::{
ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, ToolMemoryStore,
tool_memory_store, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, ToolMemoryStore,
};
use crate::rpc::RpcOutcome;
@@ -62,7 +62,7 @@ pub struct ToolRulesForPromptParams {
async fn open_store() -> Result<ToolMemoryStore, String> {
let client = active_memory_client().await?;
Ok(ToolMemoryStore::new(client.memory_handle()))
Ok(tool_memory_store(client.memory_handle()))
}
/// Upsert a tool-scoped memory rule.
+5 -6
View File
@@ -13,13 +13,12 @@ Each tool gets its own namespace `tool-{tool_name}`. Build the string via
| Path | Role |
| --- | --- |
| [`mod.rs`](mod.rs) | Module root + public re-exports. |
| [`types.rs`](types.rs) | `ToolMemoryRule` (id, tool_name, rule text, priority, source, tags, created_at, updated_at) + `ToolMemoryPriority` (Normal / High / Critical) + `ToolMemorySource` (UserExplicit / PostTurn / Programmatic) + `tool_memory_namespace(tool_name)`. |
| [`store.rs`](store.rs) | `ToolMemoryStore` over `Arc<dyn Memory>`: `put_rule`, `get_rule`, `list_rules`, `delete_rule`, `rules_for_prompt`, `list_tool_names`, `record`, `list_rules_json`. |
| [`store_tests.rs`](store_tests.rs) | Store coverage against the `MockMemory` from `test_helpers`. |
| [`capture.rs`](capture.rs) | `ToolMemoryCaptureHook` `PostTurnHook` impl that captures user edicts and repeated tool failures into the store. |
| [`prompt.rs`](prompt.rs) | `ToolMemoryRulesSection` + `render_tool_memory_rules` — prompt section that pins Critical / High rules into the system prompt so they survive compression. `TOOL_MEMORY_HEADING` + `TOOL_MEMORY_PROMPT_CAP` constants. |
| [`types.rs`](types.rs) | **Shim** — re-exports `ToolMemoryRule` / `ToolMemoryPriority` / `ToolMemorySource` / `tool_memory_namespace` from `tinycortex::memory::tool_memory::types` (W7). |
| [`store.rs`](store.rs) | **Shim** — re-exports the crate `ToolMemoryStore` (`put_rule`, `get_rule`, `list_rules`, `delete_rule`, `rules_for_prompt`, `list_tool_names`, `record`, `list_rules_json`) + `tool_memory_store(Arc<dyn host::Memory>)`, which bridges the host `Memory` trait object to the crate `Memory` the store needs (host = crate + `sqlite_conn`, gap G1). |
| [`capture.rs`](capture.rs) | `ToolMemoryCaptureHook``PostTurnHook` impl that captures user edicts and repeated tool failures into the store (host-retained). |
| [`prompt.rs`](prompt.rs) | **Shim** — re-exports the crate `ToolMemoryRulesSection` + `render_tool_memory_rules` + `TOOL_MEMORY_HEADING`, and keeps the host `PromptSection` impl that plugs the section into the system-prompt builder. |
| [`tools/`](tools/) | Agent-facing read/write tools: `MemoryToolsListTool` (list rules for a tool), `MemoryToolsPutTool` (upsert a rule). |
| [`test_helpers.rs`](test_helpers.rs) | `#[cfg(test)]` `MockMemory` used by `store_tests` + `capture::tests`. |
| [`test_helpers.rs`](test_helpers.rs) | `#[cfg(test)]` `MockMemory` used by `capture::tests` (the store engine's own coverage lives in the crate). |
## How it fits
+7 -7
View File
@@ -33,7 +33,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use super::store::ToolMemoryStore;
use super::store::{tool_memory_store, ToolMemoryStore};
use super::types::{ToolMemoryPriority, ToolMemorySource};
use crate::openhuman::agent::hooks::{PostTurnHook, ToolCallRecord, TurnContext};
use crate::openhuman::memory::Memory;
@@ -52,7 +52,7 @@ impl ToolMemoryCaptureHook {
/// Build a new capture hook backed by the given memory.
pub fn new(memory: Arc<dyn Memory>, enabled: bool) -> Self {
Self {
store: ToolMemoryStore::new(memory),
store: tool_memory_store(memory),
enabled,
}
}
@@ -289,7 +289,7 @@ fn tool_aliases(tool_name: &str) -> Vec<&'static str> {
mod tests {
use super::*;
use crate::openhuman::agent::hooks::ToolCallRecord;
use crate::openhuman::memory_tools::store::ToolMemoryStore;
use crate::openhuman::memory_tools::store::tool_memory_store;
use crate::openhuman::memory_tools::test_helpers::MockMemory;
fn ctx_with(message: &str, tool_calls: Vec<ToolCallRecord>) -> TurnContext {
@@ -391,7 +391,7 @@ mod tests {
#[tokio::test]
async fn on_turn_complete_persists_critical_rule_for_user_edict() {
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
let store = ToolMemoryStore::new(memory.clone());
let store = tool_memory_store(memory.clone());
let hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
hook.on_turn_complete(&ctx_with(
@@ -411,7 +411,7 @@ mod tests {
#[tokio::test]
async fn on_turn_complete_no_op_when_disabled() {
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
let store = ToolMemoryStore::new(memory.clone());
let store = tool_memory_store(memory.clone());
let hook = ToolMemoryCaptureHook::from_store(store.clone(), false);
hook.on_turn_complete(&ctx_with(
"Never email Sarah.",
@@ -428,7 +428,7 @@ mod tests {
#[tokio::test]
async fn safety_case_never_email_sarah_pins_into_prompt_block() {
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
let store = ToolMemoryStore::new(memory.clone());
let store = tool_memory_store(memory.clone());
let hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
// 1. Capture the edict from a normal user turn.
@@ -466,7 +466,7 @@ mod tests {
#[tokio::test]
async fn on_turn_complete_records_repeated_failure_observation() {
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
let store = ToolMemoryStore::new(memory.clone());
let store = tool_memory_store(memory.clone());
let hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
hook.on_turn_complete(&ctx_with(
"Try again",
+1 -1
View File
@@ -43,5 +43,5 @@ pub mod types;
pub use capture::ToolMemoryCaptureHook;
pub use prompt::{render_tool_memory_rules, ToolMemoryRulesSection, TOOL_MEMORY_HEADING};
pub use store::{ToolMemoryStore, TOOL_MEMORY_PROMPT_CAP};
pub use store::{tool_memory_store, ToolMemoryStore, TOOL_MEMORY_PROMPT_CAP};
pub use types::{tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
+25 -172
View File
@@ -1,75 +1,32 @@
//! Prompt section that injects tool-scoped memory rules into the
//! system prompt.
//! Prompt section that injects tool-scoped memory rules into the system
//! prompt — thin host shim over `tinycortex::memory::tool_memory::render` (W7).
//!
//! ## Why a prompt section
//!
//! Mid-session compression rewrites the rolling chat buffer but never
//! the system prompt — that prompt is frozen for the whole session by
//! design (so the inference backend's prefix cache stays warm; see
//! [`crate::openhuman::agent::prompts::SystemPromptBuilder::build`]).
//! Mid-session compression rewrites the rolling chat buffer but never the
//! system prompt — that prompt is frozen for the whole session by design (so the
//! inference backend's prefix cache stays warm; see
//! [`crate::openhuman::agent::prompts::SystemPromptBuilder::build`]). Anything we
//! want to be **compression-resistant** therefore has to live in the system
//! prompt — exactly where Critical and High priority [`ToolMemoryRule`]s belong.
//!
//! Anything we want to be **compression-resistant** therefore has to
//! live in the system prompt. That is exactly where Critical and High
//! priority [`ToolMemoryRule`]s belong: a "never email Sarah" rule
//! cannot be silently dropped when the buffer fills up.
//! ## What this shim owns
//!
//! ## What gets rendered
//!
//! The section takes ownership of the caller-supplied list of rules
//! (already filtered to the eager priorities by
//! [`ToolMemoryStore::rules_for_prompt`]) at construction time, mirrors
//! the pattern used by
//! [`crate::openhuman::agent::prompts::ReflectionMemoryContextSection`].
//! Snapshot semantics — the rendered bytes are stable for the lifetime
//! of the session, preserving the inference backend's prefix cache hit.
//! The rendering (`render_tool_memory_rules`) and the section type
//! ([`ToolMemoryRulesSection`], a byte-stable at-construction snapshot) are the
//! crate's and are re-exported here. Host-retained: the [`PromptSection`] impl
//! that plugs the crate section into the host system-prompt builder — a host
//! trait we can implement for the crate type under the orphan rule.
//!
//! [`ToolMemoryRule`]: super::types::ToolMemoryRule
//! [`ToolMemoryStore::rules_for_prompt`]: super::store::ToolMemoryStore::rules_for_prompt
use anyhow::Result;
use super::types::{ToolMemoryPriority, ToolMemoryRule};
use crate::openhuman::context::prompt::{PromptContext, PromptSection};
/// Heading injected when at least one rule is present.
pub const TOOL_MEMORY_HEADING: &str = "## Tool-scoped rules";
/// Prompt section that renders an at-construction snapshot of
/// [`ToolMemoryRule`]s into the system prompt.
///
/// Construct via [`Self::new`] with the rules the session builder
/// pre-fetched from [`ToolMemoryStore::rules_for_prompt`].
///
/// [`ToolMemoryStore::rules_for_prompt`]: super::store::ToolMemoryStore::rules_for_prompt
pub struct ToolMemoryRulesSection {
rendered: String,
}
impl ToolMemoryRulesSection {
/// Build a section from a pre-fetched rule snapshot.
///
/// Rendering happens up-front so subsequent `build` calls — which
/// run once per system prompt assembly — are I/O-free and
/// deterministic.
pub fn new(rules: Vec<ToolMemoryRule>) -> Self {
Self {
rendered: render_tool_memory_rules(&rules),
}
}
/// Construct an empty section. Useful as a placeholder for builders
/// that always include the section name in their chain.
pub fn empty() -> Self {
Self {
rendered: String::new(),
}
}
/// Returns true when the section will emit no output.
pub fn is_empty(&self) -> bool {
self.rendered.trim().is_empty()
}
}
pub use tinycortex::memory::tool_memory::render::{
render_tool_memory_rules, ToolMemoryRulesSection, TOOL_MEMORY_HEADING,
};
impl PromptSection for ToolMemoryRulesSection {
fn name(&self) -> &str {
@@ -77,67 +34,9 @@ impl PromptSection for ToolMemoryRulesSection {
}
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
Ok(self.rendered.clone())
}
}
/// Pure rendering helper — public so callers that pre-render the block
/// (e.g. tests, dynamic prompt sources) can share the same logic.
pub fn render_tool_memory_rules(rules: &[ToolMemoryRule]) -> String {
if rules.is_empty() {
return String::new();
}
// Stable order: Critical first, then High; within a priority, by
// tool name, then by rule body. Callers may pass an already-sorted
// list (the store does), but rendering must not depend on that
// contract — the system prompt has to be byte-stable.
let mut sorted: Vec<&ToolMemoryRule> = rules.iter().collect();
sorted.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.tool_name.cmp(&b.tool_name))
.then_with(|| a.rule.cmp(&b.rule))
.then_with(|| a.id.cmp(&b.id))
});
let mut out = String::new();
out.push_str(TOOL_MEMORY_HEADING);
out.push_str("\n\n");
out.push_str(
"These rules are pinned by the user or by the safety pipeline. Treat \
every entry as a hard constraint when considering the matching tool — \
do not override them silently. Lower-priority guidance lives in the \
`tool-{name}` memory namespace and can be queried via `memory_recall` \
if needed.\n\n",
);
let mut current_tool: Option<&str> = None;
for rule in sorted {
if current_tool != Some(rule.tool_name.as_str()) {
if current_tool.is_some() {
out.push('\n');
}
out.push_str("### `");
out.push_str(rule.tool_name.as_str());
out.push_str("`\n");
current_tool = Some(rule.tool_name.as_str());
}
out.push_str("- ");
out.push_str(priority_marker(rule.priority));
out.push(' ');
out.push_str(rule.rule.trim());
out.push('\n');
}
out
}
fn priority_marker(priority: ToolMemoryPriority) -> &'static str {
match priority {
ToolMemoryPriority::Critical => "**[critical]**",
ToolMemoryPriority::High => "**[high]**",
ToolMemoryPriority::Normal => "**[normal]**",
// build() must not depend on PromptContext fields — it returns the
// at-construction snapshot verbatim so the inference prefix cache stays warm.
Ok(self.rendered().to_string())
}
}
@@ -147,7 +46,9 @@ mod tests {
use crate::openhuman::agent::prompts::types::{
LearnedContextData, PromptContext, ToolCallFormat,
};
use crate::openhuman::memory_tools::types::ToolMemorySource;
use crate::openhuman::memory_tools::types::{
ToolMemoryPriority, ToolMemoryRule, ToolMemorySource,
};
fn rule(tool: &str, body: &str, priority: ToolMemoryPriority) -> ToolMemoryRule {
ToolMemoryRule {
@@ -162,64 +63,16 @@ mod tests {
}
}
#[test]
fn renders_empty_when_no_rules() {
assert!(render_tool_memory_rules(&[]).is_empty());
}
#[test]
fn section_empty_returns_blank_build_output() {
let section = ToolMemoryRulesSection::empty();
assert!(section.is_empty());
}
#[test]
fn renders_heading_and_priority_markers() {
let rules = vec![
rule("email", "never email Sarah", ToolMemoryPriority::Critical),
rule("shell", "avoid sudo", ToolMemoryPriority::High),
];
let out = render_tool_memory_rules(&rules);
assert!(out.contains(TOOL_MEMORY_HEADING));
assert!(out.contains("### `email`"));
assert!(out.contains("### `shell`"));
assert!(out.contains("**[critical]**"));
assert!(out.contains("**[high]**"));
assert!(out.contains("never email Sarah"));
assert!(out.contains("avoid sudo"));
}
#[test]
fn renders_critical_before_high_regardless_of_input_order() {
let rules = vec![
rule("shell", "avoid sudo", ToolMemoryPriority::High),
rule("email", "never email Sarah", ToolMemoryPriority::Critical),
];
let out = render_tool_memory_rules(&rules);
let critical_pos = out.find("never email Sarah").unwrap();
let high_pos = out.find("avoid sudo").unwrap();
assert!(
critical_pos < high_pos,
"Critical rules must render before High; output:\n{out}"
);
}
#[test]
fn renders_byte_stable_output_for_identical_inputs() {
let rules = vec![
rule("email", "never email Sarah", ToolMemoryPriority::Critical),
rule("shell", "avoid sudo", ToolMemoryPriority::High),
];
let first = render_tool_memory_rules(&rules);
let again = render_tool_memory_rules(&rules);
assert_eq!(first, again);
}
#[test]
fn section_renders_via_prompt_section_trait() {
// build() must not depend on PromptContext fields — it returns
// the at-construction snapshot verbatim. We call it here to
// exercise the trait contract directly.
// Exercise the host PromptSection glue over the crate section: build()
// returns the at-construction snapshot regardless of PromptContext.
let section = ToolMemoryRulesSection::new(vec![rule(
"email",
"never email Sarah",
+141 -288
View File
@@ -1,307 +1,160 @@
//! Storage layer for [`ToolMemoryRule`]s.
//! Storage layer for tool-scoped rules — thin host shim over
//! `tinycortex::memory::tool_memory::store` (W7).
//!
//! Rules are persisted as KV rows in the tool's dedicated namespace
//! (`tool-{tool_name}`). KV storage is preferred over the document /
//! embedding pipeline because:
//! The store engine (put / get / list / delete / prompt over an
//! `Arc<dyn Memory>`) is the crate's. It is generic over the **crate** `Memory`
//! trait, while host call sites hold `Arc<dyn `[`crate::openhuman::memory::Memory`]`>`
//! — which is the crate trait *plus* the host-only `sqlite_conn()` escape hatch
//! (gap G1). [`HostMemoryBridge`] adapts one to the other (every method forwards
//! unchanged), so [`tool_memory_store`] builds a crate `ToolMemoryStore` over a
//! host backend without waiting on the W3 trait unification.
//!
//! - Tool guidance is short, structured, and benefits from exact key
//! lookup more than semantic retrieval.
//! - Writes never block on the local embedding model.
//! - Atomicity per rule is sufficient for safety-critical instructions.
//!
//! This module wraps an [`Arc<dyn Memory>`] handle (rather than the
//! concrete [`MemoryClient`]) so unit tests can swap in an in-memory
//! mock following the existing `tool_tracker` pattern.
//! Behaviour note: the deleted host engine had a `sqlite_conn` fast-path in
//! `list_rules` (a direct `memory_docs` query ordered by `updated_at`); the
//! crate engine uses the trait `list()` only. This is behaviour-equivalent —
//! the host already used `list()` as its fallback for connectionless backends —
//! and differs only by a negligible per-tool query cost.
use std::collections::HashMap;
use std::sync::Arc;
use rusqlite::params;
use serde_json::Value;
use async_trait::async_trait;
use super::types::{tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
use crate::openhuman::memory::{Memory, MemoryCategory};
use tinycortex::memory::Memory as CrateMemory;
use tinycortex::memory::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts};
/// Maximum number of rules surfaced into the system prompt at once.
///
/// Keeps the cache-friendly prefix bounded even when callers stash a long
/// list of Critical rules over time. Lower-priority rules are still
/// available via [`ToolMemoryStore::list_rules`].
pub const TOOL_MEMORY_PROMPT_CAP: usize = 30;
use crate::openhuman::memory::Memory;
/// High-level store for tool-scoped memory rules.
///
/// All methods operate on a single shared [`Arc<dyn Memory>`] backend.
/// Cheap to clone — the backend is reference-counted.
#[derive(Clone)]
pub struct ToolMemoryStore {
memory: Arc<dyn Memory>,
}
pub use tinycortex::memory::tool_memory::store::{ToolMemoryStore, TOOL_MEMORY_PROMPT_CAP};
impl ToolMemoryStore {
/// Build a new store over the given memory backend.
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
/// Presents a host [`Arc<dyn Memory>`] as the crate [`Memory`](CrateMemory) the
/// crate `ToolMemoryStore` is generic over. The host trait is the crate trait
/// plus `sqlite_conn`, so every method forwards verbatim (the value types are
/// already crate re-exports, so no conversion is needed).
struct HostMemoryBridge(Arc<dyn Memory>);
#[async_trait]
impl CrateMemory for HostMemoryBridge {
fn name(&self) -> &str {
self.0.name()
}
/// Upsert a rule and return the stored copy (with `updated_at`
/// refreshed).
///
/// If a rule with the same `(tool_name, id)` already exists, its
/// `created_at` is preserved. `tool_name` is sourced from the rule
/// itself to avoid storage/namespace skew.
pub async fn put_rule(&self, mut rule: ToolMemoryRule) -> Result<ToolMemoryRule, String> {
if rule.tool_name.trim().is_empty() {
return Err("tool_name is required".to_string());
}
if rule.rule.trim().is_empty() {
return Err("rule body is required".to_string());
}
if rule.id.trim().is_empty() {
rule.id = ToolMemoryRule::generate_id();
}
let namespace = tool_memory_namespace(&rule.tool_name);
let key = ToolMemoryRule::storage_key(&rule.id);
// Preserve created_at on upsert.
if let Some(existing) = self.fetch_rule(&namespace, &key).await? {
rule.created_at = existing.created_at;
}
rule.updated_at = chrono::Utc::now().to_rfc3339();
let content = serde_json::to_string(&rule).map_err(|e| e.to_string())?;
self.memory
.store(
&namespace,
&key,
&content,
MemoryCategory::Custom("tool_memory".into()),
None,
)
.await
.map_err(|e| format!("store tool rule: {e:#}"))?;
log::debug!(
"[tool-memory] put rule id={} tool={} priority={:?} source={:?}",
rule.id,
rule.tool_name,
rule.priority,
rule.source
);
Ok(rule)
}
/// Fetch a single rule by `(tool_name, id)`.
pub async fn get_rule(
&self,
tool_name: &str,
rule_id: &str,
) -> Result<Option<ToolMemoryRule>, String> {
let namespace = tool_memory_namespace(tool_name);
let key = ToolMemoryRule::storage_key(rule_id);
self.fetch_rule(&namespace, &key).await
}
/// List every rule registered for a tool, sorted by priority (high
/// first) and then `updated_at` descending.
pub async fn list_rules(&self, tool_name: &str) -> Result<Vec<ToolMemoryRule>, String> {
let namespace = tool_memory_namespace(tool_name);
let mut rules: Vec<ToolMemoryRule> = if let Some(conn) = self.memory.sqlite_conn() {
let conn = conn.lock();
let mut stmt = conn
.prepare(
"SELECT key, content
FROM memory_docs
WHERE namespace = ?1 AND key LIKE 'rule/%'
ORDER BY updated_at DESC",
)
.map_err(|e| format!("prepare tool rule list: {e}"))?;
let rows = stmt
.query_map(params![namespace], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| format!("query tool rule list: {e}"))?;
rows.filter_map(|row| match row {
Ok((key, content)) => match serde_json::from_str::<ToolMemoryRule>(&content) {
Ok(rule) => Some(rule),
Err(err) => {
log::warn!(
"[tool-memory] skipping malformed sqlite rule key={} tool={tool_name}: {err}",
key
);
None
}
},
Err(err) => {
log::warn!(
"[tool-memory] skipping unreadable sqlite rule row tool={tool_name}: {err}"
);
None
}
})
.collect()
} else {
let entries = self
.memory
.list(Some(&namespace), None, None)
.await
.map_err(|e| format!("list tool rules: {e:#}"))?;
entries
.into_iter()
.filter(|entry| entry.key.starts_with("rule/"))
.filter_map(
|entry| match serde_json::from_str::<ToolMemoryRule>(&entry.content) {
Ok(rule) => Some(rule),
Err(err) => {
log::warn!(
"[tool-memory] skipping malformed rule key={} tool={tool_name}: {err}",
entry.key
);
None
}
},
)
.collect()
};
rules.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.updated_at.cmp(&a.updated_at))
});
Ok(rules)
}
/// Delete a rule. Returns `true` if the rule existed.
pub async fn delete_rule(&self, tool_name: &str, rule_id: &str) -> Result<bool, String> {
let namespace = tool_memory_namespace(tool_name);
let key = ToolMemoryRule::storage_key(rule_id);
self.memory
.forget(&namespace, &key)
.await
.map_err(|e| format!("forget tool rule: {e:#}"))
}
/// Returns the set of rules whose [`ToolMemoryPriority`] indicates
/// they must be eagerly surfaced (Critical + High), grouped by tool
/// name. Result is bounded by [`TOOL_MEMORY_PROMPT_CAP`] entries
/// total — Critical rules are always preferred over High when the
/// cap is reached.
///
/// `tools` constrains which tool namespaces to inspect; passing an
/// empty slice scans every known tool namespace via
/// [`Memory::namespace_summaries`].
pub async fn rules_for_prompt(
&self,
tools: &[String],
) -> Result<HashMap<String, Vec<ToolMemoryRule>>, String> {
let tool_names = if tools.is_empty() {
self.list_tool_names().await?
} else {
tools
.iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect()
};
let mut collected: Vec<ToolMemoryRule> = Vec::new();
for tool in &tool_names {
let rules = self.list_rules(tool).await?;
collected.extend(rules.into_iter().filter(|r| r.priority.is_eager()));
}
// Critical first, then High; within a priority, freshest first.
collected.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.updated_at.cmp(&a.updated_at))
});
collected.truncate(TOOL_MEMORY_PROMPT_CAP);
let mut out: HashMap<String, Vec<ToolMemoryRule>> = HashMap::new();
for rule in collected {
out.entry(rule.tool_name.clone()).or_default().push(rule);
}
Ok(out)
}
/// Enumerate every tool that has at least one stored rule, by
/// inspecting namespace summaries and keeping only the `tool-…`
/// prefixed ones.
pub async fn list_tool_names(&self) -> Result<Vec<String>, String> {
let summaries = self
.memory
.namespace_summaries()
.await
.map_err(|e| format!("list tool namespaces: {e:#}"))?;
let mut out = Vec::new();
for summary in summaries {
if let Some(tool) = summary.namespace.strip_prefix("tool-") {
// Exclude empty names and the sentinel used for unscoped
// edicts captured before any tool call ran — those rules are
// not permanently associated with a real tool and must not be
// injected into prompt filtering for arbitrary sessions.
if !tool.is_empty() && tool != "__unscoped__" {
out.push(tool.to_string());
}
}
}
out.sort();
out.dedup();
Ok(out)
}
/// Convenience constructor: build a rule from caller-supplied fields
/// and persist it. Returns the stored rule.
pub async fn record(
&self,
tool_name: &str,
rule_body: &str,
priority: ToolMemoryPriority,
source: ToolMemorySource,
tags: Vec<String>,
) -> Result<ToolMemoryRule, String> {
let mut rule = ToolMemoryRule::new(tool_name, rule_body, priority, source);
rule.tags = tags;
self.put_rule(rule).await
}
/// Render rules for a single tool into a JSON value suitable for
/// passing through the RPC envelope. Sorted by priority desc.
pub async fn list_rules_json(&self, tool_name: &str) -> Result<Value, String> {
let rules = self.list_rules(tool_name).await?;
serde_json::to_value(rules).map_err(|e| e.to_string())
}
async fn fetch_rule(
async fn store(
&self,
namespace: &str,
key: &str,
) -> Result<Option<ToolMemoryRule>, String> {
let entry = self
.memory
.get(namespace, key)
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()> {
self.0
.store(namespace, key, content, category, session_id)
.await
.map_err(|e| format!("get tool rule: {e:#}"))?;
match entry {
Some(entry) => match serde_json::from_str::<ToolMemoryRule>(&entry.content) {
Ok(rule) => Ok(Some(rule)),
Err(err) => {
log::warn!("[tool-memory] malformed rule entry in {namespace}/{key}: {err}");
Ok(None)
}
},
None => Ok(None),
}
async fn store_with_taint(
&self,
namespace: &str,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
taint: MemoryTaint,
) -> anyhow::Result<()> {
self.0
.store_with_taint(namespace, key, content, category, session_id, taint)
.await
}
async fn recall(
&self,
query: &str,
limit: usize,
opts: RecallOpts<'_>,
) -> anyhow::Result<Vec<MemoryEntry>> {
self.0.recall(query, limit, opts).await
}
async fn recall_relevant_by_vector(
&self,
namespace: &str,
query: &str,
limit: usize,
min_vector_similarity: f64,
) -> anyhow::Result<Vec<(String, String)>> {
self.0
.recall_relevant_by_vector(namespace, query, limit, min_vector_similarity)
.await
}
async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
self.0.get(namespace, key).await
}
async fn list(
&self,
namespace: Option<&str>,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
// UnifiedMemory::list() lists *documents* and surfaces each document's
// title as the entry content — so tool rules, stored as JSON in
// `memory_docs`, can't be round-tripped back through it (the crate
// `list_rules` would fail to deserialize the title as a rule and drop
// it). When the backend exposes a raw connection — as UnifiedMemory
// does — read the real content straight from `memory_docs`, mirroring
// the fast-path the host `ToolMemoryStore` used before this engine moved
// to the crate. Connectionless backends (e.g. the test `MockMemory`)
// fall back to the trait `list()`, whose content is already faithful.
if let (Some(ns), Some(conn)) = (namespace, self.0.sqlite_conn()) {
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT document_id, key, content, taint \
FROM memory_docs WHERE namespace = ?1",
)?;
let rows = stmt.query_map([ns], |r| {
Ok(MemoryEntry {
id: r.get::<_, String>(0)?,
key: r.get::<_, String>(1)?,
content: r.get::<_, String>(2)?,
namespace: Some(ns.to_string()),
// These fields are unused by the sole consumer
// (`ToolMemoryStore::list_rules`, which reads key + content);
// taint is carried faithfully, the rest are placeholders.
category: MemoryCategory::Core,
timestamp: String::new(),
session_id: None,
score: None,
taint: MemoryTaint::from_db_str(&r.get::<_, String>(3)?),
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
return Ok(out);
}
self.0.list(namespace, category, session_id).await
}
async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result<bool> {
self.0.forget(namespace, key).await
}
async fn namespace_summaries(&self) -> anyhow::Result<Vec<NamespaceSummary>> {
self.0.namespace_summaries().await
}
async fn count(&self) -> anyhow::Result<usize> {
self.0.count().await
}
async fn health_check(&self) -> bool {
self.0.health_check().await
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
/// Build a crate [`ToolMemoryStore`] over a host memory backend, bridging the
/// host `Memory` trait object to the crate `Memory` the store requires.
pub fn tool_memory_store(memory: Arc<dyn Memory>) -> ToolMemoryStore {
ToolMemoryStore::new(Arc::new(HostMemoryBridge(memory)))
}
-373
View File
@@ -1,373 +0,0 @@
//! Tests for [`ToolMemoryStore`] — exercise the put/list/delete/prompt
//! surface against an in-memory mock backend.
use std::sync::Arc;
use super::*;
use crate::openhuman::memory_tools::test_helpers::MockMemory;
use crate::openhuman::memory_tools::types::{ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
fn fresh_store() -> ToolMemoryStore {
ToolMemoryStore::new(Arc::new(MockMemory::default()))
}
#[tokio::test]
async fn put_rule_rejects_blank_tool_name() {
let store = fresh_store();
let rule = ToolMemoryRule::new(
" ",
"body",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
);
let err = store.put_rule(rule).await.unwrap_err();
assert!(err.contains("tool_name"));
}
#[tokio::test]
async fn put_rule_rejects_blank_body() {
let store = fresh_store();
let rule = ToolMemoryRule::new(
"email",
" ",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
);
let err = store.put_rule(rule).await.unwrap_err();
assert!(err.contains("body"));
}
#[tokio::test]
async fn put_then_get_round_trip_returns_same_rule() {
let store = fresh_store();
let rule = store
.record(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec!["safety".into()],
)
.await
.unwrap();
let fetched = store.get_rule("email", &rule.id).await.unwrap().unwrap();
assert_eq!(fetched.tool_name, "email");
assert_eq!(fetched.rule, "never email Sarah");
assert_eq!(fetched.priority, ToolMemoryPriority::Critical);
assert_eq!(fetched.source, ToolMemorySource::UserExplicit);
assert_eq!(fetched.tags, vec!["safety".to_string()]);
}
#[tokio::test]
async fn put_rule_preserves_created_at_on_upsert() {
let store = fresh_store();
let mut rule = store
.record(
"email",
"rule body",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
let created_at = rule.created_at.clone();
// Mutate and re-put under the same id.
rule.rule = "updated rule body".into();
// Sleep a tiny amount to give the timestamp string a chance to change.
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let updated = store.put_rule(rule.clone()).await.unwrap();
assert_eq!(updated.created_at, created_at);
assert_ne!(updated.updated_at, created_at);
assert_eq!(updated.rule, "updated rule body");
}
#[tokio::test]
async fn list_rules_sorts_critical_first_then_freshest() {
let store = fresh_store();
store
.record(
"email",
"older normal",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
// Tiny sleep to ensure different updated_at strings.
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let crit = store
.record(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let high = store
.record(
"email",
"double-check the recipient",
ToolMemoryPriority::High,
ToolMemorySource::PostTurn,
vec![],
)
.await
.unwrap();
let rules = store.list_rules("email").await.unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].id, crit.id);
assert_eq!(rules[1].id, high.id);
assert_eq!(rules[2].rule, "older normal");
}
#[tokio::test]
async fn delete_rule_removes_only_target_rule() {
let store = fresh_store();
let rule = store
.record(
"shell",
"never run sudo",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
store
.record(
"shell",
"prefer tmux for long-running commands",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
let deleted = store.delete_rule("shell", &rule.id).await.unwrap();
assert!(deleted);
let remaining = store.list_rules("shell").await.unwrap();
assert_eq!(remaining.len(), 1);
assert_ne!(remaining[0].id, rule.id);
}
#[tokio::test]
async fn delete_rule_returns_false_when_missing() {
let store = fresh_store();
let deleted = store.delete_rule("shell", "does-not-exist").await.unwrap();
assert!(!deleted);
}
#[tokio::test]
async fn list_tool_names_returns_only_tool_prefixed_namespaces() {
// Direct-write a global memory entry so we can verify the filter.
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
memory
.store(
"global",
"noise",
"{}",
MemoryCategory::Custom("misc".into()),
None,
)
.await
.unwrap();
let store = ToolMemoryStore::new(memory);
store
.record(
"email",
"rule a",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
store
.record(
"shell",
"rule b",
ToolMemoryPriority::High,
ToolMemorySource::PostTurn,
vec![],
)
.await
.unwrap();
let mut tools = store.list_tool_names().await.unwrap();
tools.sort();
assert_eq!(tools, vec!["email".to_string(), "shell".to_string()]);
}
#[tokio::test]
async fn rules_for_prompt_returns_only_eager_priorities() {
let store = fresh_store();
store
.record(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
store
.record(
"email",
"double-check recipient",
ToolMemoryPriority::High,
ToolMemorySource::PostTurn,
vec![],
)
.await
.unwrap();
store
.record(
"email",
"we use BCC for newsletters",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
let rendered = store
.rules_for_prompt(&["email".to_string()])
.await
.unwrap();
let email_rules = rendered.get("email").expect("email rules present");
assert_eq!(email_rules.len(), 2, "Normal rule must not be eager");
assert!(email_rules
.iter()
.any(|r| r.priority == ToolMemoryPriority::Critical));
assert!(email_rules
.iter()
.any(|r| r.priority == ToolMemoryPriority::High));
assert!(email_rules
.iter()
.all(|r| r.priority != ToolMemoryPriority::Normal));
}
#[tokio::test]
async fn rules_for_prompt_scans_all_namespaces_when_caller_passes_empty_slice() {
let store = fresh_store();
store
.record(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
store
.record(
"shell",
"never run sudo",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec![],
)
.await
.unwrap();
let rendered = store.rules_for_prompt(&[]).await.unwrap();
assert!(rendered.contains_key("email"));
assert!(rendered.contains_key("shell"));
}
#[tokio::test]
async fn rules_for_prompt_caps_results() {
let store = fresh_store();
for idx in 0..(TOOL_MEMORY_PROMPT_CAP + 5) {
store
.record(
"email",
&format!("high rule {idx}"),
ToolMemoryPriority::High,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
}
let rendered = store
.rules_for_prompt(&["email".to_string()])
.await
.unwrap();
let count: usize = rendered.values().map(|v| v.len()).sum();
assert_eq!(count, TOOL_MEMORY_PROMPT_CAP);
}
#[tokio::test]
async fn list_rules_skips_malformed_entries() {
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
// Manually write a malformed entry under the tool namespace and a
// valid one alongside it, then confirm the bad entry is dropped.
memory
.store(
"tool-email",
"rule/bad",
"{not-valid-json",
MemoryCategory::Custom("tool_memory".into()),
None,
)
.await
.unwrap();
let store = ToolMemoryStore::new(memory);
store
.record(
"email",
"valid",
ToolMemoryPriority::High,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
let rules = store.list_rules("email").await.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].rule, "valid");
}
#[tokio::test]
async fn list_rules_json_serializes_payload_for_rpc_envelopes() {
let store = fresh_store();
store
.record(
"email",
"rule body",
ToolMemoryPriority::High,
ToolMemorySource::Programmatic,
vec![],
)
.await
.unwrap();
let json = store.list_rules_json("email").await.unwrap();
let arr = json.as_array().expect("expected an array");
assert_eq!(arr.len(), 1);
assert!(arr[0]
.get("priority")
.and_then(|v| v.as_str())
.map(|s| s == "high")
.unwrap_or(false));
}
#[tokio::test]
async fn put_rule_assigns_id_when_blank() {
let store = fresh_store();
let mut rule = ToolMemoryRule::new(
"email",
"rule body",
ToolMemoryPriority::Normal,
ToolMemorySource::Programmatic,
);
rule.id = String::new();
let stored = store.put_rule(rule).await.unwrap();
assert!(!stored.id.is_empty());
}
+2 -2
View File
@@ -5,7 +5,7 @@ use serde::Deserialize;
use serde_json::json;
use crate::openhuman::memory::ops::helpers::active_memory_client;
use crate::openhuman::memory_tools::ToolMemoryStore;
use crate::openhuman::memory_tools::{tool_memory_store, ToolMemoryStore};
use crate::openhuman::tools::traits::{Tool, ToolResult};
pub struct MemoryToolsListTool;
@@ -48,7 +48,7 @@ impl Tool for MemoryToolsListTool {
let client = active_memory_client()
.await
.map_err(|e| anyhow::anyhow!("memory_tools_list: {e}"))?;
let store = ToolMemoryStore::new(client.memory_handle());
let store = tool_memory_store(client.memory_handle());
let rules = store
.list_rules(&parsed.tool_name)
.await
+4 -4
View File
@@ -6,7 +6,7 @@ use serde_json::json;
use crate::openhuman::memory::ops::helpers::active_memory_client;
use crate::openhuman::memory_tools::{
ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, ToolMemoryStore,
tool_memory_store, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, ToolMemoryStore,
};
use crate::openhuman::tools::traits::{Tool, ToolResult};
@@ -82,7 +82,7 @@ impl Tool for MemoryToolsPutTool {
let client = active_memory_client()
.await
.map_err(|e| anyhow::anyhow!("memory_tools_put: {e}"))?;
let store = ToolMemoryStore::new(client.memory_handle());
let store = tool_memory_store(client.memory_handle());
let mut rule = ToolMemoryRule::new(
&parsed.tool_name,
&parsed.rule,
@@ -107,7 +107,7 @@ mod tests {
use tempfile::TempDir;
use crate::openhuman::config::{Config, TEST_ENV_LOCK};
use crate::openhuman::memory_tools::ToolMemoryStore;
use crate::openhuman::memory_tools::tool_memory_store;
use crate::openhuman::tools::traits::Tool;
use serde_json::json;
@@ -240,7 +240,7 @@ mod tests {
let client = crate::openhuman::memory::ops::helpers::active_memory_client()
.await
.expect("active memory client");
let store = ToolMemoryStore::new(client.memory_handle());
let store = tool_memory_store(client.memory_handle());
let rules = store.list_rules("bash").await.expect("list stored rules");
let stored = rules
.iter()
+9 -258
View File
@@ -1,260 +1,11 @@
//! Domain types for the tool-scoped memory layer.
//! Domain types for the tool-scoped memory layer — thin host re-export of
//! `tinycortex::memory::tool_memory::types` (W7).
//!
//! A [`ToolMemoryRule`] is a durable, actionable instruction attached to a
//! specific tool (e.g. `email`, `shell`, `web_search`). Unlike the per-tool
//! statistics stored in the `tool_effectiveness` namespace, these rules
//! capture **guidance** — corrections, safety constraints, and learned
//! operational rules that the agent should obey when considering or
//! invoking that tool.
//!
//! Rules carry a [`ToolMemoryPriority`] level so the retrieval pipeline can
//! distinguish safety-critical instructions from soft suggestions:
//!
//! - [`ToolMemoryPriority::Critical`] — pinned into the system prompt and
//! therefore not subject to mid-session context compression.
//! - [`ToolMemoryPriority::High`] — surfaced alongside critical rules at
//! tool-selection time.
//! - [`ToolMemoryPriority::Normal`] — available on demand via the recall
//! APIs, but not eagerly injected.
//! [`ToolMemoryRule`] / [`ToolMemoryPriority`] / [`ToolMemorySource`] and the
//! [`tool_memory_namespace`] helper are the crate's (a byte-identical port,
//! preserving the serde wire strings + `rule/{id}` storage keys). Host consumers
//! keep their `memory_tools::types::*` import paths unchanged.
use serde::{Deserialize, Serialize};
/// Priority/criticality of a [`ToolMemoryRule`].
///
/// Used by both storage (to filter what is pinned into the system prompt)
/// and retrieval (to sort high-priority guidance ahead of advisory notes).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ToolMemoryPriority {
/// Soft suggestion — surfaced on demand, not eagerly injected.
#[default]
Normal,
/// Important guidance — eagerly injected at tool-selection time.
High,
/// Safety-critical rule — pinned into the (compression-resistant)
/// system prompt so it survives the agent's full session.
Critical,
}
impl ToolMemoryPriority {
/// True for priorities that must be eagerly surfaced to the agent
/// (Critical/High rules are both pinned into the system prompt and
/// prefetched at session start, so they survive context compression).
pub fn is_eager(self) -> bool {
matches!(self, Self::Critical | Self::High)
}
}
/// Where a [`ToolMemoryRule`] originated from.
///
/// Recorded for provenance and so consumers (UI / debugging) can tell user
/// edicts apart from auto-captured observations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ToolMemorySource {
/// User explicitly asked the agent to remember this rule.
UserExplicit,
/// Captured automatically from a post-turn observation (tool failure,
/// repeated correction, etc.).
PostTurn,
/// Written by another subsystem (e.g. an integration provisioner).
#[default]
Programmatic,
}
/// A single tool-scoped memory rule.
///
/// Stored under the `tool-{tool_name}` namespace as a KV entry keyed by
/// `rule/{rule_id}`. The id is stable across updates so callers can
/// upsert by replaying the same id.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolMemoryRule {
/// Stable identifier within `(tool_name)`. Generated by callers via
/// [`ToolMemoryRule::generate_id`] when one is not supplied.
pub id: String,
/// Tool this rule applies to (e.g. `email`, `shell`).
pub tool_name: String,
/// Natural-language guidance that should reach the agent.
pub rule: String,
/// Criticality level for retrieval and compression behaviour.
#[serde(default)]
pub priority: ToolMemoryPriority,
/// Where this rule came from.
#[serde(default)]
pub source: ToolMemorySource,
/// Optional free-form tags for filtering (e.g. `safety`, `permission`).
#[serde(default)]
pub tags: Vec<String>,
/// RFC3339 timestamp of when the rule was first written.
pub created_at: String,
/// RFC3339 timestamp of the last update.
pub updated_at: String,
}
impl ToolMemoryRule {
/// Build a new rule with a freshly generated id and `created_at` /
/// `updated_at` set to "now".
pub fn new(
tool_name: impl Into<String>,
rule: impl Into<String>,
priority: ToolMemoryPriority,
source: ToolMemorySource,
) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: Self::generate_id(),
tool_name: tool_name.into(),
rule: rule.into(),
priority,
source,
tags: Vec::new(),
created_at: now.clone(),
updated_at: now,
}
}
/// Generate a fresh, opaque rule id.
pub fn generate_id() -> String {
let mut id = String::with_capacity(33);
id.push('r');
for byte in uuid::Uuid::new_v4().as_bytes() {
id.push((b'a' + (byte >> 4)) as char);
id.push((b'a' + (byte & 0x0f)) as char);
}
id
}
/// Storage key used inside the tool namespace.
pub fn storage_key(id: &str) -> String {
format!("rule/{id}")
}
}
/// Namespace string for a given tool. Trimmed and lower-cased so callers
/// can pass user-supplied tool names without leaking whitespace into
/// downstream queries.
///
/// The `tool-` prefix is intentionally distinct from `global`, `skill-…`
/// and `tool_effectiveness` so retrieval and clearing operations can
/// reason about the namespace without ambiguity.
pub fn tool_memory_namespace(tool_name: &str) -> String {
format!("tool-{}", tool_name.trim().to_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_default_is_normal() {
assert_eq!(ToolMemoryPriority::default(), ToolMemoryPriority::Normal);
}
#[test]
fn priority_ordering_puts_critical_above_high() {
assert!(ToolMemoryPriority::Critical > ToolMemoryPriority::High);
assert!(ToolMemoryPriority::High > ToolMemoryPriority::Normal);
}
#[test]
fn priority_is_eager_for_high_and_critical_only() {
assert!(ToolMemoryPriority::Critical.is_eager());
assert!(ToolMemoryPriority::High.is_eager());
assert!(!ToolMemoryPriority::Normal.is_eager());
}
#[test]
fn priority_snake_case_serde() {
assert_eq!(
serde_json::to_string(&ToolMemoryPriority::Critical).unwrap(),
"\"critical\""
);
assert_eq!(
serde_json::to_string(&ToolMemoryPriority::Normal).unwrap(),
"\"normal\""
);
}
#[test]
fn source_default_is_programmatic() {
assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic);
}
#[test]
fn rule_new_fills_id_and_timestamps() {
let rule = ToolMemoryRule::new(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
);
assert!(!rule.id.is_empty());
assert_eq!(rule.tool_name, "email");
assert_eq!(rule.rule, "never email Sarah");
assert_eq!(rule.priority, ToolMemoryPriority::Critical);
assert_eq!(rule.source, ToolMemorySource::UserExplicit);
assert!(rule.created_at == rule.updated_at);
}
#[test]
fn rule_generate_id_produces_unique_values() {
let a = ToolMemoryRule::generate_id();
let b = ToolMemoryRule::generate_id();
assert_ne!(a, b);
assert!(a.starts_with('r'));
assert!(a[1..].chars().all(|c| matches!(c, 'a'..='p')));
assert!(
!crate::openhuman::memory_store::safety::pii::has_likely_pii(
&ToolMemoryRule::storage_key(&a)
)
);
}
#[test]
fn generated_rule_ids_are_safe_memory_document_keys() {
for _ in 0..128 {
let id = ToolMemoryRule::generate_id();
assert!(
id.chars().all(|ch| ch.is_ascii_lowercase()),
"generated id should avoid PII-shaped digits and separators: {id}"
);
let key = ToolMemoryRule::storage_key(&id);
assert!(
!crate::openhuman::memory_store::safety::pii::has_likely_pii(&key),
"generated storage key should not trip PII boundary: {key}"
);
}
}
#[test]
fn rule_storage_key_uses_rule_prefix() {
assert_eq!(ToolMemoryRule::storage_key("abc"), "rule/abc");
}
#[test]
fn rule_serde_roundtrip_preserves_fields() {
let rule = ToolMemoryRule {
id: "id-1".into(),
tool_name: "shell".into(),
rule: "never run sudo".into(),
priority: ToolMemoryPriority::High,
source: ToolMemorySource::PostTurn,
tags: vec!["safety".into()],
created_at: "2026-05-11T00:00:00Z".into(),
updated_at: "2026-05-11T00:00:01Z".into(),
};
let json = serde_json::to_string(&rule).unwrap();
let back: ToolMemoryRule = serde_json::from_str(&json).unwrap();
assert_eq!(back, rule);
}
#[test]
fn namespace_uses_tool_prefix_and_trims_whitespace() {
assert_eq!(tool_memory_namespace("email"), "tool-email");
assert_eq!(tool_memory_namespace(" shell "), "tool-shell");
assert_eq!(tool_memory_namespace("Send_Email"), "tool-send_email");
assert_eq!(tool_memory_namespace("WebSearch"), "tool-websearch");
}
}
pub use tinycortex::memory::tool_memory::types::{
tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource,
};