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
11 changes: 11 additions & 0 deletions docs/TEST-COVERAGE-MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/openhuman/about_app/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,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();
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/agent/agents/orchestrator/agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 58 additions & 68 deletions src/openhuman/agent/harness/session/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
);
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()
"[learning] fetch_learned_context: explicit_preferences_enabled — loaded {} general preference(s) for the system prompt",
general.len()
);

return LearnedContextData {
observations: Vec::new(),
patterns: Vec::new(),
user_profile,
reflections: Vec::new(),
tree_root_summaries: Vec::new(),
user_profile: general,
..LearnedContextData::default()
};
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 42 additions & 17 deletions src/openhuman/agent/harness/session/turn_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand Down Expand Up @@ -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,
)
Expand All @@ -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.
Expand Down Expand Up @@ -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
);
}
12 changes: 12 additions & 0 deletions src/openhuman/embeddings/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 2 additions & 2 deletions src/openhuman/learning/prompt_sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
}
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/memory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading