feat(memory): add tool-scoped memory with durable rule capture, RPC APIs, and prompt pinning (#1487)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-05-11 12:07:59 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent ef998ca16c
commit a2ced40ef9
21 changed files with 2473 additions and 0 deletions
+1
View File
@@ -25,6 +25,7 @@
* [Cron & Scheduling](features/native-tools/cron.md)
* [Voice](features/native-tools/voice.md)
* [Memory Tools](features/native-tools/memory-tools.md)
* [Tool-Scoped Memory](features/native-tools/tool-memory.md)
* [Third-party Integrations](features/native-tools/integrations.md)
* [Agent Coordination](features/native-tools/agent-coordination.md)
* [System & Utilities](features/native-tools/system-and-utilities.md)
@@ -0,0 +1,82 @@
---
description: Durable, tool-scoped rules for safety-critical guidance and learnings.
icon: shield-check
---
# Tool-Scoped Memory
The tool-scoped memory layer captures **actionable guidance** about how the agent should use specific tools - separate from the [Memory Tools](memory-tools.md) general-purpose recall and from the `tool_effectiveness` statistics namespace. It is the surface that turns "never email Sarah" into a hard constraint the agent has to obey on every subsequent turn.
It implements [issue #1400](https://github.com/tinyhumansai/openhuman/issues/1400) - a first-class storage and retrieval system for durable learnings and high-priority rules.
## What it stores
Every tool gets its own namespace, **`tool-{tool_name}`**, distinct from `global`, `skill-{id}`, and the statistics-only `tool_effectiveness` namespace. Inside it, each entry is a `ToolMemoryRule`:
| Field | Purpose |
| ------------ | ------- |
| `id` | Stable per-rule UUID. Upserts replay the same id. |
| `tool_name` | The tool the rule applies to (e.g. `send_email`, `shell`). |
| `rule` | Natural-language guidance the agent must follow. |
| `priority` | `critical`, `high`, or `normal`. Drives retrieval + compression. |
| `source` | `user_explicit`, `post_turn`, or `programmatic` - provenance. |
| `tags` | Free-form labels (`safety`, `permission`, ...). |
| `created_at` / `updated_at` | RFC3339 timestamps. |
Statistics (`tool_effectiveness/tool/{name}`) and rules (`tool-{name}/rule/{id}`) live in *different* namespaces by design - one tracks "what happened", the other tracks "what to do about it".
## Priority levels
| Priority | Where it lives | Compression-resistant? |
| ---------- | -------------- | ---------------------- |
| `critical` | Pinned into the **system prompt** via `ToolMemoryRulesSection`. | **Yes** - the system prompt is frozen per-session and never rewritten by the mid-session compactor. |
| `high` | Same system prompt block, ranked below critical. | **Yes** - same mechanism. |
| `normal` | Stored in the namespace; retrieved on demand via `memory_recall`. | No - eligible for compression like any other namespaced memory. |
The compression-resistance property is structural: critical and high rules ride in the *system prompt*, which the inference backend's prefix cache keeps frozen for the entire session. There is no way for token compression to silently drop a `critical` rule.
## Capture pipeline
Two automatic capture paths fire after every turn (via `ToolMemoryCaptureHook`):
1. **User edicts** - sentences like `never <verb> <noun>`, `don't <verb> ...`, `do not <verb> ...`, or `stop <verb>ing ...` in the user message are promoted to a **Critical** rule on the matching tool. Common-noun aliases map `"email"` to a tool named `send_email`, `"shell"` to `bash`/`exec`, etc.; when no alias matches, the rule lands on the first tool that ran in the turn so it stays adjacent to the relevant call site.
2. **Repeated tool failures** - a tool that fails twice or more in a single turn earns a **Normal**-priority observation, with the failure class summarized inline so the agent has context next time it considers that tool.
The hook is enabled by default whenever the learning subsystem is on. Disable selectively with `OPENHUMAN_LEARNING_TOOL_MEMORY_CAPTURE_ENABLED=0`.
## Retrieval at tool-selection time
At session start the harness pre-fetches every Critical and High rule via `ToolMemoryStore::rules_for_prompt`, renders them into the `## Tool-scoped rules` block, and pins the block into the system prompt. Because the prompt is frozen for the lifetime of the session, the rules are visible on every turn at tool-selection time and before any actual tool execution.
Lower-priority guidance stays out of the prompt budget; the agent reaches it on demand by calling `memory_recall` against the `tool-{name}` namespace.
## RPC surface
Six methods are exposed under the `memory` namespace:
| Method | Purpose |
| ----------------------------------- | ------- |
| `memory.tool_rule_put` | Upsert a rule. Use `priority='critical'` for safety-critical entries. |
| `memory.tool_rule_get` | Fetch a rule by `(tool_name, id)`. |
| `memory.tool_rule_list` | List all rules for a tool, sorted by priority + freshness. |
| `memory.tool_rule_delete` | Delete a rule. |
| `memory.tool_rules_for_prompt` | Return the rendered Markdown block + structured snapshot - what the session builder pins. |
| `memory.tool_rules_json` | Raw JSON list (for envelope consumers). |
JSON payloads use snake_case (`priority: "critical"`, `source: "user_explicit"`). Every method goes through the same `active_memory_client` plumbing as the rest of the memory RPCs.
## End-to-end safety case
The "never email Sarah" path is covered as a regression test:
1. User says *"Never email Sarah at sarah@example.com."* during a turn that called `send_email`.
2. `ToolMemoryCaptureHook` extracts the edict, maps the `email` alias to the `send_email` tool, and writes a Critical rule under `tool-send_email/rule/{uuid}`.
3. On the next session, `prefetch_tool_memory_rules_blocking` pulls every Critical and High rule and the session builder appends a `ToolMemoryRulesSection` to the system prompt.
4. The agent sees `### \`send_email\`` followed by `- **[critical]** Never email Sarah at sarah@example.com.` before ever choosing a tool, and the rule survives any mid-session token compression.
Coverage and the integration test live in `src/openhuman/memory/tool_memory/`.
## See also
- [Memory Tools](memory-tools.md) - general-purpose `recall`, `store`, `forget`.
- [Smart Token Compression](../token-compression.md) - what the system prompt is protected against.
+14
View File
@@ -206,6 +206,20 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: None,
},
Capability {
id: "intelligence.tool_scoped_memory",
name: "Tool-Scoped Memory Rules",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "Store durable, tool-specific rules and corrections that survive context \
compression. Critical-priority rules (e.g. 'never email Sarah') are pinned into the \
system prompt at session start. Captured automatically from user edicts and repeated \
tool failures; also writable programmatically via the memory.tool_rule_* RPC surface.",
how_to: "Automatic — user edicts are captured after every turn. Manage via \
memory.tool_rule_put / memory.tool_rule_list / memory.tool_rule_delete (RPC).",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.memory_tree_retrieval",
name: "Memory Tree Retrieval (chat)",
@@ -840,6 +840,13 @@ impl Agent {
)));
log::info!("[learning] tool_tracker hook registered");
}
if config.learning.tool_memory_capture_enabled {
post_turn_hooks.push(Arc::new(
crate::openhuman::memory::ToolMemoryCaptureHook::new(memory.clone(), true),
));
log::info!("[learning] tool_memory_capture hook registered");
}
}
// Resolve the per-agent delegation tool set and visible-tool
@@ -970,6 +977,27 @@ impl Agent {
.filter(|t| !existing_names.contains(t.name())),
);
// Pre-fetch Critical + High priority tool-scoped memory rules so they
// pin into the (compression-resistant) system prompt for the whole
// session. Done here — after the tool list is finalised — so we only
// fetch rules for tools this agent can actually use. Skipped when
// `learning.enabled` is false (no new rules are written in that mode,
// and users who opt out of learning expect no stored rules to surface)
// or when the runtime cannot host a synchronous bridge (single-threaded
// test harnesses).
if config.learning.enabled && config.learning.tool_memory_capture_enabled {
let agent_tool_names: Vec<String> =
tools.iter().map(|t| t.name().to_string()).collect();
let pinned = prefetch_tool_memory_rules_blocking(memory.clone(), &agent_tool_names);
if !pinned.is_empty() {
log::info!(
"[memory::tool_memory] pinning {} tool-scoped rule(s) into system prompt",
pinned.len()
);
prompt_builder = prompt_builder.with_tool_memory_rules(pinned);
}
}
// Build the P-Format registry AFTER the tool list is finalised
// (including orchestrator tools) so every tool gets a signature
// entry. The registry is self-contained — it doesn't hold a
@@ -1152,3 +1180,53 @@ impl Agent {
builder.build()
}
}
/// (#1400) Best-effort synchronous prefetch of eager tool-scoped rules.
///
/// `from_config_*` is sync but typically runs inside a multi-threaded
/// Tokio runtime (the agent harness path from the channels runtime).
/// We use `block_in_place` + the current runtime handle to call the
/// async store API without restructuring the whole session builder.
///
/// Returns an empty `Vec` (rather than erroring) when:
/// - no Tokio runtime is active (e.g. a sync CLI bootstrap),
/// - the runtime is single-threaded (`block_in_place` would panic),
/// - or the underlying `rules_for_prompt` call returns an error
/// (e.g. the memory backend isn't ready yet).
///
/// Critical / High rules captured later in the session are still
/// available via the `memory_tool_rules_for_prompt` RPC; this prefetch
/// merely seeds the rules that exist at session start.
fn prefetch_tool_memory_rules_blocking(
memory: Arc<dyn Memory>,
tool_names: &[String],
) -> Vec<crate::openhuman::memory::ToolMemoryRule> {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return Vec::new();
};
if handle.runtime_flavor() != tokio::runtime::RuntimeFlavor::MultiThread {
return Vec::new();
}
let tool_names = tool_names.to_vec();
tokio::task::block_in_place(|| {
handle.block_on(async move {
let store = crate::openhuman::memory::ToolMemoryStore::new(memory);
match store.rules_for_prompt(&tool_names).await {
Ok(grouped) => {
let mut flat: Vec<_> = grouped.into_values().flatten().collect();
flat.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))
});
flat
}
Err(err) => {
log::warn!("[memory::tool_memory] prefetch failed: {err}");
Vec::new()
}
}
})
})
}
+35
View File
@@ -170,6 +170,41 @@ impl SystemPromptBuilder {
self
}
/// Append a [`ToolMemoryRulesSection`] carrying a pre-fetched
/// snapshot of Critical / High priority tool-scoped rules (#1400).
///
/// Snapshot semantics — the rules are baked into the section at
/// construction so the rendered system prompt stays byte-identical
/// for the lifetime of the session. The session builder is
/// responsible for pre-fetching via
/// [`crate::openhuman::memory::ToolMemoryStore::rules_for_prompt`]
/// (or the `memory_tool_rules_for_prompt` RPC) before invoking
/// this method.
///
/// No-op when `rules` is empty.
pub fn with_tool_memory_rules(
mut self,
rules: Vec<crate::openhuman::memory::ToolMemoryRule>,
) -> Self {
if rules.is_empty() {
return self;
}
// Insert before the tool-catalogue section so these rules appear
// adjacent to the tool listings and survive tail-biased trimming.
// Falls back to push when no tools section is present.
let section: Box<dyn PromptSection> =
Box::new(crate::openhuman::memory::ToolMemoryRulesSection::new(rules));
let tools_idx = self
.sections
.iter()
.position(|s| s.name() == "tools" || s.name() == "tool_catalogue");
match tools_idx {
Some(idx) => self.sections.insert(idx, section),
None => self.sections.push(section),
}
self
}
/// Append a "Memory context" section carrying the resolved chunks the
/// subconscious LLM cited when it produced the reflection that
/// spawned this thread (#623).
+13
View File
@@ -35,6 +35,18 @@ pub struct LearningConfig {
#[serde(default = "default_true")]
pub tool_tracking_enabled: bool,
/// Enable the tool-scoped memory capture hook (see
/// [`crate::openhuman::memory::tool_memory::ToolMemoryCaptureHook`]).
///
/// When enabled, the hook records user edicts ("never email Sarah")
/// as `Critical`-priority rules in the `tool-{name}` memory
/// namespace, and tallies repeated tool failures into
/// `Normal`-priority observations. Defaults to true when learning
/// is enabled — set to false to disable durable rule capture
/// without turning off learning entirely.
#[serde(default = "default_true")]
pub tool_memory_capture_enabled: bool,
/// Which LLM to use for reflection. Default: local (Ollama).
#[serde(default)]
pub reflection_source: ReflectionSource,
@@ -91,6 +103,7 @@ impl Default for LearningConfig {
reflection_enabled: default_true(),
user_profile_enabled: default_true(),
tool_tracking_enabled: default_true(),
tool_memory_capture_enabled: default_true(),
reflection_source: ReflectionSource::default(),
max_reflections_per_session: default_max_reflections(),
min_turn_complexity: default_min_turn_complexity(),
+8
View File
@@ -898,6 +898,14 @@ impl Config {
_ => {}
}
}
if let Some(flag) = env.get("OPENHUMAN_LEARNING_TOOL_MEMORY_CAPTURE_ENABLED") {
if let Some(enabled) = parse_env_bool(
"OPENHUMAN_LEARNING_TOOL_MEMORY_CAPTURE_ENABLED",
flag.as_str(),
) {
self.learning.tool_memory_capture_enabled = enabled;
}
}
if let Some(source) = env.get("OPENHUMAN_LEARNING_REFLECTION_SOURCE") {
let normalized = source.trim().to_ascii_lowercase();
match normalized.as_str() {
+6
View File
@@ -15,6 +15,7 @@ pub mod safety;
pub mod schemas;
pub mod store;
pub mod sync_status;
pub mod tool_memory;
pub mod traits;
pub mod tree;
@@ -40,6 +41,11 @@ pub use sync_status::{
all_memory_sync_status_controller_schemas, all_memory_sync_status_registered_controllers,
FreshnessLabel, MemorySyncStatus,
};
pub use tool_memory::{
render_tool_memory_rules, tool_memory_namespace, ToolMemoryCaptureHook, ToolMemoryPriority,
ToolMemoryRule, ToolMemoryRulesSection, ToolMemorySource, ToolMemoryStore, TOOL_MEMORY_HEADING,
TOOL_MEMORY_PROMPT_CAP,
};
pub use traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
pub use tree::{
all_memory_tree_controller_schemas, all_memory_tree_registered_controllers,
+6
View File
@@ -26,6 +26,7 @@ pub mod helpers;
pub mod kv_graph;
pub mod learn;
pub mod sync;
pub mod tool_memory;
// ---------------------------------------------------------------------------
// Re-exports preserving the previous flat `memory::ops::*` surface.
@@ -48,6 +49,11 @@ pub use sync::{
memory_ingestion_status, memory_sync_all, memory_sync_channel, IngestionStatusResult,
SyncAllResult, SyncChannelParams, SyncChannelResult,
};
pub use tool_memory::{
tool_rule_delete, tool_rule_get, tool_rule_list, tool_rule_put, tool_rules_for_prompt,
tool_rules_json, ToolRuleListParams, ToolRulePutParams, ToolRuleRefParams,
ToolRulesForPromptParams, ToolRulesForPromptResult,
};
// ---------------------------------------------------------------------------
// Test-only re-exports — keep the existing `ops_tests.rs` happy without
+174
View File
@@ -0,0 +1,174 @@
//! RPC handlers for the tool-scoped memory layer (see
//! [`crate::openhuman::memory::tool_memory`]).
//!
//! All handlers go through [`active_memory_client`] so they hit the
//! same `UnifiedMemory` backend the rest of the memory RPCs use, and
//! the namespace they touch is exactly `tool-{tool_name}` — never
//! `global` or `tool_effectiveness`.
use serde::Deserialize;
use serde_json::Value;
use crate::openhuman::memory::tool_memory::{
ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, ToolMemoryStore,
};
use crate::rpc::RpcOutcome;
use super::helpers::active_memory_client;
/// Parameters for `memory_tool_rule_put`.
#[derive(Debug, Deserialize)]
pub struct ToolRulePutParams {
/// Tool the rule applies to (e.g. `email`, `shell`).
pub tool_name: String,
/// Natural-language rule body.
pub rule: String,
/// Priority/criticality. Defaults to `normal` when omitted.
#[serde(default)]
pub priority: Option<ToolMemoryPriority>,
/// Provenance — defaults to `programmatic` when omitted.
#[serde(default)]
pub source: Option<ToolMemorySource>,
/// Optional tags for filtering.
#[serde(default)]
pub tags: Vec<String>,
/// Optional rule id — when supplied, the call upserts in place
/// rather than creating a new entry.
#[serde(default)]
pub id: Option<String>,
}
/// Parameters for `memory_tool_rule_get` / `memory_tool_rule_delete`.
#[derive(Debug, Deserialize)]
pub struct ToolRuleRefParams {
pub tool_name: String,
pub id: String,
}
/// Parameters for `memory_tool_rule_list`.
#[derive(Debug, Deserialize)]
pub struct ToolRuleListParams {
pub tool_name: String,
}
/// Parameters for `memory_tool_rules_for_prompt`.
#[derive(Debug, Deserialize, Default)]
pub struct ToolRulesForPromptParams {
/// Constrain the result to these tools. Empty (or omitted) scans
/// every known tool namespace.
#[serde(default)]
pub tools: Vec<String>,
}
async fn open_store() -> Result<ToolMemoryStore, String> {
let client = active_memory_client().await?;
Ok(ToolMemoryStore::new(client.memory_handle()))
}
/// Upsert a tool-scoped memory rule.
pub async fn tool_rule_put(
params: ToolRulePutParams,
) -> Result<RpcOutcome<ToolMemoryRule>, String> {
log::debug!("[tool-memory] rpc tool_rule_put tool={}", params.tool_name);
let store = open_store().await?;
let mut rule = ToolMemoryRule::new(
&params.tool_name,
&params.rule,
params.priority.unwrap_or_default(),
params.source.unwrap_or_default(),
);
rule.tags = params.tags;
if let Some(id) = params.id {
if !id.trim().is_empty() {
rule.id = id;
}
}
let stored = store.put_rule(rule).await?;
Ok(RpcOutcome::single_log(stored, "tool memory rule stored"))
}
/// Fetch a tool-scoped rule by id.
pub async fn tool_rule_get(
params: ToolRuleRefParams,
) -> Result<RpcOutcome<Option<ToolMemoryRule>>, String> {
log::debug!(
"[tool-memory] rpc tool_rule_get tool={} id={}",
params.tool_name,
params.id
);
let store = open_store().await?;
let rule = store.get_rule(&params.tool_name, &params.id).await?;
Ok(RpcOutcome::single_log(rule, "tool memory rule fetched"))
}
/// List every tool-scoped rule for a tool.
pub async fn tool_rule_list(
params: ToolRuleListParams,
) -> Result<RpcOutcome<Vec<ToolMemoryRule>>, String> {
log::debug!("[tool-memory] rpc tool_rule_list tool={}", params.tool_name);
let store = open_store().await?;
let rules = store.list_rules(&params.tool_name).await?;
Ok(RpcOutcome::single_log(rules, "tool memory rules listed"))
}
/// Delete a tool-scoped rule by id.
pub async fn tool_rule_delete(params: ToolRuleRefParams) -> Result<RpcOutcome<bool>, String> {
log::debug!(
"[tool-memory] rpc tool_rule_delete tool={} id={}",
params.tool_name,
params.id
);
let store = open_store().await?;
let deleted = store.delete_rule(&params.tool_name, &params.id).await?;
Ok(RpcOutcome::single_log(deleted, "tool memory rule deleted"))
}
/// Return the rendered prompt block plus the structured rule list for
/// the caller-supplied set of tools. Used by the session builder to
/// pin Critical / High rules into the system prompt.
#[derive(Debug, serde::Serialize)]
pub struct ToolRulesForPromptResult {
/// Pre-rendered Markdown block, ready for injection.
pub rendered: String,
/// Underlying rule snapshot the renderer used.
pub rules: Vec<ToolMemoryRule>,
}
/// Pre-fetch Critical + High priority rules for prompt injection.
pub async fn tool_rules_for_prompt(
params: ToolRulesForPromptParams,
) -> Result<RpcOutcome<ToolRulesForPromptResult>, String> {
log::debug!(
"[tool-memory] rpc tool_rules_for_prompt tools={:?}",
params.tools
);
let store = open_store().await?;
let grouped = store.rules_for_prompt(&params.tools).await?;
let mut flat: Vec<ToolMemoryRule> = grouped.into_values().flatten().collect();
flat.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))
});
let rendered = crate::openhuman::memory::tool_memory::render_tool_memory_rules(&flat);
Ok(RpcOutcome::single_log(
ToolRulesForPromptResult {
rendered,
rules: flat,
},
"tool memory rules prepared for prompt",
))
}
/// Render the raw JSON form of a tool's rules, useful for envelope
/// consumers that want the unfiltered list.
pub async fn tool_rules_json(params: ToolRuleListParams) -> Result<RpcOutcome<Value>, String> {
log::debug!(
"[tool-memory] rpc tool_rules_json tool={}",
params.tool_name
);
let store = open_store().await?;
let value = store.list_rules_json(&params.tool_name).await?;
Ok(RpcOutcome::single_log(value, "tool memory rules json"))
}
+6
View File
@@ -25,6 +25,7 @@ mod files;
mod kv_graph;
mod learn;
mod sync;
mod tool_memory;
// ---------------------------------------------------------------------------
// Public entry points
@@ -38,6 +39,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
out.extend(kv_graph::FUNCTIONS.iter().map(|f| schemas(f)));
out.extend(sync::FUNCTIONS.iter().map(|f| schemas(f)));
out.extend(learn::FUNCTIONS.iter().map(|f| schemas(f)));
out.extend(tool_memory::FUNCTIONS.iter().map(|f| schemas(f)));
out
}
@@ -49,6 +51,7 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
out.extend(kv_graph::controllers());
out.extend(sync::controllers());
out.extend(learn::controllers());
out.extend(tool_memory::controllers());
out
}
@@ -69,6 +72,9 @@ pub fn schemas(function: &str) -> ControllerSchema {
if let Some(schema) = learn::schema(function) {
return schema;
}
if let Some(schema) = tool_memory::schema(function) {
return schema;
}
unknown_schema()
}
+269
View File
@@ -0,0 +1,269 @@
//! Schemas and handlers for the tool-scoped memory RPC methods.
//!
//! Six methods are exposed under the `memory` namespace:
//!
//! - `tool_rule_put` — upsert a rule
//! - `tool_rule_get` — fetch a single rule by id
//! - `tool_rule_list` — list every rule for a tool
//! - `tool_rule_delete` — delete a rule
//! - `tool_rules_for_prompt` — pre-rendered prompt block + structured rules
//! - `tool_rules_json` — raw JSON list for envelope consumers
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::memory::rpc::{
self, ToolRuleListParams, ToolRulePutParams, ToolRuleRefParams, ToolRulesForPromptParams,
};
use super::{parse_params, to_json};
pub(super) const FUNCTIONS: &[&str] = &[
"tool_rule_put",
"tool_rule_get",
"tool_rule_list",
"tool_rule_delete",
"tool_rules_for_prompt",
"tool_rules_json",
];
pub(super) fn controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema("tool_rule_put").unwrap(),
handler: handle_tool_rule_put,
},
RegisteredController {
schema: schema("tool_rule_get").unwrap(),
handler: handle_tool_rule_get,
},
RegisteredController {
schema: schema("tool_rule_list").unwrap(),
handler: handle_tool_rule_list,
},
RegisteredController {
schema: schema("tool_rule_delete").unwrap(),
handler: handle_tool_rule_delete,
},
RegisteredController {
schema: schema("tool_rules_for_prompt").unwrap(),
handler: handle_tool_rules_for_prompt,
},
RegisteredController {
schema: schema("tool_rules_json").unwrap(),
handler: handle_tool_rules_json,
},
]
}
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
Some(match function {
"tool_rule_put" => ControllerSchema {
namespace: "memory",
function: "tool_rule_put",
description:
"Upsert a tool-scoped memory rule. Stored in namespace `tool-{tool_name}`, separate \
from generic `global` / `skill-{id}` / `tool_effectiveness` namespaces. Use \
`priority='critical'` for safety-critical rules that must survive context compression.",
inputs: vec![
FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Tool the rule applies to.",
required: true,
},
FieldSchema {
name: "rule",
ty: TypeSchema::String,
comment: "Natural-language rule body.",
required: true,
},
FieldSchema {
name: "priority",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Priority — 'critical', 'high', or 'normal'. Defaults to 'normal'.",
required: false,
},
FieldSchema {
name: "source",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment:
"Provenance — 'user_explicit', 'post_turn', or 'programmatic'. Defaults to \
'programmatic'.",
required: false,
},
FieldSchema {
name: "tags",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Optional tags for filtering.",
required: false,
},
FieldSchema {
name: "id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional rule id — supplied to upsert in place.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Stored rule, including assigned id and timestamps.",
required: true,
}],
},
"tool_rule_get" => ControllerSchema {
namespace: "memory",
function: "tool_rule_get",
description: "Fetch a tool-scoped rule by `(tool_name, id)`. Returns null when missing.",
inputs: vec![
FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Tool the rule applies to.",
required: true,
},
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Rule id.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Stored rule or null.",
required: true,
}],
},
"tool_rule_list" => ControllerSchema {
namespace: "memory",
function: "tool_rule_list",
description:
"List every rule for a tool, sorted by priority (critical → high → normal) and then \
by `updated_at` descending.",
inputs: vec![FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Tool to list rules for.",
required: true,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Array of stored rules.",
required: true,
}],
},
"tool_rule_delete" => ControllerSchema {
namespace: "memory",
function: "tool_rule_delete",
description:
"Delete a tool-scoped rule. Returns true if the rule existed before deletion.",
inputs: vec![
FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Tool the rule applies to.",
required: true,
},
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Rule id.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Bool,
comment: "True when the rule existed.",
required: true,
}],
},
"tool_rules_for_prompt" => ControllerSchema {
namespace: "memory",
function: "tool_rules_for_prompt",
description:
"Pre-fetch Critical + High priority rules for prompt injection. Returns the \
rendered Markdown block (ready for the system prompt) plus the structured rule \
snapshot. Used by the session builder to surface rules during tool-selection and \
pre-execution phases.",
inputs: vec![FieldSchema {
name: "tools",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Constrain to these tools. Empty means scan every known tool namespace.",
required: false,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{ rendered: string, rules: ToolMemoryRule[] }.",
required: true,
}],
},
"tool_rules_json" => ControllerSchema {
namespace: "memory",
function: "tool_rules_json",
description:
"Return the full rule list for a tool as raw JSON — useful for envelope consumers.",
inputs: vec![FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Tool to list rules for.",
required: true,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Array of stored rules as JSON.",
required: true,
}],
},
_ => return None,
})
}
fn handle_tool_rule_put(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRulePutParams>(params)?;
to_json(rpc::tool_rule_put(payload).await?)
})
}
fn handle_tool_rule_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRuleRefParams>(params)?;
to_json(rpc::tool_rule_get(payload).await?)
})
}
fn handle_tool_rule_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRuleListParams>(params)?;
to_json(rpc::tool_rule_list(payload).await?)
})
}
fn handle_tool_rule_delete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRuleRefParams>(params)?;
to_json(rpc::tool_rule_delete(payload).await?)
})
}
fn handle_tool_rules_for_prompt(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRulesForPromptParams>(params)?;
to_json(rpc::tool_rules_for_prompt(payload).await?)
})
}
fn handle_tool_rules_json(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ToolRuleListParams>(params)?;
to_json(rpc::tool_rules_json(payload).await?)
})
}
+7
View File
@@ -33,6 +33,13 @@ const ALL_FUNCTIONS: &[&str] = &[
"sync_all",
"learn_all",
"ingestion_status",
// Tool-scoped memory (#1400)
"tool_rule_put",
"tool_rule_get",
"tool_rule_list",
"tool_rule_delete",
"tool_rules_for_prompt",
"tool_rules_json",
];
#[test]
+9
View File
@@ -58,6 +58,15 @@ impl MemoryClient {
std::sync::Arc::clone(&self.inner.conn)
}
/// Returns an `Arc<dyn Memory>` handle backed by the same
/// [`UnifiedMemory`] this client wraps. Used by sub-systems that
/// want to build on top of the `Memory` trait (e.g. the
/// tool-scoped memory layer) without depending on the concrete
/// `MemoryClient` type or holding a reference to it.
pub fn memory_handle(&self) -> Arc<dyn crate::openhuman::memory::Memory> {
Arc::clone(&self.inner) as Arc<dyn crate::openhuman::memory::Memory>
}
/// Create a new local memory client using the default `.openhuman` directory.
///
/// # Errors
+481
View File
@@ -0,0 +1,481 @@
//! Post-turn capture hook for tool-scoped memory.
//!
//! This hook complements the statistics-only [`ToolTrackerHook`] —
//! `tool_effectiveness` records *what happened* (counts, error patterns),
//! while [`ToolMemoryCaptureHook`] records *what to do about it* as
//! actionable [`ToolMemoryRule`]s in the tool-scoped namespace.
//!
//! Two capture paths fire automatically after every turn:
//!
//! 1. **User edicts** — phrases like `never <verb> <object>`,
//! `don't <verb> …`, or `stop <verb>ing …` in the user message are
//! promoted to a `Critical` rule attached to the matching tool when
//! one of the turn's tool calls plausibly applies. This covers the
//! "never email Sarah" safety case from the spec.
//!
//! 2. **Repeated tool failures** — when a tool fails twice or more
//! within a single turn, a `Normal`-priority observation is captured
//! so the agent has a record next time it considers that tool.
//!
//! Both paths are conservative — they only fire on clear signals, and
//! the captured rule body always points back to the user's own words so
//! a reviewer can see exactly what triggered it.
//!
//! Captured rules are stored via [`ToolMemoryStore`] in the
//! `tool-{tool_name}` namespace, never in `global` or
//! `tool_effectiveness`.
//!
//! [`ToolTrackerHook`]: crate::openhuman::learning::ToolTrackerHook
//! [`ToolMemoryStore`]: super::store::ToolMemoryStore
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use super::store::ToolMemoryStore;
use super::types::{ToolMemoryPriority, ToolMemorySource};
use crate::openhuman::agent::hooks::{PostTurnHook, ToolCallRecord, TurnContext};
use crate::openhuman::memory::Memory;
/// Maximum length (chars) of the captured rule body — keeps malformed or
/// runaway input from bloating the namespace.
const MAX_RULE_LEN: usize = 240;
/// Post-turn hook that captures durable tool-scoped rules.
pub struct ToolMemoryCaptureHook {
store: ToolMemoryStore,
enabled: bool,
}
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),
enabled,
}
}
/// Build a hook directly over a [`ToolMemoryStore`] — useful for
/// tests and call sites that already hold a store.
pub fn from_store(store: ToolMemoryStore, enabled: bool) -> Self {
Self { store, enabled }
}
/// Look at the user message and return any `Critical`-priority rule
/// patterns it contains, paired with the tool name they apply to.
///
/// Pure / synchronous so it can be unit-tested without a memory
/// backend.
pub fn extract_user_edicts(
user_message: &str,
tool_calls: &[ToolCallRecord],
) -> Vec<(String, String)> {
let trimmed = user_message.trim();
if trimmed.is_empty() {
return Vec::new();
}
let lower = trimmed.to_lowercase();
// Only treat "stop" as an imperative edict when it appears at a
// sentence boundary (start of message or after ". "/"\n"), so routine
// phrases like "I want to stop working" don't trigger false captures.
let stop_imperative =
lower.starts_with("stop ") || lower.contains(". stop ") || lower.contains("\nstop ");
if !(lower.contains("never ") || lower.contains("don't ") || lower.contains("do not "))
&& !stop_imperative
{
return Vec::new();
}
// Default tool: the first tool that ran in the turn. When there
// were no tool calls we still want to capture user edicts so
// they survive into the next turn — those land under the
// `__unscoped__` tool name and the agent can refile them.
let default_tool = tool_calls
.first()
.map(|tc| tc.name.clone())
.unwrap_or_else(|| "__unscoped__".to_string());
let mut out = Vec::new();
for raw_line in trimmed.split(|c: char| matches!(c, '.' | '\n' | ';')) {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let lower_line = line.to_lowercase();
let is_edict = lower_line.starts_with("never ")
|| lower_line.starts_with("don't ")
|| lower_line.starts_with("do not ")
|| lower_line.starts_with("stop ")
|| lower_line.contains(" never ")
|| lower_line.contains(" don't ")
|| lower_line.contains(" do not ");
if !is_edict {
continue;
}
let body: String = line.chars().take(MAX_RULE_LEN).collect();
if body.is_empty() {
continue;
}
let tool =
pick_tool_for_edict(&body, tool_calls).unwrap_or_else(|| default_tool.clone());
out.push((tool, body));
}
out
}
/// Look at the tool-call records and return any (tool_name, body)
/// pairs that describe repeated failures worth pinning as a
/// `Normal`-priority observation.
///
/// A tool counts when it failed two or more times in the turn —
/// transient one-off failures are ignored to keep the namespace
/// from filling with noise.
pub fn extract_repeated_failures(tool_calls: &[ToolCallRecord]) -> Vec<(String, String)> {
let mut tallies: HashMap<&str, (usize, Option<&str>)> = HashMap::new();
for tc in tool_calls {
if tc.success {
continue;
}
let entry = tallies.entry(tc.name.as_str()).or_insert((0, None));
entry.0 += 1;
if entry.1.is_none() {
entry.1 = Some(tc.output_summary.as_str());
}
}
let mut out = Vec::new();
for (tool, (count, sample)) in tallies {
if count < 2 {
continue;
}
let body = match sample {
Some(sample) => format!(
"Tool failed {count} times in one turn ({sample}). Consider an alternative \
approach before retrying."
),
None => format!(
"Tool failed {count} times in one turn. Consider an alternative approach \
before retrying."
),
};
out.push((tool.to_string(), body.chars().take(MAX_RULE_LEN).collect()));
}
out
}
}
#[async_trait]
impl PostTurnHook for ToolMemoryCaptureHook {
fn name(&self) -> &str {
"tool_memory_capture"
}
async fn on_turn_complete(&self, ctx: &TurnContext) -> anyhow::Result<()> {
if !self.enabled {
return Ok(());
}
for (tool, body) in Self::extract_user_edicts(&ctx.user_message, &ctx.tool_calls) {
log::debug!(
"[tool-memory] capturing user edict tool={tool} body_len={}",
body.len()
);
if let Err(err) = self
.store
.record(
&tool,
&body,
ToolMemoryPriority::Critical,
ToolMemorySource::UserExplicit,
vec!["user-edict".into()],
)
.await
{
log::warn!("[tool-memory] failed to capture user edict for {tool}: {err}");
}
}
for (tool, body) in Self::extract_repeated_failures(&ctx.tool_calls) {
log::debug!(
"[tool-memory] capturing repeated failure tool={tool} body_len={}",
body.len()
);
if let Err(err) = self
.store
.record(
&tool,
&body,
ToolMemoryPriority::Normal,
ToolMemorySource::PostTurn,
vec!["repeated-failure".into()],
)
.await
{
log::warn!(
"[tool-memory] failed to capture repeated-failure observation for {tool}: {err}"
);
}
}
Ok(())
}
}
/// Helper: emit a [`ToolMemoryRule`] preview without flooding logs with
/// raw user prose.
fn truncate_for_log(body: &str) -> String {
let mut out: String = body.chars().take(80).collect();
if body.chars().count() > 80 {
out.push('…');
}
out
}
/// Best-effort match between a user edict and a tool that ran in the
/// turn. We look for the tool name appearing as a word in the edict;
/// when several match, the first call's tool wins.
fn pick_tool_for_edict(body: &str, tool_calls: &[ToolCallRecord]) -> Option<String> {
if tool_calls.is_empty() {
return None;
}
let lower = body.to_lowercase();
for tc in tool_calls {
let needle = tc.name.to_lowercase();
if needle.is_empty() {
continue;
}
if lower.contains(&needle) {
return Some(tc.name.clone());
}
// Common-noun aliases — match "email" to a tool named
// "send_email", "gmail_send", etc.
for alias in tool_aliases(&tc.name) {
if lower.contains(alias) {
return Some(tc.name.clone());
}
}
}
None
}
/// Map a tool name to a small set of common-noun aliases users would
/// say in plain English ("email", "shell", "browser", …). Kept tiny on
/// purpose — anything more ambitious belongs in an LLM extractor.
fn tool_aliases(tool_name: &str) -> Vec<&'static str> {
let lower = tool_name.to_lowercase();
let mut out = Vec::new();
if lower.contains("mail") {
out.push("email");
out.push("mail");
}
if lower.contains("shell") || lower.contains("bash") || lower.contains("exec") {
out.push("shell");
out.push("terminal");
}
if lower.contains("browser") || lower.contains("web") || lower.contains("http") {
out.push("browser");
out.push("web");
}
if lower.contains("slack") {
out.push("slack");
out.push("dm");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::hooks::ToolCallRecord;
use crate::openhuman::memory::tool_memory::store::ToolMemoryStore;
use crate::openhuman::memory::tool_memory::test_helpers::MockMemory;
fn ctx_with(message: &str, tool_calls: Vec<ToolCallRecord>) -> TurnContext {
TurnContext {
user_message: message.into(),
assistant_response: "ok".into(),
tool_calls,
turn_duration_ms: 1,
session_id: None,
iteration_count: 1,
}
}
fn call(name: &str, success: bool) -> ToolCallRecord {
ToolCallRecord {
name: name.into(),
arguments: serde_json::json!({}),
success,
output_summary: if success {
"ok".into()
} else {
"permission denied".into()
},
duration_ms: 10,
}
}
#[test]
fn extract_user_edicts_picks_up_never_phrase() {
let edicts = ToolMemoryCaptureHook::extract_user_edicts(
"Never email Sarah at sarah@example.com — she does not want updates.",
&[call("send_email", true)],
);
assert!(!edicts.is_empty(), "expected at least one captured edict");
let (tool, body) = &edicts[0];
assert_eq!(
tool, "send_email",
"should map 'email' alias to send_email tool"
);
assert!(body.to_lowercase().contains("never email"));
}
#[test]
fn extract_user_edicts_handles_dont_and_stop_phrases() {
let edicts = ToolMemoryCaptureHook::extract_user_edicts(
"Don't run shell commands with sudo. Stop using browser for that.",
&[call("shell", true), call("browser", true)],
);
assert_eq!(edicts.len(), 2, "should capture each imperative separately");
}
#[test]
fn extract_user_edicts_returns_empty_when_no_edict_present() {
let edicts = ToolMemoryCaptureHook::extract_user_edicts(
"Send Sarah an update when you can.",
&[call("send_email", true)],
);
assert!(edicts.is_empty());
}
#[test]
fn extract_user_edicts_falls_back_to_first_tool_when_no_alias_match() {
let edicts = ToolMemoryCaptureHook::extract_user_edicts(
"Never do that automatically.",
&[call("calendar", true)],
);
assert_eq!(edicts.len(), 1);
assert_eq!(edicts[0].0, "calendar");
}
#[test]
fn extract_user_edicts_uses_sentinel_when_no_tools_ran() {
let edicts = ToolMemoryCaptureHook::extract_user_edicts("Never do that.", &[]);
assert_eq!(edicts.len(), 1);
assert_eq!(edicts[0].0, "__unscoped__");
}
#[test]
fn extract_repeated_failures_needs_two_or_more_failures() {
let observations = ToolMemoryCaptureHook::extract_repeated_failures(&[
call("shell", false),
call("shell", false),
call("shell", true),
]);
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].0, "shell");
assert!(observations[0].1.contains("failed 2 times"));
}
#[test]
fn extract_repeated_failures_ignores_single_failures() {
let observations =
ToolMemoryCaptureHook::extract_repeated_failures(&[call("shell", false)]);
assert!(observations.is_empty());
}
#[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 hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
hook.on_turn_complete(&ctx_with(
"Never email Sarah — she opted out.",
vec![call("send_email", true)],
))
.await
.unwrap();
let rules = store.list_rules("send_email").await.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].priority, ToolMemoryPriority::Critical);
assert_eq!(rules[0].source, ToolMemorySource::UserExplicit);
assert!(rules[0].tags.contains(&"user-edict".to_string()));
}
#[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 hook = ToolMemoryCaptureHook::from_store(store.clone(), false);
hook.on_turn_complete(&ctx_with(
"Never email Sarah.",
vec![call("send_email", true)],
))
.await
.unwrap();
assert!(store.list_rules("send_email").await.unwrap().is_empty());
}
/// Safety case (AC #5): "never email Sarah" flows end-to-end from
/// a user utterance → captured as a Critical rule → surfaces in
/// the prompt-injection block.
#[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 hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
// 1. Capture the edict from a normal user turn.
hook.on_turn_complete(&ctx_with(
"Never email Sarah at sarah@example.com.",
vec![call("send_email", true)],
))
.await
.unwrap();
// 2. The rule lands in the tool-scoped namespace with Critical
// priority — distinct from `tool_effectiveness` / global.
let stored = store.list_rules("send_email").await.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].priority, ToolMemoryPriority::Critical);
// 3. `rules_for_prompt` pulls it eagerly so the session builder
// can pin it into the (compression-resistant) system prompt.
let prompt = store
.rules_for_prompt(&["send_email".to_string()])
.await
.unwrap();
assert!(prompt.contains_key("send_email"));
// 4. The rendered block is non-empty and mentions the edict
// verbatim — the exact bytes the safety pipeline puts in
// front of the agent on every subsequent turn.
let mut flat: Vec<_> = prompt.into_values().flatten().collect();
flat.sort_by(|a, b| b.priority.cmp(&a.priority));
let rendered = crate::openhuman::memory::tool_memory::render_tool_memory_rules(&flat);
assert!(rendered.contains("Never email Sarah"));
assert!(rendered.contains("**[critical]**"));
}
#[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 hook = ToolMemoryCaptureHook::from_store(store.clone(), true);
hook.on_turn_complete(&ctx_with(
"Try again",
vec![call("shell", false), call("shell", false)],
))
.await
.unwrap();
let rules = store.list_rules("shell").await.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].priority, ToolMemoryPriority::Normal);
assert_eq!(rules[0].source, ToolMemorySource::PostTurn);
assert!(rules[0].tags.contains(&"repeated-failure".to_string()));
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Tool-scoped memory layer for durable learnings and high-priority rules.
//!
//! Implements the dedicated memory namespace requested in
//! [issue #1400](https://github.com/tinyhumansai/openhuman/issues/1400):
//! a first-class storage and retrieval surface for **actionable**
//! tool-specific guidance, distinct from the
//! [`tool_effectiveness`](crate::openhuman::learning::tool_tracker)
//! statistics namespace and from the generic `global` / `skill-*`
//! namespaces.
//!
//! ## Namespace convention
//!
//! Each tool gets its own namespace `tool-{tool_name}`. The prefix is
//! distinct from `global`, `skill-{id}`, `tool_effectiveness`, and the
//! learning namespaces so list/clear operations can reason about it
//! without ambiguity. Build the namespace string via
//! [`types::tool_memory_namespace`] — never hard-code the format.
//!
//! ## Components
//!
//! - [`types`] — [`ToolMemoryRule`], [`ToolMemoryPriority`],
//! [`ToolMemorySource`].
//! - [`store`] — [`ToolMemoryStore`], the put/list/delete/prompt API
//! built on top of an `Arc<dyn Memory>`.
//! - [`capture`] — [`ToolMemoryCaptureHook`], the post-turn
//! [`PostTurnHook`] that records user edicts and repeated tool
//! failures.
//! - [`prompt`] — [`ToolMemoryRulesSection`], the prompt section that
//! pins Critical / High rules into the system prompt so they survive
//! mid-session compression.
//!
//! [`PostTurnHook`]: crate::openhuman::agent::hooks::PostTurnHook
pub mod capture;
pub mod prompt;
pub mod store;
#[cfg(test)]
pub mod test_helpers;
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 types::{tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
+250
View File
@@ -0,0 +1,250 @@
//! Prompt section that injects tool-scoped memory rules into the
//! system prompt.
//!
//! ## 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`]).
//!
//! 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 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.
//!
//! [`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()
}
}
impl PromptSection for ToolMemoryRulesSection {
fn name(&self) -> &str {
"tool_memory_rules"
}
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]**",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::prompts::types::{
LearnedContextData, PromptContext, ToolCallFormat,
};
use crate::openhuman::memory::tool_memory::types::ToolMemorySource;
fn rule(tool: &str, body: &str, priority: ToolMemoryPriority) -> ToolMemoryRule {
ToolMemoryRule {
id: format!("{tool}/{body}"),
tool_name: tool.into(),
rule: body.into(),
priority,
source: ToolMemorySource::UserExplicit,
tags: vec![],
created_at: "2026-05-11T00:00:00Z".into(),
updated_at: "2026-05-11T00:00:00Z".into(),
}
}
#[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.
let section = ToolMemoryRulesSection::new(vec![rule(
"email",
"never email Sarah",
ToolMemoryPriority::Critical,
)]);
assert!(!section.is_empty());
let visible = std::collections::HashSet::new();
let ctx = PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "test",
tools: &[],
skills: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &[],
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
};
let built = section.build(&ctx).unwrap();
assert!(built.contains("never email Sarah"));
}
}
+269
View File
@@ -0,0 +1,269 @@
//! Storage layer for [`ToolMemoryRule`]s.
//!
//! 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:
//!
//! - 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.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::Value;
use super::types::{tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
use crate::openhuman::memory::{Memory, MemoryCategory};
/// 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;
/// 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>,
}
impl ToolMemoryStore {
/// Build a new store over the given memory backend.
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
}
/// 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 entries = self
.memory
.list(Some(&namespace), None, None)
.await
.map_err(|e| format!("list tool rules: {e:#}"))?;
let mut rules: Vec<ToolMemoryRule> = 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(
&self,
namespace: &str,
key: &str,
) -> Result<Option<ToolMemoryRule>, String> {
let entry = self
.memory
.get(namespace, key)
.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),
}
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
@@ -0,0 +1,375 @@
//! 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::tool_memory::test_helpers::MockMemory;
use crate::openhuman::memory::tool_memory::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());
}
@@ -0,0 +1,107 @@
//! Shared test infrastructure for the tool-scoped memory layer.
//!
//! Only compiled under `#[cfg(test)]`.
use std::collections::HashMap;
use async_trait::async_trait;
use parking_lot::Mutex;
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
/// Minimal in-memory [`Memory`] backend for unit tests.
///
/// Stores entries in a `HashMap` keyed by `(namespace, key)`. All methods
/// that are not needed by the store/capture tests are no-ops.
#[derive(Default)]
pub struct MockMemory {
pub entries: Mutex<HashMap<(String, String), MemoryEntry>>,
}
#[async_trait]
impl Memory for MockMemory {
fn name(&self) -> &str {
"mock"
}
async fn store(
&self,
namespace: &str,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()> {
self.entries.lock().insert(
(namespace.to_string(), key.to_string()),
MemoryEntry {
id: format!("{namespace}/{key}"),
key: key.to_string(),
content: content.to_string(),
namespace: Some(namespace.to_string()),
category,
timestamp: "now".into(),
session_id: session_id.map(str::to_string),
score: None,
},
);
Ok(())
}
async fn recall(
&self,
_query: &str,
_limit: usize,
_opts: RecallOpts<'_>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
Ok(self
.entries
.lock()
.get(&(namespace.to_string(), key.to_string()))
.cloned())
}
async fn list(
&self,
namespace: Option<&str>,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let lock = self.entries.lock();
Ok(match namespace {
Some(ns) => lock
.iter()
.filter(|((n, _), _)| n == ns)
.map(|(_, v)| v.clone())
.collect(),
None => lock.iter().map(|(_, v)| v.clone()).collect(),
})
}
async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result<bool> {
Ok(self
.entries
.lock()
.remove(&(namespace.to_string(), key.to_string()))
.is_some())
}
async fn namespace_summaries(&self) -> anyhow::Result<Vec<NamespaceSummary>> {
let mut counts: HashMap<String, usize> = HashMap::new();
for ((ns, _), _) in self.entries.lock().iter() {
*counts.entry(ns.clone()).or_default() += 1;
}
Ok(counts
.into_iter()
.map(|(namespace, count)| NamespaceSummary {
namespace,
count,
last_updated: None,
})
.collect())
}
async fn count(&self) -> anyhow::Result<usize> {
Ok(self.entries.lock().len())
}
async fn health_check(&self) -> bool {
true
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Domain types for the tool-scoped memory layer.
//!
//! 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.
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")]
pub enum ToolMemoryPriority {
/// Soft suggestion — surfaced on demand, not eagerly injected.
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 Default for ToolMemoryPriority {
fn default() -> Self {
Self::Normal
}
}
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")]
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).
Programmatic,
}
impl Default for ToolMemorySource {
fn default() -> Self {
Self::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 {
uuid::Uuid::new_v4().to_string()
}
/// 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);
}
#[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");
}
}