mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory): two-lane user preferences (save_preference) + model-aware embedding recall (#2501)
Co-authored-by: sanil-23 <sanil@alphahuman.xyz> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
sanil-23
Claude Opus 4.7
parent
533208bbf3
commit
ae0464b62d
@@ -294,6 +294,17 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 8.3.8 | Drill-Down Isolates Children | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_drill_down_isolates_children` | ✅ | Verifies query_topic does not cross scope boundaries |
|
||||
| 8.3.9 | Scale Ingest 20 Sources No Real Data | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_scale_ingest_20_sources_no_real_data` | ✅ | Verifies retrieval correctness at scale with synthetic data |
|
||||
|
||||
### 8.4 Explicit User Preferences (Two-Lane)
|
||||
|
||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||
| ----- | ------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- |
|
||||
| 8.4.1 | Save Preference (general / situational) | RU | `src/openhuman/tools/impl/agent/save_preference_tests.rs` | ✅ | `save_preference` tool → `user_pref_{general,situational}`, topic-keyed |
|
||||
| 8.4.2 | Lane A — Standing Prefs in System Prompt | RU | `src/openhuman/learning/prompt_sections.rs`, `src/openhuman/agent/harness/session/turn_tests.rs` | ✅ | General prefs rendered into the system prompt at thread start |
|
||||
| 8.4.3 | Lane B — Situational Recall (vector-gated) | RU | `src/openhuman/memory/store/unified/query_tests.rs::recall_relevant_by_vector_gates_on_similarity` | ✅ | Per-turn; relevant query injects, unrelated suppresses |
|
||||
| 8.4.4 | Same-Topic Contradiction (replace) | RU | `src/openhuman/tools/impl/agent/save_preference_tests.rs::recategorising_moves_pref_between_namespaces` | ✅ | `ON CONFLICT REPLACE`; a topic lives in exactly one scope |
|
||||
| 8.4.5 | Cross-Topic Contradiction Surfacing | RU | `src/openhuman/tools/impl/agent/save_preference_tests.rs::save_surfaces_related_preference_for_contradiction_check` | ✅ | Related prefs surfaced in the tool result for the chat agent to resolve |
|
||||
| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory/store/unified/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows |
|
||||
|
||||
---
|
||||
|
||||
## 9. Automation Engine
|
||||
|
||||
@@ -1281,6 +1281,19 @@ const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.remember_preferences",
|
||||
name: "Remember Preferences",
|
||||
domain: "memory",
|
||||
category: CapabilityCategory::Intelligence,
|
||||
description: "Remember preferences you state in chat and apply them automatically — \
|
||||
general preferences shape every reply (tone, language, standing habits); \
|
||||
situational ones surface only when relevant to your current message.",
|
||||
how_to: "State a preference in chat, e.g. \"always reply in British English\" or \
|
||||
\"when writing Rust, prefer Result over unwrap\".",
|
||||
status: CapabilityStatus::Stable,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
];
|
||||
|
||||
static VALIDATED: OnceLock<()> = OnceLock::new();
|
||||
|
||||
@@ -101,6 +101,7 @@ hint = "chat"
|
||||
named = [
|
||||
"query_memory",
|
||||
"memory_store",
|
||||
"save_preference",
|
||||
"memory_forget",
|
||||
"memory_tree",
|
||||
# WhatsApp local-data tools (issue #1341). The scanner ingests chats
|
||||
|
||||
@@ -334,7 +334,7 @@ impl Agent {
|
||||
// Gate: `learning.stm_recall_enabled` must be true AND this must
|
||||
// be the first turn (STM is snapshot-frozen at session start).
|
||||
// Failure is non-fatal — bare `context` passes through untouched.
|
||||
let context = if is_first_turn_for_stm {
|
||||
let mut context = if is_first_turn_for_stm {
|
||||
// Load config to check the gate. Use a cached load (cheap).
|
||||
let stm_enabled = crate::openhuman::config::rpc::load_config_with_timeout()
|
||||
.await
|
||||
@@ -388,6 +388,38 @@ impl Agent {
|
||||
context
|
||||
};
|
||||
|
||||
// ── Lane B: situational preferences (every turn) ─────────────────────
|
||||
// Recall topic-scoped preferences semantically relevant to THIS message
|
||||
// (model-aware embeddings, gated by vector similarity) and inject them
|
||||
// under a banner. Runs every turn — unlike the first-turn-gated tree/STM
|
||||
// blocks above — because the query changes per message; it rides the
|
||||
// per-turn context that's prepended to the user message (no KV-cache
|
||||
// cost). An unrelated message clears the similarity gate to nothing, so
|
||||
// no block is injected.
|
||||
{
|
||||
let situational =
|
||||
crate::openhuman::memory::preferences::recall_situational_preferences(
|
||||
&self.memory,
|
||||
user_message,
|
||||
)
|
||||
.await;
|
||||
if !situational.is_empty() {
|
||||
log::info!(
|
||||
"[pref_recall] situational block injected: {} item(s)",
|
||||
situational.len()
|
||||
);
|
||||
context.push_str("## Relevant preferences for this message\n\n");
|
||||
for pref in &situational {
|
||||
context.push_str("- ");
|
||||
context.push_str(pref.trim());
|
||||
context.push('\n');
|
||||
}
|
||||
context.push('\n');
|
||||
} else {
|
||||
log::debug!("[pref_recall] no situational preference relevant to this message");
|
||||
}
|
||||
}
|
||||
|
||||
let enriched = if context.is_empty() {
|
||||
log::info!("[agent] no memory context found — using raw user message");
|
||||
self.last_memory_context = None;
|
||||
@@ -1493,63 +1525,24 @@ impl Agent {
|
||||
return LearnedContextData::default();
|
||||
}
|
||||
|
||||
// Narrow explicit-preferences path: only fetch pinned user_profile
|
||||
// entries; skip all inference-derived data.
|
||||
// Narrow explicit-preferences path (Lane A): inject the latest-N general
|
||||
// (always-on) preferences written via `save_preference`. Topic-scoped
|
||||
// (situational) prefs are NOT injected here — they ride the user message
|
||||
// via per-turn recall (Lane B). The legacy `user_profile` pinned namespace
|
||||
// is no longer read here; explicit prefs now live in `user_pref_general`.
|
||||
if !self.learning_enabled && self.explicit_preferences_enabled {
|
||||
let general = crate::openhuman::memory::preferences::load_general_preferences(
|
||||
&self.memory,
|
||||
crate::openhuman::memory::preferences::STANDING_PREFS_LIMIT,
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
"[learning] fetch_learned_context: explicit_preferences_enabled=true, \
|
||||
learning_enabled=false — fetching only pinned user_profile entries"
|
||||
"[learning] fetch_learned_context: explicit_preferences_enabled — loaded {} general preference(s) for the system prompt",
|
||||
general.len()
|
||||
);
|
||||
let profile_entries = self
|
||||
.memory
|
||||
.list(
|
||||
Some("user_profile"),
|
||||
// Core category is used by RememberPreferenceTool for pinned entries.
|
||||
// We list without category filter so we pick up both Core entries
|
||||
// (pinned) and any Custom("user_profile") entries from the older
|
||||
// UserProfileHook code path, keeping this backward-compatible.
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// `.list()` already scopes to the `user_profile` namespace at the
|
||||
// store layer (via the `Some("user_profile")` argument above). This
|
||||
// `.filter()` is a defensive guard against any future store-layer
|
||||
// change that might weaken that scoping — it is not load-bearing
|
||||
// under the current implementation.
|
||||
if profile_entries.len() > 50 {
|
||||
tracing::warn!(
|
||||
total = profile_entries.len(),
|
||||
dropped = profile_entries.len() - 50,
|
||||
"[learning] user_profile pinned preferences exceed prompt cap of 50; \
|
||||
{} entries will be dropped from this turn's context",
|
||||
profile_entries.len() - 50,
|
||||
);
|
||||
}
|
||||
let user_profile: Vec<String> = profile_entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.namespace
|
||||
.as_deref()
|
||||
.map_or(false, |ns| ns == "user_profile")
|
||||
})
|
||||
.take(50)
|
||||
.map(|e| sanitize_learned_entry(&e.content))
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
"[learning] fetch_learned_context: fetched {} pinned user_profile entries",
|
||||
user_profile.len()
|
||||
);
|
||||
|
||||
return LearnedContextData {
|
||||
observations: Vec::new(),
|
||||
patterns: Vec::new(),
|
||||
user_profile,
|
||||
reflections: Vec::new(),
|
||||
tree_root_summaries: Vec::new(),
|
||||
user_profile: general,
|
||||
..LearnedContextData::default()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1578,15 +1571,16 @@ impl Agent {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let profile_entries = self
|
||||
.memory
|
||||
.list(
|
||||
Some("user_profile"),
|
||||
Some(&MemoryCategory::Custom("user_profile".into())),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Standing preferences come from the explicit two-lane store (Lane A),
|
||||
// not the inferred `user_profile` facets — those are demoted: no longer
|
||||
// injected as ground truth. A high-confidence inferred facet should be
|
||||
// *proposed* to the user (and pinned via `save_preference` on
|
||||
// confirmation), not silently treated as a standing preference.
|
||||
let general = crate::openhuman::memory::preferences::load_general_preferences(
|
||||
&self.memory,
|
||||
crate::openhuman::memory::preferences::STANDING_PREFS_LIMIT,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Explicit user reflections — privileged memory class. Pulled
|
||||
// separately from observations/patterns so the prompt assembly
|
||||
@@ -1632,11 +1626,7 @@ impl Agent {
|
||||
.take(3)
|
||||
.map(|e| sanitize_learned_entry(&e.content))
|
||||
.collect(),
|
||||
user_profile: profile_entries
|
||||
.iter()
|
||||
.take(20)
|
||||
.map(|e| sanitize_learned_entry(&e.content))
|
||||
.collect(),
|
||||
user_profile: general,
|
||||
// Cap reflections at 10 to keep the privileged section
|
||||
// bounded — the issue requires reflections improve context
|
||||
// rather than flood it. Newest first.
|
||||
|
||||
@@ -792,7 +792,7 @@ async fn execute_tool_call_applies_inline_result_budget() {
|
||||
// flag combinations:
|
||||
// 1. both flags off → empty context
|
||||
// 2. explicit_preferences_enabled=true, learning_enabled=false
|
||||
// → only pinned user_profile entries returned, no inference data
|
||||
// → only general user_pref entries returned, no inference data
|
||||
// 3. learning_enabled=true → full path (existing tests cover this; we only
|
||||
// verify that explicit entries are included as well)
|
||||
//
|
||||
@@ -860,24 +860,26 @@ async fn fetch_learned_context_returns_empty_when_both_flags_off() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_learned_context_returns_pinned_prefs_when_explicit_flag_on_learning_off() {
|
||||
async fn fetch_learned_context_returns_general_prefs_when_explicit_flag_on_learning_off() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = make_real_memory(tmp.path());
|
||||
|
||||
// Store two pinned preferences via the same key format RememberPreferenceTool uses.
|
||||
// Store two general preferences in the two-lane store (where save_preference
|
||||
// writes them). The explicit path now reads `user_pref_general`, not the
|
||||
// legacy `user_profile` pinned namespace.
|
||||
mem.store(
|
||||
"user_profile",
|
||||
"pinned/tooling/package_manager",
|
||||
"[pinned] (class=tooling) package_manager: pnpm",
|
||||
crate::openhuman::memory::preferences::USER_PREF_GENERAL_NAMESPACE,
|
||||
"package_manager",
|
||||
"Use pnpm for package management.",
|
||||
crate::openhuman::memory::MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
"user_profile",
|
||||
"pinned/style/verbosity",
|
||||
"[pinned] (class=style) verbosity: terse",
|
||||
crate::openhuman::memory::preferences::USER_PREF_GENERAL_NAMESPACE,
|
||||
"verbosity",
|
||||
"Keep replies terse.",
|
||||
crate::openhuman::memory::MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
@@ -896,20 +898,17 @@ async fn fetch_learned_context_returns_pinned_prefs_when_explicit_flag_on_learni
|
||||
assert_eq!(
|
||||
learned.user_profile.len(),
|
||||
2,
|
||||
"explicit flag on, learning off: expected 2 pinned preferences, got: {:?}",
|
||||
"explicit flag on, learning off: expected 2 general preferences, got: {:?}",
|
||||
learned.user_profile
|
||||
);
|
||||
assert!(
|
||||
learned
|
||||
.user_profile
|
||||
.iter()
|
||||
.any(|s| s.contains("package_manager")),
|
||||
"package_manager preference must appear in user_profile: {:?}",
|
||||
learned.user_profile.iter().any(|s| s.contains("pnpm")),
|
||||
"package_manager preference value must appear in user_profile: {:?}",
|
||||
learned.user_profile
|
||||
);
|
||||
assert!(
|
||||
learned.user_profile.iter().any(|s| s.contains("verbosity")),
|
||||
"verbosity preference must appear in user_profile: {:?}",
|
||||
learned.user_profile.iter().any(|s| s.contains("terse")),
|
||||
"verbosity preference value must appear in user_profile: {:?}",
|
||||
learned.user_profile
|
||||
);
|
||||
// Inference-derived data must remain empty — the stack was NOT engaged.
|
||||
@@ -957,3 +956,29 @@ async fn fetch_learned_context_explicit_flag_off_learning_off_returns_empty_even
|
||||
learned.user_profile
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_learned_context_loads_general_prefs_when_learning_enabled() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = make_real_memory(tmp.path());
|
||||
mem.store(
|
||||
crate::openhuman::memory::preferences::USER_PREF_GENERAL_NAMESPACE,
|
||||
"tone",
|
||||
"Be concise and direct.",
|
||||
crate::openhuman::memory::MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// learning_enabled=true → full path, which now also sources standing prefs
|
||||
// from the explicit user_pref_general store (inferred facets are demoted, so
|
||||
// they are no longer injected as ground truth).
|
||||
let agent = make_agent_with_memory(mem, tmp.path().to_path_buf(), true, true);
|
||||
let learned = agent.fetch_learned_context().await;
|
||||
assert!(
|
||||
learned.user_profile.iter().any(|s| s.contains("concise")),
|
||||
"learning path must inject explicit general prefs into user_profile: {:?}",
|
||||
learned.user_profile
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,18 @@ impl OpenHumanCloudEmbedding {
|
||||
|
||||
fn state_dir(&self) -> PathBuf {
|
||||
self.openhuman_dir.clone().unwrap_or_else(|| {
|
||||
// Honor OPENHUMAN_WORKSPACE (where auth-profiles.json lives) before
|
||||
// falling back to ~/.openhuman, so the cloud embedder resolves the
|
||||
// session JWT from the same directory the chat provider does. Without
|
||||
// this, any non-default workspace (OPENHUMAN_WORKSPACE set, e.g. tests
|
||||
// / multi-instance) silently has no session for embeddings —
|
||||
// resolve_bearer() bails, embed() errors, and vectors are dropped.
|
||||
if let Some(ws) = std::env::var_os("OPENHUMAN_WORKSPACE")
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
{
|
||||
return ws;
|
||||
}
|
||||
directories::UserDirs::new()
|
||||
.map(|d| d.home_dir().join(".openhuman"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openhuman"))
|
||||
|
||||
@@ -86,7 +86,7 @@ impl PromptSection for UserProfileSection {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut out = String::from("## User Profile (Learned)\n\n");
|
||||
let mut out = String::from("## Your standing preferences\n\n");
|
||||
for entry in &ctx.learned.user_profile {
|
||||
out.push_str("- ");
|
||||
out.push_str(entry);
|
||||
@@ -357,7 +357,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(section.name(), "user_profile");
|
||||
assert!(rendered.starts_with("## User Profile (Learned)\n\n"));
|
||||
assert!(rendered.starts_with("## Your standing preferences\n\n"));
|
||||
assert!(rendered.contains("- Timezone: America/Los_Angeles"));
|
||||
assert!(rendered.contains("- Prefers Rust"));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod conversations;
|
||||
pub mod global;
|
||||
pub mod ingestion;
|
||||
pub mod ops;
|
||||
pub mod preferences;
|
||||
pub mod rpc_models;
|
||||
pub mod safety;
|
||||
pub mod schemas;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Two-lane explicit user preferences — namespaces + read helpers.
|
||||
//!
|
||||
//! Preferences written by the `save_preference` tool live in one of two
|
||||
//! namespaces depending on their relevance scope:
|
||||
//!
|
||||
//! - [`USER_PREF_GENERAL_NAMESPACE`] — always-on; injected into the system
|
||||
//! prompt at thread start (Lane A).
|
||||
//! - [`USER_PREF_SITUATIONAL_NAMESPACE`] — topic-scoped; recalled per-turn by
|
||||
//! semantic similarity to the user's message (Lane B).
|
||||
//!
|
||||
//! Keeping the namespace constants and read helpers here (rather than in the
|
||||
//! tool module) lets the write path, the system-prompt builder, and the
|
||||
//! per-turn recall path all share one definition.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::Memory;
|
||||
|
||||
/// Always-on preferences — injected into the system prompt every thread.
|
||||
pub const USER_PREF_GENERAL_NAMESPACE: &str = "user_pref_general";
|
||||
|
||||
/// Topic-scoped preferences — recalled per query against the user's message.
|
||||
pub const USER_PREF_SITUATIONAL_NAMESPACE: &str = "user_pref_situational";
|
||||
|
||||
/// Default cap on general preferences injected into the system prompt. Keeps
|
||||
/// the always-on block bounded so it can't blow a small model's context window
|
||||
/// (see the legacy `gpt-4` 8K overflow).
|
||||
pub const STANDING_PREFS_LIMIT: usize = 10;
|
||||
|
||||
/// Load the latest-`limit` general preferences as plain-language strings,
|
||||
/// newest-first (by `updated_at`). This is the Lane-A system-prompt block.
|
||||
///
|
||||
/// `list()` returns entries ordered newest-first but with `content` set to the
|
||||
/// title (= topic key), so the body value is fetched via `get()`.
|
||||
pub async fn load_general_preferences(memory: &Arc<dyn Memory>, limit: usize) -> Vec<String> {
|
||||
let entries = memory
|
||||
.list(Some(USER_PREF_GENERAL_NAMESPACE), None, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.into_iter().take(limit) {
|
||||
if let Ok(Some(full)) = memory.get(USER_PREF_GENERAL_NAMESPACE, &entry.key).await {
|
||||
let value = full.content.trim();
|
||||
if !value.is_empty() {
|
||||
out.push(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Top-K situational preferences to recall per turn (Lane B).
|
||||
pub const SITUATIONAL_RECALL_LIMIT: usize = 5;
|
||||
|
||||
/// Minimum query↔preference vector similarity for a situational preference to be
|
||||
/// injected. Below this the current message isn't considered relevant to the
|
||||
/// preference, so nothing is injected (the "unrelated query → no block"
|
||||
/// behaviour). Tunable against live data.
|
||||
pub const SITUATIONAL_MIN_SIMILARITY: f64 = 0.35;
|
||||
|
||||
/// Recall situational preferences semantically relevant to `query` (Lane B).
|
||||
///
|
||||
/// Returns only preferences whose vector similarity to the message clears
|
||||
/// [`SITUATIONAL_MIN_SIMILARITY`], so an unrelated message yields an empty list
|
||||
/// (and no injected block). Uses the model-aware embedding recall, so a stale
|
||||
/// embedding-model signature is excluded rather than mis-scored.
|
||||
pub async fn recall_situational_preferences(memory: &Arc<dyn Memory>, query: &str) -> Vec<String> {
|
||||
if query.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
memory
|
||||
.recall_relevant_by_vector(
|
||||
USER_PREF_SITUATIONAL_NAMESPACE,
|
||||
query,
|
||||
SITUATIONAL_RECALL_LIMIT,
|
||||
SITUATIONAL_MIN_SIMILARITY,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(_topic, value)| value)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimum similarity for an existing preference to be flagged as a possible
|
||||
/// contradiction of a newly-saved one. Higher than the Lane-B recall floor — we
|
||||
/// only surface genuinely-close matches as contradiction candidates. Tunable.
|
||||
pub const CONTRADICTION_SIMILARITY: f64 = 0.6;
|
||||
|
||||
/// Find existing preferences (across both lanes) semantically close to `value`,
|
||||
/// excluding `exclude_topic` (the just-saved one). Returns `(topic, value)`
|
||||
/// pairs so the chat agent — which captured the preference in the first place —
|
||||
/// can resolve a contradiction itself: overwrite the conflicting topic or remove
|
||||
/// it. No separate model call; the conversation affirms it.
|
||||
pub async fn recall_related_preferences(
|
||||
memory: &Arc<dyn Memory>,
|
||||
value: &str,
|
||||
exclude_topic: &str,
|
||||
limit: usize,
|
||||
) -> Vec<(String, String)> {
|
||||
if value.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// `limit` is a global cap across *both* lanes, not per-namespace — spend a
|
||||
// shared budget so the total surfaced for one contradiction check can never
|
||||
// exceed what the caller asked for.
|
||||
let mut remaining = limit;
|
||||
for ns in [USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE] {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if let Ok(hits) = memory
|
||||
.recall_relevant_by_vector(ns, value, remaining, CONTRADICTION_SIMILARITY)
|
||||
.await
|
||||
{
|
||||
for (topic, val) in hits {
|
||||
if topic != exclude_topic {
|
||||
out.push((topic, val));
|
||||
remaining = remaining.saturating_sub(1);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{MemoryCategory, UnifiedMemory};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_general_preferences_returns_values_newest_first_capped() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::new(UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap());
|
||||
|
||||
mem.store(
|
||||
USER_PREF_GENERAL_NAMESPACE,
|
||||
"reply_language",
|
||||
"Reply in British English.",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
USER_PREF_GENERAL_NAMESPACE,
|
||||
"tone",
|
||||
"Be terse.",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let general = load_general_preferences(&mem, 10).await;
|
||||
// Returns the values (bodies), not the topic keys.
|
||||
assert!(general.iter().any(|v| v.contains("British English")));
|
||||
assert!(general.iter().any(|v| v.contains("Be terse")));
|
||||
assert!(!general.iter().any(|v| v == "reply_language"));
|
||||
|
||||
// The limit caps the block.
|
||||
assert_eq!(load_general_preferences(&mem, 1).await.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,25 @@ impl Memory for UnifiedMemory {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn recall_relevant_by_vector(
|
||||
&self,
|
||||
namespace: &str,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
min_vector_similarity: f64,
|
||||
) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let hits = self
|
||||
.query_namespace_hits(namespace, query, limit as u32)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(hits
|
||||
.into_iter()
|
||||
.filter(|h| h.score_breakdown.vector_similarity >= min_vector_similarity)
|
||||
.filter(|h| !h.content.trim().is_empty())
|
||||
.map(|h| (h.key, h.content))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
||||
let ns = if namespace.trim().is_empty() {
|
||||
GLOBAL_NAMESPACE.to_string()
|
||||
|
||||
@@ -155,18 +155,19 @@ impl UnifiedMemory {
|
||||
|
||||
let chunks = Self::chunk_document_content(&input.content, 225);
|
||||
for (idx, chunk) in chunks.iter().enumerate() {
|
||||
let embedding = self
|
||||
.embedder
|
||||
.embed_one(chunk)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| Self::vec_to_bytes(&v));
|
||||
// Embed the chunk, capturing the model signature + dimension so recall
|
||||
// can exclude vectors produced by a different embedding model (cross-model
|
||||
// cosine is meaningless) and guard against dimension mismatches.
|
||||
let embedded = self.embedder.embed_one(chunk).await.ok();
|
||||
let dim = embedded.as_ref().map(|v| v.len() as i64);
|
||||
let model_signature = embedded.as_ref().map(|_| self.embedder.signature());
|
||||
let embedding = embedded.as_ref().map(|v| Self::vec_to_bytes(v));
|
||||
let chunk_id = format!("{document_id}:{idx}");
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO vector_chunks
|
||||
(namespace, document_id, chunk_id, text, embedding, metadata_json, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(namespace, document_id, chunk_id, text, embedding, metadata_json, created_at, updated_at, model_signature, dim)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
namespace,
|
||||
document_id,
|
||||
@@ -175,7 +176,9 @@ impl UnifiedMemory {
|
||||
embedding,
|
||||
json!({"lancedb_table": format!("ns_{namespace}"), "chunk_index": idx}).to_string(),
|
||||
now,
|
||||
now
|
||||
now,
|
||||
model_signature,
|
||||
dim
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("insert vector chunk: {e}"))?;
|
||||
|
||||
@@ -112,11 +112,30 @@ impl UnifiedMemory {
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
model_signature TEXT,
|
||||
dim INTEGER,
|
||||
PRIMARY KEY(namespace, chunk_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_chunks_ns_doc ON vector_chunks(namespace, document_id);",
|
||||
)?;
|
||||
|
||||
// Tag vector_chunks with the embedding model that produced each vector
|
||||
// on existing databases (idempotent). Fresh installs get these from the
|
||||
// CREATE TABLE above; older DBs need the ALTERs so recall can exclude
|
||||
// vectors generated by a different embedding model (cross-model cosine is
|
||||
// garbage) and skip dimension mismatches instead of silently scoring 0.
|
||||
for sql in [
|
||||
"ALTER TABLE vector_chunks ADD COLUMN model_signature TEXT",
|
||||
"ALTER TABLE vector_chunks ADD COLUMN dim INTEGER",
|
||||
] {
|
||||
match conn.execute(sql, []) {
|
||||
Ok(_) => tracing::debug!("[vector_chunks:init] applied: {sql}"),
|
||||
Err(e) => {
|
||||
tracing::trace!("[vector_chunks:init] skipped (probably already exists): {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create FTS5 episodic tables (episodic_log, episodic_fts, and their
|
||||
// triggers) so the Archivist can call episodic_insert immediately after
|
||||
// the store is initialised.
|
||||
|
||||
@@ -39,6 +39,10 @@ struct StoredChunk {
|
||||
text: String,
|
||||
embedding: Option<Vec<f32>>,
|
||||
updated_at: f64,
|
||||
/// Signature of the embedding model that produced `embedding`. `None` for
|
||||
/// rows written before model tagging was introduced. Used to exclude
|
||||
/// cross-model vectors from cosine scoring.
|
||||
model_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -506,7 +510,7 @@ impl UnifiedMemory {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT document_id, chunk_id, text, embedding, updated_at
|
||||
"SELECT document_id, chunk_id, text, embedding, updated_at, model_signature
|
||||
FROM vector_chunks
|
||||
WHERE namespace = ?1",
|
||||
)
|
||||
@@ -526,6 +530,7 @@ impl UnifiedMemory {
|
||||
text: row.get(2).map_err(|e| e.to_string())?,
|
||||
embedding: embedding_blob.as_deref().map(Self::bytes_to_vec),
|
||||
updated_at: row.get(4).map_err(|e| e.to_string())?,
|
||||
model_signature: row.get(5).map_err(|e| e.to_string())?,
|
||||
});
|
||||
}
|
||||
Ok(chunks)
|
||||
@@ -544,11 +549,26 @@ impl UnifiedMemory {
|
||||
.embed_one(query)
|
||||
.await
|
||||
.map_err(|e| format!("embedding query: {e}"))?;
|
||||
let active_signature = self.embedder.signature();
|
||||
let mut scores = HashMap::new();
|
||||
for chunk in chunks {
|
||||
let Some(embedding) = chunk.embedding.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// Skip vectors produced by a different embedding model — cosine across
|
||||
// two embedding spaces is meaningless. Rows with no signature (written
|
||||
// before model tagging) fall through to the dimension guard below.
|
||||
if let Some(sig) = chunk.model_signature.as_deref() {
|
||||
if sig != active_signature {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Dimension guard: a model swap that changed dimensionality leaves
|
||||
// legacy/untagged vectors at the old length; skip them rather than
|
||||
// letting cosine_similarity silently return 0.
|
||||
if embedding.len() != query_embedding.len() {
|
||||
continue;
|
||||
}
|
||||
let similarity = Self::cosine_similarity(&query_embedding, embedding);
|
||||
let entry = scores
|
||||
.entry(chunk.document_id.clone())
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory};
|
||||
use crate::openhuman::memory::{Memory, NamespaceDocumentInput, UnifiedMemory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn graph_duplicate_upsert_aggregates_evidence_count() {
|
||||
@@ -422,3 +422,256 @@ async fn format_context_text_includes_entity_types() {
|
||||
context.context_text
|
||||
);
|
||||
}
|
||||
|
||||
// ── vector_chunks model-signature guard (embedding model-swap safety) ─────────
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::openhuman::embeddings::EmbeddingProvider;
|
||||
|
||||
/// Embedder stub that returns a fixed vector for any text, with a controllable
|
||||
/// name + dimension so tests can produce distinct embedding signatures and
|
||||
/// dimensionalities.
|
||||
struct StubEmbedder {
|
||||
name: &'static str,
|
||||
vector: Vec<f32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for StubEmbedder {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
fn model_id(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
self.vector.len()
|
||||
}
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts.iter().map(|_| self.vector.clone()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn pref_doc(key: &str, content: &str) -> NamespaceDocumentInput {
|
||||
NamespaceDocumentInput {
|
||||
namespace: "user_pref".to_string(),
|
||||
key: key.to_string(),
|
||||
title: key.to_string(),
|
||||
content: content.to_string(),
|
||||
source_type: "pref".to_string(),
|
||||
priority: "medium".to_string(),
|
||||
tags: vec![],
|
||||
metadata: json!({}),
|
||||
category: "core".to_string(),
|
||||
session_id: None,
|
||||
document_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_tags_vector_chunks_with_signature_and_dim() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let embedder = Arc::new(StubEmbedder {
|
||||
name: "stub-a",
|
||||
vector: vec![1.0, 0.0, 0.0],
|
||||
});
|
||||
let memory = UnifiedMemory::new(tmp.path(), embedder.clone(), None).unwrap();
|
||||
|
||||
memory
|
||||
.upsert_document(pref_doc("reply_language", "Reply in British English."))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The stored chunk carries the active model's signature.
|
||||
let chunks = memory.load_chunks_for_scope("user_pref").await.unwrap();
|
||||
assert_eq!(chunks.len(), 1, "expected exactly one chunk for the doc");
|
||||
assert_eq!(
|
||||
chunks[0].model_signature.as_deref(),
|
||||
Some(embedder.signature().as_str()),
|
||||
"chunk should be tagged with the embedder signature"
|
||||
);
|
||||
|
||||
// The `dim` column reflects the embedding dimensionality.
|
||||
let dim: Option<i64> = memory
|
||||
.conn
|
||||
.lock()
|
||||
.query_row(
|
||||
"SELECT dim FROM vector_chunks WHERE namespace = 'user_pref' LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dim, Some(3));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_excludes_other_model_signature() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Write under model A.
|
||||
let emb_a = Arc::new(StubEmbedder {
|
||||
name: "model-a",
|
||||
vector: vec![1.0, 0.0, 0.0],
|
||||
});
|
||||
{
|
||||
let memory = UnifiedMemory::new(tmp.path(), emb_a.clone(), None).unwrap();
|
||||
memory
|
||||
.upsert_document(pref_doc("p1", "formal tone for emails to my manager"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Same model → the vector is scored.
|
||||
let chunks = memory.load_chunks_for_scope("user_pref").await.unwrap();
|
||||
let scores = memory
|
||||
.query_vector_scores_from_chunks(&chunks, "email tone")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!scores.is_empty(), "same-signature vectors must be scored");
|
||||
}
|
||||
|
||||
// Reopen the same DB under a DIFFERENT model (swap), same dim + vector.
|
||||
let emb_b = Arc::new(StubEmbedder {
|
||||
name: "model-b",
|
||||
vector: vec![1.0, 0.0, 0.0],
|
||||
});
|
||||
let memory_b = UnifiedMemory::new(tmp.path(), emb_b, None).unwrap();
|
||||
let chunks = memory_b.load_chunks_for_scope("user_pref").await.unwrap();
|
||||
assert_eq!(chunks.len(), 1, "the chunk persists across reopen");
|
||||
let scores = memory_b
|
||||
.query_vector_scores_from_chunks(&chunks, "email tone")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
scores.is_empty(),
|
||||
"vectors from a different embedding model must be excluded, not compared as garbage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_skips_dimension_mismatch_for_untagged_rows() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Active model produces 4-dim vectors.
|
||||
let emb = Arc::new(StubEmbedder {
|
||||
name: "model-a",
|
||||
vector: vec![1.0, 0.0, 0.0, 0.0],
|
||||
});
|
||||
let memory = UnifiedMemory::new(tmp.path(), emb, None).unwrap();
|
||||
|
||||
// Insert a legacy chunk: NULL signature, 2-dim vector (a pre-tagging row left
|
||||
// behind by a dimension-changing model swap).
|
||||
let legacy_vec = UnifiedMemory::vec_to_bytes(&[1.0_f32, 0.0]);
|
||||
memory
|
||||
.conn
|
||||
.lock()
|
||||
.execute(
|
||||
"INSERT INTO vector_chunks
|
||||
(namespace, document_id, chunk_id, text, embedding, metadata_json, created_at, updated_at, model_signature, dim)
|
||||
VALUES ('user_pref','legacy','legacy:0','old pref',?1,'{}',0,0,NULL,2)",
|
||||
rusqlite::params![legacy_vec],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let chunks = memory.load_chunks_for_scope("user_pref").await.unwrap();
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(
|
||||
chunks[0].model_signature.is_none(),
|
||||
"legacy row should have no signature"
|
||||
);
|
||||
let scores = memory
|
||||
.query_vector_scores_from_chunks(&chunks, "old pref")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
scores.is_empty(),
|
||||
"dimension-mismatched legacy vectors must be skipped, not scored 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ── recall_relevant_by_vector — Lane B situational-pref relevance gate ─────────
|
||||
|
||||
/// Embedder whose vector depends on keywords in the text, so a query can be
|
||||
/// genuinely relevant (high cosine) or irrelevant (zero) to a stored pref.
|
||||
struct KeywordEmbedder;
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for KeywordEmbedder {
|
||||
fn name(&self) -> &str {
|
||||
"keyword-stub"
|
||||
}
|
||||
fn model_id(&self) -> &str {
|
||||
"keyword-stub"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
2
|
||||
}
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let lower = t.to_lowercase();
|
||||
vec![
|
||||
if lower.contains("rust") { 1.0 } else { 0.0 },
|
||||
if lower.contains("email") { 1.0 } else { 0.0 },
|
||||
]
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn situational_doc(key: &str, content: &str) -> NamespaceDocumentInput {
|
||||
NamespaceDocumentInput {
|
||||
namespace: "user_pref_situational".to_string(),
|
||||
key: key.to_string(),
|
||||
title: key.to_string(),
|
||||
content: content.to_string(),
|
||||
source_type: "pref".to_string(),
|
||||
priority: "medium".to_string(),
|
||||
tags: vec![],
|
||||
metadata: json!({}),
|
||||
category: "core".to_string(),
|
||||
session_id: None,
|
||||
document_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_relevant_by_vector_gates_on_similarity() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(KeywordEmbedder), None).unwrap();
|
||||
|
||||
// Two situational prefs that embed onto orthogonal axes.
|
||||
memory
|
||||
.upsert_document(situational_doc(
|
||||
"rust_style",
|
||||
"When writing rust, prefer explicit error handling.",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
memory
|
||||
.upsert_document(situational_doc(
|
||||
"email_tone",
|
||||
"Be formal in email to my manager.",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A rust-related message recalls only the rust pref.
|
||||
let hits = memory
|
||||
.recall_relevant_by_vector("user_pref_situational", "help me with my rust code", 5, 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1, "only the relevant pref should pass the gate");
|
||||
assert_eq!(hits[0].0, "rust_style");
|
||||
assert!(hits[0].1.contains("explicit error handling"));
|
||||
|
||||
// An unrelated message clears the gate to nothing — no block injected.
|
||||
let none = memory
|
||||
.recall_relevant_by_vector("user_pref_situational", "what is the weather today", 5, 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
none.is_empty(),
|
||||
"an unrelated message must surface no situational preferences"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,28 @@ pub trait Memory: Send + Sync {
|
||||
opts: RecallOpts<'_>,
|
||||
) -> anyhow::Result<Vec<MemoryEntry>>;
|
||||
|
||||
/// Recall documents in `namespace` semantically relevant to `query`, keeping
|
||||
/// only those whose *vector* similarity to the query is at least
|
||||
/// `min_vector_similarity`. Returns `(key, content)` pairs, most-relevant
|
||||
/// first — the key lets callers act on the matched entry (e.g. overwrite a
|
||||
/// contradicting preference by its topic).
|
||||
///
|
||||
/// Unlike [`Self::recall`] (which ranks on a combined keyword + vector +
|
||||
/// freshness score), this gates on the vector component alone, so an
|
||||
/// unrelated query surfaces nothing — the behaviour Lane-B situational
|
||||
/// preferences need. Default returns empty so keyword-only and mock backends
|
||||
/// opt out; the unified store overrides it.
|
||||
async fn recall_relevant_by_vector(
|
||||
&self,
|
||||
namespace: &str,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
min_vector_similarity: f64,
|
||||
) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let _ = (namespace, query, limit, min_vector_similarity);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Retrieves a specific memory entry by exact (namespace, key).
|
||||
async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ mod dispatch;
|
||||
pub(crate) mod onboarding_status;
|
||||
mod plan_exit;
|
||||
pub mod remember_preference;
|
||||
pub mod save_preference;
|
||||
mod skill_delegation;
|
||||
mod spawn_parallel_agents;
|
||||
mod spawn_subagent;
|
||||
@@ -22,6 +23,7 @@ pub use complete_onboarding::CompleteOnboardingTool;
|
||||
pub use delegate::DelegateTool;
|
||||
pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER};
|
||||
pub use remember_preference::RememberPreferenceTool;
|
||||
pub use save_preference::SavePreferenceTool;
|
||||
pub use skill_delegation::SkillDelegationTool;
|
||||
pub use spawn_parallel_agents::SpawnParallelAgentsTool;
|
||||
pub use spawn_subagent::SpawnSubagentTool;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! `save_preference` — explicit two-lane user-preference capture.
|
||||
//!
|
||||
//! Splits a free-form preference into one of two relevance scopes:
|
||||
//!
|
||||
//! - **`general`** → applies to *every* reply (tone, language, identity,
|
||||
//! standing habits). Stored in [`USER_PREF_GENERAL_NAMESPACE`] and injected
|
||||
//! into the system prompt at thread start (Lane A).
|
||||
//! - **`situational`** → only relevant when its topic comes up. Stored in
|
||||
//! [`USER_PREF_SITUATIONAL_NAMESPACE`] and recalled per-turn by semantic
|
||||
//! similarity to the user's message (Lane B).
|
||||
//!
|
||||
//! `topic` is a snake_case slug used as the storage key, so re-saving the same
|
||||
//! topic overwrites the prior value (no duplicates — `ON CONFLICT REPLACE`). A
|
||||
//! topic lives in exactly one scope: writing it under one namespace clears any
|
||||
//! prior copy in the other so a re-categorised preference can't linger in both
|
||||
//! lanes.
|
||||
//!
|
||||
//! Unlike the inference pipeline (`user_profile` facets), these are written
|
||||
//! verbatim and immediately — they bypass the stability detector entirely.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::memory::{safety, Memory, MemoryCategory};
|
||||
use crate::openhuman::security::policy::ToolOperation;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
// Namespace constants live in `memory::preferences` so the write path (here),
|
||||
// the system-prompt builder (Lane A), and per-turn recall (Lane B) all share a
|
||||
// single definition.
|
||||
pub use crate::openhuman::memory::preferences::{
|
||||
USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE,
|
||||
};
|
||||
|
||||
/// Relevance scope chosen by the model when saving a preference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrefScope {
|
||||
/// Applies to every reply regardless of topic.
|
||||
General,
|
||||
/// Only relevant when its topic relates to the current message.
|
||||
Situational,
|
||||
}
|
||||
|
||||
impl PrefScope {
|
||||
/// Parse the `category` argument (case-insensitive).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"general" => Some(Self::General),
|
||||
"situational" => Some(Self::Situational),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage namespace for this scope.
|
||||
pub fn namespace(self) -> &'static str {
|
||||
match self {
|
||||
Self::General => USER_PREF_GENERAL_NAMESPACE,
|
||||
Self::Situational => USER_PREF_SITUATIONAL_NAMESPACE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The opposite scope's namespace — cleared on write so a topic lives in
|
||||
/// exactly one lane.
|
||||
pub fn other_namespace(self) -> &'static str {
|
||||
match self {
|
||||
Self::General => USER_PREF_SITUATIONAL_NAMESPACE,
|
||||
Self::Situational => USER_PREF_GENERAL_NAMESPACE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::General => "general",
|
||||
Self::Situational => "situational",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent tool that saves an explicit user preference into the two-lane store.
|
||||
pub struct SavePreferenceTool {
|
||||
memory: Arc<dyn Memory>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl SavePreferenceTool {
|
||||
pub fn new(memory: Arc<dyn Memory>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { memory, security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SavePreferenceTool {
|
||||
fn name(&self) -> &str {
|
||||
"save_preference"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Save a user preference so it shapes future replies. Call this when the user states or \
|
||||
asks to remember a preference. Choose `category`:\n\
|
||||
- \"general\": applies to EVERY reply regardless of topic — tone, language, identity, \
|
||||
standing habits (e.g. \"reply in British English\", \"be terse\", \"I'm in IST\", \
|
||||
\"I'm vegetarian\"). Present in every conversation.\n\
|
||||
- \"situational\": only relevant when its topic comes up (e.g. \"when writing Rust prefer \
|
||||
X\", \"be formal in emails to my manager\", \"my AWS account is Y\"). Surfaced only when \
|
||||
the user's message relates to it.\n\
|
||||
`topic` is a short snake_case slug (e.g. reply_language, email_tone_boss, cuisine); \
|
||||
re-saving the same topic overwrites the previous value — no duplicates are created."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["topic", "value", "category"],
|
||||
"properties": {
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Short snake_case slug naming what this preference is about, e.g. \
|
||||
reply_language, verbosity, cuisine, email_tone_boss. Lowercase \
|
||||
letters, digits, and underscores only. Re-saving the same topic \
|
||||
replaces the previous value."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The preference in plain language, e.g. \"Reply in British English \
|
||||
spelling and idiom.\""
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["general", "situational"],
|
||||
"description": "general = applies to every reply; situational = only when the \
|
||||
topic is relevant to the current message."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
tracing::debug!(
|
||||
"[tool][save_preference] invoked: topic={:?} category={:?} value_len={}",
|
||||
args.get("topic").and_then(|v| v.as_str()),
|
||||
args.get("category").and_then(|v| v.as_str()),
|
||||
args.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or(0, str::len),
|
||||
);
|
||||
|
||||
// Security gate — Write-level autonomy, mirroring remember_preference.
|
||||
if let Err(error) = self
|
||||
.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "save_preference")
|
||||
{
|
||||
tracing::warn!("[tool][save_preference] security gate rejected: {error}");
|
||||
return Ok(ToolResult::error(error));
|
||||
}
|
||||
|
||||
// Parse category.
|
||||
let category = match args.get("category").and_then(|v| v.as_str()) {
|
||||
Some(s) => match PrefScope::parse(s) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"invalid category {s:?}; must be \"general\" or \"situational\""
|
||||
)));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: category".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Parse topic — non-empty snake_case slug (used as the dedup key).
|
||||
let topic = match args.get("topic").and_then(|v| v.as_str()) {
|
||||
Some(t) => t.trim(),
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: topic".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if topic.is_empty() {
|
||||
return Ok(ToolResult::error("topic cannot be empty".to_string()));
|
||||
}
|
||||
if !topic
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||||
{
|
||||
return Ok(ToolResult::error(format!(
|
||||
"topic {topic:?} contains invalid characters; use only lowercase letters, digits, \
|
||||
and underscores (snake_case)"
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse value — free-form, trimmed.
|
||||
let value = match args.get("value").and_then(|v| v.as_str()) {
|
||||
Some(v) => v.trim(),
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"missing required argument: value".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Ok(ToolResult::error("value cannot be empty".to_string()));
|
||||
}
|
||||
// Same secret guard `memory_store` applies — a credential pasted as a
|
||||
// "preference" would otherwise be stored verbatim and later surfaced or
|
||||
// injected. Reject before any write.
|
||||
if safety::has_likely_secret(value) {
|
||||
tracing::warn!(
|
||||
"[tool][save_preference] rejected secret-like value topic={} value_chars={}",
|
||||
topic,
|
||||
value.len()
|
||||
);
|
||||
return Ok(ToolResult::error(
|
||||
"Refusing to store content that looks like a secret. Remove credentials or \
|
||||
tokens and try again."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let namespace = category.namespace();
|
||||
|
||||
tracing::debug!(
|
||||
"[tool][save_preference] storing namespace={} topic={} category={} value_len={}",
|
||||
namespace,
|
||||
topic,
|
||||
category.as_str(),
|
||||
value.len()
|
||||
);
|
||||
|
||||
match self
|
||||
.memory
|
||||
.store(namespace, topic, value, MemoryCategory::Core, None)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"[tool][save_preference] saved namespace={} topic={} category={}",
|
||||
namespace,
|
||||
topic,
|
||||
category.as_str()
|
||||
);
|
||||
// A topic lives in exactly one scope. Now that the new write has
|
||||
// succeeded, clear any prior copy in the other namespace so a
|
||||
// re-categorised preference doesn't linger in both lanes. Done
|
||||
// *after* the store (not before) so a store failure can never
|
||||
// leave the user with neither copy.
|
||||
if let Err(e) = self.memory.forget(category.other_namespace(), topic).await {
|
||||
tracing::debug!(
|
||||
"[tool][save_preference] clearing other-scope copy failed (non-fatal) ns={} topic={}: {e}",
|
||||
category.other_namespace(),
|
||||
topic
|
||||
);
|
||||
}
|
||||
// Surface semantically-related existing preferences so the chat
|
||||
// agent (which captured this preference) can spot and resolve a
|
||||
// contradiction itself — no separate model call.
|
||||
let related = crate::openhuman::memory::preferences::recall_related_preferences(
|
||||
&self.memory,
|
||||
value,
|
||||
topic,
|
||||
4,
|
||||
)
|
||||
.await;
|
||||
let mut msg = format!("Saved {} preference: {topic} = {value}", category.as_str());
|
||||
if !related.is_empty() {
|
||||
tracing::info!(
|
||||
"[tool][save_preference] {} related preference(s) surfaced for contradiction check",
|
||||
related.len()
|
||||
);
|
||||
msg.push_str(
|
||||
"\n\nExisting preferences related to this one — check for contradictions:",
|
||||
);
|
||||
for (other_topic, other_value) in &related {
|
||||
msg.push_str(&format!("\n- {other_topic}: {other_value}"));
|
||||
}
|
||||
msg.push_str(
|
||||
"\n\nIf any of these conflicts with what was just saved, resolve it now: \
|
||||
overwrite that topic with save_preference, or remove it with memory_forget. \
|
||||
Otherwise leave them as-is.",
|
||||
);
|
||||
}
|
||||
Ok(ToolResult::success(msg))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[tool][save_preference] failed to store namespace={} topic={}: {e:#}",
|
||||
namespace,
|
||||
topic
|
||||
);
|
||||
Ok(ToolResult::error(format!("Failed to save preference: {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "save_preference_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Tests for the `save_preference` two-lane preference tool.
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::UnifiedMemory;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_security() -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy::default())
|
||||
}
|
||||
|
||||
fn test_mem() -> (TempDir, Arc<dyn Memory>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
(tmp, Arc::new(mem))
|
||||
}
|
||||
|
||||
async fn keys_in(mem: &Arc<dyn Memory>, namespace: &str) -> Vec<String> {
|
||||
mem.list(Some(namespace), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── PrefScope ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pref_scope_parse_case_insensitive() {
|
||||
assert_eq!(PrefScope::parse("general"), Some(PrefScope::General));
|
||||
assert_eq!(
|
||||
PrefScope::parse("Situational"),
|
||||
Some(PrefScope::Situational)
|
||||
);
|
||||
assert_eq!(
|
||||
PrefScope::parse("SITUATIONAL"),
|
||||
Some(PrefScope::Situational)
|
||||
);
|
||||
assert_eq!(PrefScope::parse("bogus"), None);
|
||||
assert_eq!(PrefScope::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pref_scope_namespace_mapping() {
|
||||
assert_eq!(PrefScope::General.namespace(), USER_PREF_GENERAL_NAMESPACE);
|
||||
assert_eq!(
|
||||
PrefScope::Situational.namespace(),
|
||||
USER_PREF_SITUATIONAL_NAMESPACE
|
||||
);
|
||||
assert_eq!(
|
||||
PrefScope::General.other_namespace(),
|
||||
USER_PREF_SITUATIONAL_NAMESPACE
|
||||
);
|
||||
assert_eq!(
|
||||
PrefScope::Situational.other_namespace(),
|
||||
USER_PREF_GENERAL_NAMESPACE
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tool metadata ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_name_and_permission() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem, test_security());
|
||||
assert_eq!(tool.name(), "save_preference");
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_has_required_fields() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem, test_security());
|
||||
let schema = tool.parameters_schema();
|
||||
let required: Vec<&str> = schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert!(required.contains(&"topic"));
|
||||
assert!(required.contains(&"value"));
|
||||
assert!(required.contains(&"category"));
|
||||
}
|
||||
|
||||
// ── Argument validation ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_category_returns_error() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem, test_security());
|
||||
let r = tool
|
||||
.execute(json!({"topic": "x", "value": "y", "category": "bogus"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_error);
|
||||
assert!(r.output().contains("category"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_topic_chars_returns_error() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem, test_security());
|
||||
let r = tool
|
||||
.execute(json!({"topic": "Bad Topic!", "value": "y", "category": "general"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_value_returns_error() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem, test_security());
|
||||
let r = tool
|
||||
.execute(json!({"topic": "topic", "value": " ", "category": "general"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secret_like_value_is_rejected_before_write() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem.clone(), test_security());
|
||||
let r = tool
|
||||
.execute(json!({
|
||||
"topic": "api",
|
||||
"value": "api_key=sk-123456789012345678901234567890",
|
||||
"category": "general",
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_error);
|
||||
assert!(r.output().contains("looks like a secret"));
|
||||
// Nothing persisted in either lane.
|
||||
assert!(keys_in(&mem, USER_PREF_GENERAL_NAMESPACE).await.is_empty());
|
||||
assert!(keys_in(&mem, USER_PREF_SITUATIONAL_NAMESPACE)
|
||||
.await
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
// ── Storage behaviour ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_general_pref_to_general_namespace() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem.clone(), test_security());
|
||||
let r = tool
|
||||
.execute(json!({
|
||||
"topic": "reply_language",
|
||||
"value": "Reply in British English.",
|
||||
"category": "general"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r.is_error, "expected success, got: {}", r.output());
|
||||
|
||||
assert!(keys_in(&mem, USER_PREF_GENERAL_NAMESPACE)
|
||||
.await
|
||||
.contains(&"reply_language".to_string()));
|
||||
assert!(keys_in(&mem, USER_PREF_SITUATIONAL_NAMESPACE)
|
||||
.await
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recategorising_moves_pref_between_namespaces() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = SavePreferenceTool::new(mem.clone(), test_security());
|
||||
|
||||
// Save as general.
|
||||
tool.execute(json!({"topic": "tone", "value": "be terse", "category": "general"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(keys_in(&mem, USER_PREF_GENERAL_NAMESPACE)
|
||||
.await
|
||||
.contains(&"tone".to_string()));
|
||||
|
||||
// Re-save the same topic as situational → moves namespaces, no stale copy.
|
||||
tool.execute(
|
||||
json!({"topic": "tone", "value": "be terse in code reviews", "category": "situational"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(keys_in(&mem, USER_PREF_SITUATIONAL_NAMESPACE)
|
||||
.await
|
||||
.contains(&"tone".to_string()));
|
||||
assert!(
|
||||
!keys_in(&mem, USER_PREF_GENERAL_NAMESPACE)
|
||||
.await
|
||||
.contains(&"tone".to_string()),
|
||||
"the general-scope copy must be cleared when re-categorised"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contradiction surfacing (chat-affirmed) ──────────────────────────────────
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Keyword-sensitive embedder so prefs about the same theme embed close together
|
||||
/// (high cosine) and unrelated ones don't.
|
||||
struct KwEmbedder;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::openhuman::embeddings::EmbeddingProvider for KwEmbedder {
|
||||
fn name(&self) -> &str {
|
||||
"kw"
|
||||
}
|
||||
fn model_id(&self) -> &str {
|
||||
"kw"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
2
|
||||
}
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let l = t.to_lowercase();
|
||||
vec![
|
||||
if l.contains("terse") || l.contains("verbose") || l.contains("detail") {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
if l.contains("rust") { 1.0 } else { 0.0 },
|
||||
]
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn kw_mem() -> (TempDir, Arc<dyn Memory>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = UnifiedMemory::new(tmp.path(), Arc::new(KwEmbedder), None).unwrap();
|
||||
(tmp, Arc::new(mem))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_surfaces_related_preference_for_contradiction_check() {
|
||||
let (_tmp, mem) = kw_mem();
|
||||
let tool = SavePreferenceTool::new(mem.clone(), test_security());
|
||||
|
||||
tool.execute(json!({"topic": "verbosity", "value": "always be terse", "category": "general"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A semantically-related pref under a different topic.
|
||||
let r = tool
|
||||
.execute(json!({
|
||||
"topic": "explanation_style",
|
||||
"value": "give detailed verbose explanations",
|
||||
"category": "general"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r.is_error);
|
||||
assert!(
|
||||
r.output().contains("verbosity") && r.output().contains("always be terse"),
|
||||
"expected the related pref to be surfaced for a contradiction check, got: {}",
|
||||
r.output()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_unrelated_preference_surfaces_nothing() {
|
||||
let (_tmp, mem) = kw_mem();
|
||||
let tool = SavePreferenceTool::new(mem.clone(), test_security());
|
||||
|
||||
tool.execute(json!({"topic": "verbosity", "value": "always be terse", "category": "general"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// An unrelated pref (rust) — no contradiction note.
|
||||
let r = tool
|
||||
.execute(json!({
|
||||
"topic": "rust_edition",
|
||||
"value": "use rust 2021 edition",
|
||||
"category": "situational"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r.is_error);
|
||||
assert!(
|
||||
!r.output().contains("check for contradictions"),
|
||||
"an unrelated pref should surface no related prefs, got: {}",
|
||||
r.output()
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,10 @@ impl Tool for MemoryStoreTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Store a fact, preference, or note in a namespace. Requires explicit namespace (e.g. global, background, autocomplete, skill-telegram)."
|
||||
"Store a general fact or note in a namespace (e.g. global, background, autocomplete, skill-{id}). \
|
||||
Do NOT use this for user preferences — for any preference (how the user wants you to behave, \
|
||||
their tastes, settings, standing instructions) call `save_preference` instead, which routes it \
|
||||
to the preference store the assistant actually reads. Requires an explicit namespace."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
|
||||
@@ -159,6 +159,10 @@ pub fn all_tools_with_runtime(
|
||||
memory.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
// Two-lane explicit preferences (general → system prompt, situational →
|
||||
// per-query recall). Written verbatim to user_pref_{general,situational};
|
||||
// bypasses the inference/stability pipeline. Always registered.
|
||||
Box::new(SavePreferenceTool::new(memory.clone(), security.clone())),
|
||||
// WhatsApp data store — read-only agent surface (issue #1341).
|
||||
// The matching `whatsapp_data_ingest` write-path stays internal-only
|
||||
// (registered in `src/core/all.rs::build_internal_only_controllers`)
|
||||
|
||||
Reference in New Issue
Block a user