Skip to content

feat(memory): two-lane user preferences (save_preference) + model-aware embedding recall#2501

Merged
senamakel merged 10 commits into
tinyhumansai:mainfrom
sanil-23:feat/user-preferences-two-lane
May 22, 2026
Merged

feat(memory): two-lane user preferences (save_preference) + model-aware embedding recall#2501
senamakel merged 10 commits into
tinyhumansai:mainfrom
sanil-23:feat/user-preferences-two-lane

Conversation

@sanil-23
Copy link
Copy Markdown
Contributor

@sanil-23 sanil-23 commented May 22, 2026

Summary

  • Add a 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.
  • Make the doc-store vector index embedding-model-awarevector_chunks gains model_signature + dim, and recall excludes vectors produced by a different model (and guards dimension mismatches) instead of silently scoring 0 / garbage.
  • Fix the cloud embedder to honour OPENHUMAN_WORKSPACE (it fell back to ~/.openhuman, so any non-default workspace silently had no embeddings — disabling all semantic recall).
  • Route preference-intent to save_preference (was landing in memory_store's arbitrary namespace) and demote inferred user_profile facets from prompt injection (explicit prefs are the source of truth; high-confidence inferred facets should be proposed, not silently injected).

Problem

  • Explicit prefs were unreliable. They went to arbitrary namespaces (global via memory_store) that no injection path reads; the inferred user_profile facets 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.
  • Doc-store vectors weren't model-aware. vector_chunks recorded 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 STM segment_embeddings stores, which are model-aware.
  • Embeddings silently failed off the default workspace. OpenHumanCloudEmbedding::state_dir() resolved the session JWT from ~/.openhuman, ignoring OPENHUMAN_WORKSPACE; on any custom workspace resolve_bearer() bailed, embed() errored, and vector_chunks were written NULL — with the error swallowed by .ok().

Solution

  • save_preference writes to user_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 in memory::preferences.
  • Lane A renders general prefs as a ## Your standing preferences block in the system prompt (latest-N by recency, bounded).
  • Lane B adds Memory::recall_relevant_by_vector — recall gated on the vector-similarity component alone (default-empty for mocks; the unified store filters query_namespace_hits by score_breakdown.vector_similarity), so an unrelated message surfaces nothing. Runs every turn on the user-message lane.
  • Contradiction: same-topic = 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_chunks model-signature: columns + idempotent migration; the active embedder signature + dim are stamped on write; recall excludes other-signature vectors and dim-guards legacy/untagged rows.
  • Embedder workspace fix: state_dir() honours OPENHUMAN_WORKSPACE before the home fallback, matching how the chat provider resolves its dir.
  • Tool routing: memory_store's description no longer advertises "preference" (defers to save_preference), and save_preference is 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

  • Tests added or updated — 35 Rust unit tests across the tool (incl. secret-guard rejection + recategorise-after-store), both lanes' read paths, vector-similarity recall gating, model-signature write/cross-model-exclusion/dim-guard, contradiction surfacing, and the demotion path (happy + failure/edge). Plus the live E2E above.
  • Diff coverage ≥ 80% — unit tests cover the new logic (tool incl. secret-guard + recategorise-after-store, both lanes' read paths, recall gating, model-signature, contradiction, demotion); the per-turn injection wiring in turn.rs is exercised live. The coverage.yml job (diff-cover over cargo-llvm-cov) is the authoritative gate — if it flags the turn.rs lines I'll add a focused turn-level test.
  • Coverage matrix updated — docs/TEST-COVERAGE-MATRIX.md §8.4 (rows 8.4.1–8.4.6).
  • All affected feature IDs listed under ## Related.
  • No new external network dependencies — uses the existing OpenHuman-backend embedder; no new endpoints/crates.
  • Manual smoke checklist — N/A: no release-cut surface changed (core RPC/agent only; no UI).
  • Linked issue closed via Closes #NNN — N/A: no tracking issue for this change.

Impact

  • Runtime: core/agent only — no UI changes. New agent tool save_preference; the orchestrator system prompt gains a standing-preferences block; the per-turn user message may gain a relevant-preferences block. about_app capability intelligence.remember_preferences added.
  • Migration: vector_chunks gains two nullable columns via idempotent ALTER (safe on existing DBs). Legacy rows have NULL model_signature and are accepted with a dim-guard; a full re-embed backfill is a noted follow-up.
  • Compatibility / fix blast radius: the OPENHUMAN_WORKSPACE embedder fix restores semantic recall for all custom-workspace deployments (tests, multi-instance), not just preferences.

Related

  • Closes: N/A
  • Coverage matrix feature IDs: 8.4.1 (save), 8.4.2 (Lane A), 8.4.3 (Lane B), 8.4.4 (same-topic contradiction), 8.4.5 (cross-topic surfacing), 8.4.6 (model-signature guard).
  • Follow-up PR(s)/TODOs:
    • Re-embed backfill for legacy NULL-signature vector_chunks rows.
    • Proactive suggestion surface promoting high-confidence inferred user_profile facets into user_pref on confirmation.

AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: feat/user-preferences-two-lane (head sanil-23:feat/user-preferences-two-lane)
  • Commit SHA: b816f68b

Summary by CodeRabbit

  • New Features

    • Added explicit user preference saving with two modes: general (always applied) and situational (recalled when relevant).
    • System now detects and surfaces related or contradictory preferences for review.
    • General preferences are injected into system prompts; situational preferences are recalled per query.
  • Improvements

    • Enhanced embedding model compatibility and consistency checks.

sanil-23 and others added 8 commits May 22, 2026 16:23
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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7edebdd8-ada6-49c7-8de0-7aa4a62be5a2

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee733c and b816f68.

📒 Files selected for processing (3)
  • src/openhuman/memory/preferences.rs
  • src/openhuman/tools/impl/agent/save_preference.rs
  • src/openhuman/tools/impl/agent/save_preference_tests.rs

📝 Walkthrough

Walkthrough

This 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.

Changes

Two-Lane Explicit Preferences System

Layer / File(s) Summary
Preference module contracts and loading helpers
src/openhuman/memory/mod.rs, src/openhuman/memory/preferences.rs, src/openhuman/memory/traits.rs
Defines USER_PREF_GENERAL_NAMESPACE/USER_PREF_SITUATIONAL_NAMESPACE, tuning constants, load_general_preferences, recall_situational_preferences, recall_related_preferences, and adds Memory::recall_relevant_by_vector default method.
Vector embedding provenance and upsert
src/openhuman/memory/store/unified/init.rs, src/openhuman/memory/store/unified/documents.rs
Adds model_signature and dim columns to vector_chunks, bootstraps columns for existing DBs, and persists embedding model signature and dimensionality during upsert.
Chunk projection and similarity/dimension gating
src/openhuman/memory/store/unified/query.rs, src/openhuman/memory/store/memory_trait.rs
Selects model_signature into StoredChunk and updates vector scoring to skip chunks from different model signatures or with mismatched embedding lengths; implements UnifiedMemory helper to filter vector hits by min similarity.
Embedding and recall tests
src/openhuman/memory/store/unified/query_tests.rs
Adds stub and keyword embedders and tests verifying signature/dim persistence, model-swap exclusion, legacy-dim skipping, and similarity-gated situational recall.
Agent turn context accumulation and preference injection
src/openhuman/agent/harness/session/turn.rs, src/openhuman/agent/harness/session/turn_tests.rs
Makes context mutable, injects per-turn situational preferences after STM recall, changes fetch_learned_context to load standing general preferences, and updates tests.
SavePreferenceTool: explicit two-lane preference storage
src/openhuman/tools/impl/agent/mod.rs, src/openhuman/tools/impl/agent/save_preference.rs, src/openhuman/tools/impl/agent/save_preference_tests.rs
Adds SavePreferenceTool and PrefScope, validates inputs, enforces ToolOperation::Act, stores into selected namespace while clearing the other, recalls related preferences for contradiction checks, and includes comprehensive tests.
Tool/catalog/orchestrator/docs/config wiring
src/openhuman/tools/ops.rs, src/openhuman/about_app/catalog.rs, src/openhuman/agent/agents/orchestrator/agent.toml, src/openhuman/tools/impl/memory/store.rs, src/openhuman/embeddings/cloud.rs, src/openhuman/learning/prompt_sections.rs
Registers SavePreferenceTool, adds "Remember Preferences" capability, updates orchestrator allowlist and memory_store description, prefers OPENHUMAN_WORKSPACE for embedder state dir, and updates prompt heading to "## Your standing preferences".
Test coverage documentation
docs/TEST-COVERAGE-MATRIX.md
Adds 8.4 Explicit User Preferences (Two-Lane) subsection with rows 8.4.1–8.4.6 mapping to unit tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • tinyhumansai/openhuman#2150: Related changes to how explicitly stored user preferences are fetched when learning is disabled and how learned context is assembled.
  • tinyhumansai/openhuman#2175: Related STM/turn injection flow changes in Agent::turn where situational preference injection was added.

Suggested labels

working

Suggested reviewers

  • graycyrus
  • senamakel

Poem

🐰 Two lanes now, a gentle map of mind,
General whispers steady, situational finds,
Vectors guard the paths with signed embrace,
Tools save the truth and clear the old place,
A rabbit hops — preferences in their place.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding two-lane user preferences (save_preference tool) and model-aware embedding recall for the memory system.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

…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>
@sanil-23 sanil-23 marked this pull request as ready for review May 22, 2026 19:54
@sanil-23 sanil-23 requested a review from a team May 22, 2026 19:54
@coderabbitai coderabbitai Bot added the agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. label May 22, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/openhuman/memory/preferences.rs (1)

35-39: ⚡ Quick win

Add 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: Use log or tracing crate at debug or trace level for Rust diagnostic logs." and "Use log / tracing at debug or trace level 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

📥 Commits

Reviewing files that changed from the base of the PR and between 66b3151 and 2ee733c.

📒 Files selected for processing (20)
  • docs/TEST-COVERAGE-MATRIX.md
  • src/openhuman/about_app/catalog.rs
  • src/openhuman/agent/agents/orchestrator/agent.toml
  • src/openhuman/agent/harness/session/turn.rs
  • src/openhuman/agent/harness/session/turn_tests.rs
  • src/openhuman/embeddings/cloud.rs
  • src/openhuman/learning/prompt_sections.rs
  • src/openhuman/memory/mod.rs
  • src/openhuman/memory/preferences.rs
  • src/openhuman/memory/store/memory_trait.rs
  • src/openhuman/memory/store/unified/documents.rs
  • src/openhuman/memory/store/unified/init.rs
  • src/openhuman/memory/store/unified/query.rs
  • src/openhuman/memory/store/unified/query_tests.rs
  • src/openhuman/memory/traits.rs
  • src/openhuman/tools/impl/agent/mod.rs
  • src/openhuman/tools/impl/agent/save_preference.rs
  • src/openhuman/tools/impl/agent/save_preference_tests.rs
  • src/openhuman/tools/impl/memory/store.rs
  • src/openhuman/tools/ops.rs

Comment thread src/openhuman/memory/preferences.rs
Comment thread src/openhuman/tools/impl/agent/save_preference.rs
Comment thread src/openhuman/tools/impl/agent/save_preference.rs Outdated
- 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>
@coderabbitai coderabbitai Bot added the working A PR that is being worked on by the team. label May 22, 2026
@senamakel senamakel merged commit ae0464b into tinyhumansai:main May 22, 2026
30 of 33 checks passed
senamakel pushed a commit to aqilaziz/openhuman that referenced this pull request May 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants