feat(memory): two-lane user preferences (save_preference) + model-aware embedding recall#2501
Conversation
Doc-store vectors were stored as raw blobs with no record of which embedding model produced them. After an embedding-model change, cosine recall silently returned 0 (dimension change) or garbage (same dim, different model) instead of excluding stale vectors — unlike the model-aware memory-tree and STM segment_embeddings paths. - add model_signature + dim columns to vector_chunks (CREATE + idempotent ALTER migration) - stamp the active embedder signature + dim on every chunk write - recall excludes chunks whose signature != the active model; dimension guard skips legacy untagged rows instead of scoring them 0 - tests: write tagging, cross-model exclusion, dim-guard for legacy rows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explicit, deterministic preference capture split by relevance scope:
- save_preference(topic, value, category) writes to user_pref_{general,situational};
topic = dedup key (re-saving replaces), and a topic lives in exactly one scope.
Written verbatim — bypasses the inference/stability pipeline.
- Lane A: general (always-on) prefs render as a '## Your standing preferences'
block in the system prompt at thread start (latest-N by recency, bounded).
- Namespace constants + the Lane-A read helper centralised in memory::preferences;
the explicit-prefs path no longer reads the legacy user_profile pinned namespace.
Lane B (per-turn situational recall), contradiction handling, and demoting the
inferred user_profile table to a suggestion feed follow in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Topic-scoped (situational) preferences are recalled every turn against the user's message and injected under a '## Relevant preferences for this message' block on the per-turn context (not the frozen system prompt). - Memory::recall_relevant_by_vector gates on the vector-similarity component alone (default-empty for mocks; UnifiedMemory filters query_namespace_hits by score_breakdown.vector_similarity), so an unrelated message surfaces nothing. - Leverages the model-aware vector_chunks embeddings (worktree A): a stale embedding model signature is excluded rather than mis-scored. - turn.rs injects the situational block every turn; general prefs stay in the frozen system prompt (Lane A). - test: relevant query surfaces the matching pref; unrelated query surfaces none. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same-topic contradictions were already handled (ON CONFLICT REPLACE + recategorise clears the other lane). For cross-topic semantic contradictions, rather than a separate hidden LLM judge, surface related existing preferences to the chat agent that captured the preference — it is already in the loop and can resolve it. - recall_relevant_by_vector now returns (topic, value) pairs so callers can act on the matched entry by topic. - on save, recall_related_preferences finds prefs across both lanes within a high similarity threshold (excluding the just-saved topic) and lists them in the tool result, telling the model to overwrite/remove a conflicting one or leave them. - tests: related pref surfaced for a near-duplicate; nothing for an unrelated pref. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The system prompt's standing-preferences block now sources exclusively from the explicit two-lane store (user_pref_general) in BOTH the explicit-only and full learning paths. The inferred user_profile facets (Archivist heuristic + tree stability detector) are no longer injected as ground truth — a high-confidence facet should be proposed to the user and pinned via save_preference, not silently treated as a standing preference. - fetch_learned_context learning path: the user_profile field loads general prefs instead of the inferred user_profile namespace. - tests: load_general_preferences returns values (not topic keys), newest-first, capped; the explicit-path fetch_learned_context test now uses the two-lane store. Follow-up: a proactive suggestion surface that promotes high-confidence inferred facets into user_pref on user confirmation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chat model preferentially called memory_store (which advertised storing 'preferences') for user preferences, so they landed in an arbitrary namespace that no preference lane reads. Narrow memory_store's description to facts/notes only and explicitly defer all preferences to save_preference (surfaced during live testing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
save_preference was registered globally (ops.rs) but absent from the orchestrator's named tool allowlist, so the model was never offered it and fell back to memory_store even when explicitly asked to use it. Add it next to memory_store. Surfaced during live testing of the two-lane preferences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on lookup OpenHumanCloudEmbedding::state_dir() fell back to ~/.openhuman when openhuman_dir was None (how the memory store builds its embedder), ignoring OPENHUMAN_WORKSPACE. On any non-default workspace the session JWT was never found, so resolve_bearer() bailed, embed() errored, and vector_chunks rows were written with NULL embedding/model_signature/dim — silently disabling all semantic recall, including Lane-B situational preferences. Honour OPENHUMAN_WORKSPACE before the home fallback, matching how the chat provider resolves its dir. Surfaced while live-testing the two-lane preferences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR introduces a dual-lane explicit preference system enabling users to store and recall preferences. General preferences are always injected into system prompts, while situational preferences are recalled per-turn via semantic vector similarity. The implementation includes vector-gating infrastructure, agent turn integration, a new SavePreferenceTool, comprehensive tests, and configuration wiring. ChangesTwo-Lane Explicit Preferences System
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
…ferences - TEST-COVERAGE-MATRIX.md §8.4: six rows mapping the two-lane preference features to their Rust unit tests. - about_app catalog: add the intelligence.remember_preferences capability. - Add fetch_learned_context_loads_general_prefs_when_learning_enabled test covering the demotion path in the learning-enabled branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
src/openhuman/memory/preferences.rs (1)
35-39: ⚡ Quick winAdd debug/trace logging on swallowed memory-read errors.
These paths currently convert errors to empty results silently, which makes "backend failure" indistinguishable from "no preferences found."
As per coding guidelines "
src/**/*.rs: Uselogortracingcrate atdebugortracelevel for Rust diagnostic logs." and "Uselog/tracingatdebugortracelevel on ... error paths ... and any branch that is hard to infer from tests alone."Also applies to: 72-81, 107-116
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/memory/preferences.rs` around lines 35 - 39, Replace the silent unwrap_or_default on memory read failures with an explicit match that logs the error at debug/trace level before returning the empty/default value; specifically, in load_general_preferences (and the analogous blocks at the other locations referenced) capture the Result from memory.list(...).await, and on Err(e) call tracing::debug! or log::debug! with a concise message including the namespace (USER_PREF_GENERAL_NAMESPACE or the respective namespace) and the error (e), then return the default empty Vec/String list—leave the Ok(path) branch returning the existing entries unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/openhuman/memory/preferences.rs`:
- Around line 96-119: The function recall_related_preferences applies the
per-namespace limit and can return more than the requested global limit; update
recall_related_preferences to enforce the global limit by tracking how many
items have been collected (out.len()), computing remaining =
limit.saturating_sub(out.len()) before each call to
memory.recall_relevant_by_vector, passing remaining instead of limit, skipping
the call or breaking the loop when remaining == 0, and still filtering by
exclude_topic; reference the function recall_related_preferences, the
memory.recall_relevant_by_vector calls, and the namespaces
USER_PREF_GENERAL_NAMESPACE and USER_PREF_SITUATIONAL_NAMESPACE as the locations
to change.
In `@src/openhuman/tools/impl/agent/save_preference.rs`:
- Around line 202-213: Reject secret-like values before persisting by running
the same secret-detector used by memory_store on the parsed `value` and
returning a `ToolResult::error` if the detector flags it; update both the first
parse block (where `value` is trimmed and checked for empty) and the second
occurrence referenced (around lines 235-238) to call the memory_store
secret-check utility and abort persistence with a clear error message when a
secret is detected. Ensure you reference the existing `value` variable and
return via `ToolResult::error` so the preference is never stored if flagged.
- Around line 217-239: The current logic calls
self.memory.forget(other_namespace, topic) before writing the new value, which
can delete the existing preference if the subsequent self.memory.store(...)
fails; change the order so you first call self.memory.store(namespace, topic,
value, MemoryCategory::Core, None).await and only if that store succeeds call
self.memory.forget(category.other_namespace(), topic).await to remove the old
copy; propagate or log any store error and make the forget failure non-fatal
(retain the existing debug logging behavior) so you never lose the old
preference when the new write fails.
---
Nitpick comments:
In `@src/openhuman/memory/preferences.rs`:
- Around line 35-39: Replace the silent unwrap_or_default on memory read
failures with an explicit match that logs the error at debug/trace level before
returning the empty/default value; specifically, in load_general_preferences
(and the analogous blocks at the other locations referenced) capture the Result
from memory.list(...).await, and on Err(e) call tracing::debug! or log::debug!
with a concise message including the namespace (USER_PREF_GENERAL_NAMESPACE or
the respective namespace) and the error (e), then return the default empty
Vec/String list—leave the Ok(path) branch returning the existing entries
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7eba92ad-8b20-4383-ac81-af96a4488ab2
📒 Files selected for processing (20)
docs/TEST-COVERAGE-MATRIX.mdsrc/openhuman/about_app/catalog.rssrc/openhuman/agent/agents/orchestrator/agent.tomlsrc/openhuman/agent/harness/session/turn.rssrc/openhuman/agent/harness/session/turn_tests.rssrc/openhuman/embeddings/cloud.rssrc/openhuman/learning/prompt_sections.rssrc/openhuman/memory/mod.rssrc/openhuman/memory/preferences.rssrc/openhuman/memory/store/memory_trait.rssrc/openhuman/memory/store/unified/documents.rssrc/openhuman/memory/store/unified/init.rssrc/openhuman/memory/store/unified/query.rssrc/openhuman/memory/store/unified/query_tests.rssrc/openhuman/memory/traits.rssrc/openhuman/tools/impl/agent/mod.rssrc/openhuman/tools/impl/agent/save_preference.rssrc/openhuman/tools/impl/agent/save_preference_tests.rssrc/openhuman/tools/impl/memory/store.rssrc/openhuman/tools/ops.rs
- Reject secret-like values via memory::safety::has_likely_secret before any write (same guard memory_store uses) — a credential pasted as a 'preference' is no longer stored verbatim. - Clear the opposite-lane copy only *after* the new store succeeds, so a failed store can't leave the user with neither copy (data-loss fix). - recall_related_preferences: spend a shared 'remaining' budget across both namespaces so the total surfaced never exceeds the caller-requested limit. - Add secret_like_value_is_rejected_before_write test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re embedding recall (tinyhumansai#2501) Co-authored-by: sanil-23 <sanil@alphahuman.xyz> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
save_preference(topic, value, category)tool and a two-lane user-preference model: general prefs (always-on) are injected into the system prompt at thread start; situational prefs (topic-scoped) are recalled per turn by semantic similarity to the user's message and injected on the user-message lane.vector_chunksgainsmodel_signature+dim, and recall excludes vectors produced by a different model (and guards dimension mismatches) instead of silently scoring 0 / garbage.OPENHUMAN_WORKSPACE(it fell back to~/.openhuman, so any non-default workspace silently had no embeddings — disabling all semantic recall).save_preference(was landing inmemory_store's arbitrary namespace) and demote inferreduser_profilefacets from prompt injection (explicit prefs are the source of truth; high-confidence inferred facets should be proposed, not silently injected).Problem
globalviamemory_store) that no injection path reads; the inferreduser_profilefacets are lossy (fixed 6-class taxonomy, evidence threshold, half-life decay); and explicit pins were injected only on turn 1 into the frozen system prompt, so mid-session changes went stale.vector_chunksrecorded no embedding model, so an embedding-model change produced cross-space cosine — silent 0 (dim change) or garbage (same dim, different model) — unlike the tree and STMsegment_embeddingsstores, which are model-aware.OpenHumanCloudEmbedding::state_dir()resolved the session JWT from~/.openhuman, ignoringOPENHUMAN_WORKSPACE; on any custom workspaceresolve_bearer()bailed,embed()errored, andvector_chunkswere written NULL — with the error swallowed by.ok().Solution
save_preferencewrites touser_pref_general/user_pref_situational(topic = dedup key →ON CONFLICT REPLACE; a topic lives in exactly one scope). Written verbatim — bypasses the inference/stability pipeline. Namespaces + read helpers centralised inmemory::preferences.## Your standing preferencesblock in the system prompt (latest-N by recency, bounded).Memory::recall_relevant_by_vector— recall gated on the vector-similarity component alone (default-empty for mocks; the unified store filtersquery_namespace_hitsbyscore_breakdown.vector_similarity), so an unrelated message surfaces nothing. Runs every turn on the user-message lane.ON CONFLICT REPLACE; cross-topic = the tool surfaces semantically-related existing prefs in its result so the in-loop chat agent resolves it (no separate model call).vector_chunksmodel-signature: columns + idempotent migration; the active embedder signature + dim are stamped on write; recall excludes other-signature vectors and dim-guards legacy/untagged rows.state_dir()honoursOPENHUMAN_WORKSPACEbefore the home fallback, matching how the chat provider resolves its dir.memory_store's description no longer advertises "preference" (defers tosave_preference), andsave_preferenceis added to the orchestrator's tool allowlist (it was registered globally but never offered).Verified end-to-end on a standalone core (save → Lane A behavioural [British-spelling reply to an American-spelled prompt] → Lane B relevant-injects/unrelated-suppresses → same-topic replace → embeddings produce real vectors after the workspace fix).
Submission Checklist
turn.rsis exercised live. Thecoverage.ymljob (diff-coverovercargo-llvm-cov) is the authoritative gate — if it flags theturn.rslines I'll add a focused turn-level test.docs/TEST-COVERAGE-MATRIX.md§8.4 (rows 8.4.1–8.4.6).## Related.Closes #NNN— N/A: no tracking issue for this change.Impact
save_preference; the orchestrator system prompt gains a standing-preferences block; the per-turn user message may gain a relevant-preferences block.about_appcapabilityintelligence.remember_preferencesadded.vector_chunksgains two nullable columns via idempotentALTER(safe on existing DBs). Legacy rows have NULLmodel_signatureand are accepted with a dim-guard; a full re-embed backfill is a noted follow-up.OPENHUMAN_WORKSPACEembedder fix restores semantic recall for all custom-workspace deployments (tests, multi-instance), not just preferences.Related
vector_chunksrows.user_profilefacets intouser_prefon confirmation.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
feat/user-preferences-two-lane(headsanil-23:feat/user-preferences-two-lane)b816f68bSummary by CodeRabbit
New Features
Improvements