Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/openhuman/agent/harness/session/builder/helpers.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/openhuman/memory/ops/tool_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions src/openhuman/memory_tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 7 additions & 7 deletions src/openhuman/memory_tools/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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.",
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/openhuman/memory_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
197 changes: 25 additions & 172 deletions src/openhuman/memory_tools/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,143 +1,42 @@
//! 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 {
"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]**",
// 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())
}
}

Expand All @@ -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 {
Expand All @@ -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",
Expand Down
Loading
Loading