diff --git a/src/openhuman/agent/harness/session/builder/helpers.rs b/src/openhuman/agent/harness/session/builder/helpers.rs index 6de044d68..21661f0af 100644 --- a/src/openhuman/agent/harness/session/builder/helpers.rs +++ b/src/openhuman/agent/harness/session/builder/helpers.rs @@ -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(); diff --git a/src/openhuman/memory/ops/tool_memory.rs b/src/openhuman/memory/ops/tool_memory.rs index d3a6e5822..42781315f 100644 --- a/src/openhuman/memory/ops/tool_memory.rs +++ b/src/openhuman/memory/ops/tool_memory.rs @@ -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 { 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. diff --git a/src/openhuman/memory_tools/README.md b/src/openhuman/memory_tools/README.md index 3a6e61dbf..66c37a370 100644 --- a/src/openhuman/memory_tools/README.md +++ b/src/openhuman/memory_tools/README.md @@ -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`: `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)`, 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 diff --git a/src/openhuman/memory_tools/capture.rs b/src/openhuman/memory_tools/capture.rs index 29eeba875..b9934378a 100644 --- a/src/openhuman/memory_tools/capture.rs +++ b/src/openhuman/memory_tools/capture.rs @@ -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, 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) -> TurnContext { @@ -391,7 +391,7 @@ mod tests { #[tokio::test] async fn on_turn_complete_persists_critical_rule_for_user_edict() { let memory: Arc = 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 = 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 = 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 = 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", diff --git a/src/openhuman/memory_tools/mod.rs b/src/openhuman/memory_tools/mod.rs index 2750e963d..11ff3b528 100644 --- a/src/openhuman/memory_tools/mod.rs +++ b/src/openhuman/memory_tools/mod.rs @@ -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}; diff --git a/src/openhuman/memory_tools/prompt.rs b/src/openhuman/memory_tools/prompt.rs index 32ede2081..0f730b5a1 100644 --- a/src/openhuman/memory_tools/prompt.rs +++ b/src/openhuman/memory_tools/prompt.rs @@ -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) -> 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 { - 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", diff --git a/src/openhuman/memory_tools/store.rs b/src/openhuman/memory_tools/store.rs index 4ef685d46..4d581246f 100644 --- a/src/openhuman/memory_tools/store.rs +++ b/src/openhuman/memory_tools/store.rs @@ -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`) is the crate's. It is generic over the **crate** `Memory` +//! trait, while host call sites hold `Arc` +//! — 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`] 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`] backend. -/// Cheap to clone — the backend is reference-counted. -#[derive(Clone)] -pub struct ToolMemoryStore { - memory: Arc, -} +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) -> Self { - Self { memory } +/// Presents a host [`Arc`] 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); + +#[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 { - 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, 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, String> { - let namespace = tool_memory_namespace(tool_name); - let mut rules: Vec = 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::(&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::(&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 { - 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>, 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 = 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> = 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, 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, - ) -> Result { - 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 { - 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, 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::(&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> { + 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> { + self.0 + .recall_relevant_by_vector(namespace, query, limit, min_vector_similarity) + .await + } + + async fn get(&self, namespace: &str, key: &str) -> anyhow::Result> { + self.0.get(namespace, key).await + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> anyhow::Result> { + // 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 { + self.0.forget(namespace, key).await + } + + async fn namespace_summaries(&self) -> anyhow::Result> { + self.0.namespace_summaries().await + } + + async fn count(&self) -> anyhow::Result { + 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) -> ToolMemoryStore { + ToolMemoryStore::new(Arc::new(HostMemoryBridge(memory))) +} diff --git a/src/openhuman/memory_tools/store_tests.rs b/src/openhuman/memory_tools/store_tests.rs deleted file mode 100644 index 0bdd652e6..000000000 --- a/src/openhuman/memory_tools/store_tests.rs +++ /dev/null @@ -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 = 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 = 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()); -} diff --git a/src/openhuman/memory_tools/tools/list.rs b/src/openhuman/memory_tools/tools/list.rs index c4feb2a26..c7610e954 100644 --- a/src/openhuman/memory_tools/tools/list.rs +++ b/src/openhuman/memory_tools/tools/list.rs @@ -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 diff --git a/src/openhuman/memory_tools/tools/put.rs b/src/openhuman/memory_tools/tools/put.rs index 872456378..9cbe82af0 100644 --- a/src/openhuman/memory_tools/tools/put.rs +++ b/src/openhuman/memory_tools/tools/put.rs @@ -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() diff --git a/src/openhuman/memory_tools/types.rs b/src/openhuman/memory_tools/types.rs index eecda7251..2a173548c 100644 --- a/src/openhuman/memory_tools/types.rs +++ b/src/openhuman/memory_tools/types.rs @@ -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, - /// 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, - rule: impl Into, - 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, +};