mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(tinycortex): W7 — shim memory_tools over the crate tool_memory engine (#4789)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user