Skip to content

fix(plugin): confine automatic recall to the calling session by default - #2562

Open
saya-ch wants to merge 1 commit into
MemTensor:mainfrom
saya-ch:fix/recall-scope-session
Open

saya-ch wants to merge 1 commit into
MemTensor:mainfrom
saya-ch:fix/recall-scope-session

Conversation

@saya-ch

@saya-ch saya-ch commented Nov 5, 2026 •

Copy link
Copy Markdown

Description

Automatic recall selected every trace visible to the profile. A trace stores its transcript verbatim ([user] … / [assistant] …), so with two Sessions open under one profileId, one Session's assistant text reached another Session's prompt with nothing distinguishing it from that Session's own history. Observed on DSH 0.2.1-rc.5: Session 08a39873 recalled a trace owned by Session f03c9595, whose assistant field opened with a first-person self-description.

Root cause: core/runtime/namespace.ts::visibilityWhere() filters on owner_agent_kind and owner_profile_id only; it has no session dimension. The storage layer already accepts filter.sessionId — retrieval never passed it down.

Add algorithm.retrieval.recallScope:

  • session (new default) — only traces written by the calling Session may be injected.
  • profile — previous behaviour; every Session sharing this home and profileId recalls every other Session's transcript.

Both retrieval entry points are scoped through one shared helper, resolveRecallSessionKey(): orchestrator.retrievalDepsFor() for turn-start / skill / sub-agent / repair recall, and memory-core.searchMemory() for tool-driven recall. The resolved pin is pushed into SQL so foreign rows cannot consume the candidate budget, and is also exposed as RetrievalConfig.sessionKey so tier-2 filter builders suppress contradictory same-session exclusions. A missing Session id now logs recall.scope_unscoped instead of silently widening to profile-wide recall.

policies / world_model stay profile-wide: they are statement-shaped rather than transcript-shaped, so cross-session sharing there is the intended design. getManyByIds remains a pure id lookup so L2/L3 evidence citations cannot lose their support rows. A Viewer toggle exposes the same key under Settings → General.

This changes the default and is therefore breaking. Users who rely on cross-session recall must set algorithm.retrieval.recallScope: profile. The previous behaviour silently corrupts agent identity, which is worse than losing recall breadth, but the choice belongs in release notes. All changes are under apps/memos-local-plugin/; production dependencies are unchanged.

A new Session now starts with no recall until it has written its first trace. That is the intended trade-off, not an oversight. When algorithm.lightweightMemory is disabled, the L2/L3 path remains the cross-session channel.

Related Issue: #1532 (same failure mode; #1869 addressed it for the OpenClaw adapter, whose change set touches only apps/memos-local-openclaw/), #2520 (DSH 0.2.x support; this does not change peer ranges or the Cloud plugin).

Review feedback addressed

Two rounds of Open Code Review. Both rounds found real defects; the fixes are cumulative in this revision.

Round 1 (a347647 → 53764d2) — the original revision scoped only the tool-driven path:

# finding resolution
1, 7 turn_start recall bypassed the scoped deps via orchestrator.retrievalDepsFor() retrievalDepsFor(namespace, sessionKey) now takes the key; all six retrieval entry points thread it through
2 recallScope at algorithm top level created two sources of truth moved to algorithm.retrieval.recallScope
3 isVisibleTo(row as never, …) erased the row type Parameters<typeof isVisibleTo>[0] & { sessionId?: string }
4 searchByErrorSignature double-filtered documented as an invariant guard; SQL stays authoritative for LIMIT
5 unreachable ?? "session" fallback removed
6 misleading RetrievalConfig.recallScope comment rewritten as advisory, naming enforcement sites

Round 2 (53764d2 → e334e0c):

# finding resolution
1, 3 required RetrievalConfig.recallScope breaks existing test fixtures field is optional again; the TypeBox default guarantees a value in real deployments. sessionKey?: string added as the runtime pin.
2 session_id = X AND session_id != X — tier-2 excludeSessionId / visibleContext predicates contradict the pin, zeroing out trace recall buildTraceSearchFilters() reads deps.config.sessionKey and drops same-session exclusions when pinned, keeping the ts / user_text constraints
4, 5 duplicated scoping rule and silent fail-open on a missing Session id extracted resolveRecallSessionKey(recallScope, sessionId, onUnscoped) into retrieval-repos.ts; both call sites use it and log recall.scope_unscoped
6 session-filtered getManyByIds silently drops L2/L3 evidence rows restored to pure id hydration; confinement stays in the search channels
7 patch("algorithm") merges one level deep, overwriting sibling retrieval.* onPatchAlgorithm({ retrieval: { ...algorithm.retrieval, recallScope } })

New tests for round 2: recall-scope-tier2.test.ts (3 cases) verifies that a pinned Session emits no session_id != @exclude_session_id, that an unpinned one still does, and that the two predicates never co-occur. recall-scope.test.ts was updated to assert getManyByIds stays pure while search channels stay scoped.

Type of change

  • Bug fix — cross-session transcript injection
  • Breaking change — the default recallScope changes from profile-wide to session-scoped

How Has This Been Tested?

Environment: Windows 11 x64, Node 24.13.1, pnpm 10.10.0, DSH 0.2.1-rc.5. Dependencies installed with pnpm install --ignore-scripts.

  • pnpm lint (tsc -p tsconfig.json --noEmit) — passes, no output.
  • npx vitest run tests/unit/retrieval tests/unit/pipeline — 27 files passed, 234 tests passed, 0 failed. This includes the tier1/2/3, ranker and integration fixtures called out in review, confirming the optional field restores type compatibility.

Regression proofs (each verified by temporarily reverting the specific change and re-running):

reverted change failing tests
disable sessionKey entirely 3 failures in recall-scope.test.ts
remove only the SQL predicate, keep JS guard 2 failures
disable turn-start chaining (scopedSessionKey) 1 failure in recall-scope-turnstart.test.ts
disable tier-2 cooperation (scoped = false) 1 failure in recall-scope-tier2.test.ts

The wrapper tests cover searchByVector / text / pattern / error-signature channels, the SQL predicate with a deliberately better-scored foreign trace, profile compatibility, L2/L3 staying unconfined, and pure id hydration. The spy test covers orchestrator wiring; the tier-2 test covers filter cooperation.

Full suite on this machine (first revision): 191 files, 13 failed / 177 passed / 1 skipped; 49 tests failed / 1592 passed. I did not triage every failure. Spot checks place them in two environment-only buckets — tests/unit/config/paths.test.ts asserts POSIX separators and fails on Windows regardless of this change, and tests/unit/embedding/* requires network access to Hugging Face, which this sandbox does not have. Neither module is touched by this PR. A full-suite pass on Linux CI is still required before merge.

A/B smoke on DSH 0.2.1-rc.5, same home and profileId, two concurrent Sessions: before, Session 08a39873 recalled a trace owned by f03c9595. After loading the same change into a local build of 2.0.21-beta.1 and restarting DSH, Session 2169d99e recalled only traces it owned. The "after" side is a local build, not a published artifact, and this smoke alone was not sufficient proof — the review correctly identified gaps that the new tests now cover.

No live profile, credentials, or customer sessions were used for the smoke.

Checklist

  • I have performed a self-review of my own code
  • I have commented the session-scoping logic where it is not self-evident
  • I have added tests demonstrating the fix
  • Documentation update in MemOS-Docs — not applicable; the key is documented in the config schema comment and the Viewer toggle
  • Related issues are linked above
  • Reviewers: @hijzy and @whipser030

Reviewer Checklist

  • Scope reviewed: changes confined to apps/memos-local-plugin/; default-value change called out explicitly
  • Local checks passed on Windows; Linux full-suite run still needed
  • Tests and real output provided

@Memtensor-AI Memtensor-AI added area:plugin OpenClaw & Hermes status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Nov 5, 2026
@saya-ch
saya-ch force-pushed the fix/recall-scope-session branch from 40baa3b to a347647 Compare November 5, 2026 15:34
@Memtensor-AI

Memtensor-AI commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2562
Task: 8571292b4098200d
Base: main
Head: fix/recall-scope-session
Head SHA: e334e0cdd6693550c78d3beb605b20eaed921f9d

🔍 OpenCodeReview found 7 issue(s) in this PR.

⚠️ Review warnings (3)

  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files

1. apps/memos-local-plugin/core/config/defaults.ts (L289)

This introduces a behaviour change on upgrade: existing installs have no recallScope on disk, so they silently inherit session from DEFAULT_CONFIG and lose cross-session recall. However docs/CONFIG-ADVANCED.md's algorithm.retrieval: reference block (lines ~172-201) lists every other retrieval knob but not this one, so operators have no documented way to discover the switch or the opt-out. Please add a recallScope: session # session (default) | profile line (and a short note on the upgrade behaviour) to that block in the same PR.


2. apps/memos-local-plugin/viewer/src/views/SettingsView.tsx (L1022-L1025)

algorithm.retrieval comes from GET /api/v1/config, which returns the fully-resolved retrieval block (all ~25 schema defaults). Spreading it here means toggling this single switch PATCHes every retrieval setting back, and patchConfig deep-merges them into config.yaml. That silently pins today's defaults (future default changes won't reach these users) and can overwrite concurrent external edits. The server already deep-merges (core/config/writer.ts::applyPatch), mirroring the sibling lightweightMemory toggle, so send only the changed leaf.

💡 Suggested Change

Before:

          <ToggleSwitch
            checked={(algorithm.retrieval?.recallScope ?? "session") === "profile"}
            onChange={(v) => onPatchAlgorithm({ retrieval: { ...algorithm.retrieval, recallScope: v ? "profile" : "session" } })}
          />

After:

          <ToggleSwitch
            checked={(algorithm.retrieval?.recallScope ?? "session") === "profile"}
            onChange={(v) => onPatchAlgorithm({ retrieval: { recallScope: v ? "profile" : "session" } })}
          />

3. apps/memos-local-plugin/core/pipeline/retrieval-repos.ts (L118-L119)

getManyByIds dropped the previous isVisibleTo(r, namespace) guard, not just the session pin. That removes the namespace access-control boundary for a publicly exported adapter (core/index.ts exports wrapRetrievalRepos): a caller hydrating ids that originated outside the scoped search channels (e.g. L2/L3 sourceTraceIds, or an id supplied by an embedder) can now receive private traces from another profile/agent. The stated justification ("pure id hydration") does not hold for arbitrary callers, and the current in-repo caller (tier2-trace.ts) already gets its ids from visibility-filtered channels, so keeping the namespace filter costs nothing there. Only the session restriction should be skipped here.

💡 Suggested Change

Before:

        const rows = repos.traces.getManyByIds(ids as readonly TraceId[]);
        return rows.map((r) => ({

After:

        const rows = repos.traces.getManyByIds(ids as readonly TraceId[]);
        return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({

4. apps/memos-local-plugin/core/retrieval/tier2-trace.ts (L355)

When scoped is true this discards the whole visibleContext, so the startTs time bound and the userTexts exclusion below are lost as well. That contradicts the comment just above ("keep the other (ts / user-text) constraints only") and changes behaviour: session-scoped turn-start recall can now re-inject traces that are already visible in the current prompt (repetition) and has no lower time bound. Only the session_id != @visible_context_session_id predicate is contradictory under a pin; the ts/user-text clauses should be kept in a pin-compatible form, e.g. ts < @visible_context_start_ts and user_text NOT IN (...).

💡 Suggested Change

Before:

  const visible = scoped ? undefined : input.visibleContext;

After:

  // Only the session-identity clause is contradictory under a pin; keep the
  // ts / user-text bounds so the already-visible context is still excluded.
  const visible = input.visibleContext;

5. apps/memos-local-plugin/core/pipeline/retrieval-repos.ts (L273)

This named parameter is merged after the caller's opts.params, so a caller-supplied vis_session_key (or a caller where that already references it) is silently overwritten. The generic as T cast hides the collision from the compiler. Prefer a more specific name (e.g. vis_recall_session_key) or document/normalize it before merging.

💡 Suggested Change

Before:

      ...(scoped === undefined ? {} : { vis_session_key: sessionKey }),

After:

      ...(scoped === undefined ? {} : { vis_recall_session_key: sessionKey }),

6. apps/memos-local-plugin/viewer/src/stores/i18n.ts (L964)

This copy overstates the guarantee of the new switch. Per resolveRecallSessionKey in core/pipeline/retrieval-repos.ts, an absent sessionId (e.g. ad-hoc memos_search calls) deliberately falls back to profile-wide recall and only logs recall.scope_unscoped, and the schema comment scopes profile to sessions sharing both the same home and profileId. Users treating this toggle as an isolation guarantee could be misled; consider wording it as "recall is limited to traces from the current session when the caller provides one" and mention the same-profile condition. The zh string at line 1842 has the same gap and should be updated in lockstep.


7. apps/memos-local-plugin/core/pipeline/memory-core.ts (L2952-L2956)

This resolved key is only used for the tool-driven deps built below. When query.reason === "turn_start" (line 2996) the same call is routed to handle.recallTurn, which re-resolves the pin inside the orchestrator from the sessionId computed at line 2964 — and that value falls back to a freshly generated "adhoc-session-" id. With the new default recallScope: "session", that path therefore pins trace recall to a Session that has no rows and returns nothing, while this call site logs recall.scope_unscoped and stays profile-wide. The comment above ("Ad-hoc memos_search calls that omit sessionId stay unscoped") is thus only true for the tool-driven branch. Either feed the resolved sessionKey/sessionId into the recallTurn input, or skip the pin for the adhoc fallback id so both paths agree.

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Node dependency preparation failed for memos_local_plugin: Command failed with exit code 1: npm ci --include=dev --no-audit --no-fund
stdout:
stderr: npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
npm error code 1
npm error path /data/test-workspaces/61f30985f5d371e4/repo/apps/memos-local-plugin/node_modules/onnxruntime-node
npm error command failed
npm error command sh -c node ./script/install
npm error /data/test-workspaces/61f30985f5d371e4/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57
npm error reject(new Error(Failed to download build list. HTTP status code = ${statusCode}));
npm error ^
npm error
npm error Error: Failed to download build list. HTTP status code = 302
npm error at ClientRequest. (/data/test-workspaces/61f30985f5d371e4/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57:18)
npm error at Object.onceWrapper (node:events:634:26)
npm error at ClientRequest.emit (node:events:519:28)
npm error at ClientRequest.emit (node:domain:489:12)
npm error at HTTPParser.parserOnIncomingClient (node:_http_client:780:27)
npm error at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
npm error at TLSSocket.socketOnData (node:_http_client:615:22)
npm error at TLSSocket.emit (node:events:519:28)
npm error at TLSSocket.emit (node:domain:489:12)
npm error at addChunk (node:internal/streams/readable:561:12)
npm error
npm error Node.js v22.22.2
npm error A complete log of this run can be found in: /data/test-workspaces/_shared/npm-downloads/_logs/2026-11-05T15_58_25_293Z-debug-0.log
Branch: fix/recall-scope-session

Tested source: a34764762dc219078bc9a531e60f73af028fad3b
Tested base: d326a6419467c2205041b58533b0ebfdf4f38173
Tested commit: a34764762dc219078bc9a531e60f73af028fad3b
These results apply only to this immutable source/base snapshot.

@saya-ch
saya-ch force-pushed the fix/recall-scope-session branch from a347647 to 53764d2 Compare November 5, 2026 16:18
@saya-ch

saya-ch commented Nov 5, 2026

Copy link
Copy Markdown
Author

Thanks for the review — findings #1 and #7 were correct and critical, and they exposed a gap my own verification had missed.

The first revision scoped only memory-core.searchMemory (the tool-driven memos_search path). The primary turn_start chain goes onTurnStart → recallTurn → retrieveTurnStart → orchestrator.retrievalDepsFor(), which rebuilt its repos with wrapRetrievalRepos(deps.repos, namespace) and never received a sessionKey. So the default claimed isolation while automatic recall stayed profile-wide. I had treated an A/B smoke as proof of the fix, but that run merely happened to surface a same-session trace first; it was correlation, not causation.

Changes in 53764d2:

The reason my five original tests did not catch this is that they exercised wrapRetrievalRepos directly. I added recall-scope-turnstart.test.ts, which spies on the wrapper and asserts the turn-start path itself threads sessionKey. Verified that it fails when scopedSessionKey is temporarily disabled, and passes after the fix. Also added coverage for profile mode leaving recall unscoped.

Current verification: pnpm lint passes; recall-scope.test.ts (5) + recall-scope-turnstart.test.ts (2) + retrieval-repos.test.ts (1) + orchestrator.test.ts (15) = 23 passed. Full suite on this Windows sandbox still has environment-only failures (config/paths.test.ts POSIX separators, embedding/* requiring Hugging Face); a Linux full-suite run is still needed.

Regarding the automated test result: the ENV ISSUE is onnxruntime-node failing to download its build list (HTTP 302) during npm ci — it is a dependency-preparation failure, not a test failure on this branch.

Thanks again for the thorough review; #1/#7 materially improved the fix.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Node dependency preparation failed for memos_local_plugin: Command failed with exit code 1: npm ci --include=dev --no-audit --no-fund
stdout:
stderr: npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
npm error code 1
npm error path /data/test-workspaces/9e60c8bf57ce1aa0/repo/apps/memos-local-plugin/node_modules/onnxruntime-node
npm error command failed
npm error command sh -c node ./script/install
npm error /data/test-workspaces/9e60c8bf57ce1aa0/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57
npm error reject(new Error(Failed to download build list. HTTP status code = ${statusCode}));
npm error ^
npm error
npm error Error: Failed to download build list. HTTP status code = 302
npm error at ClientRequest. (/data/test-workspaces/9e60c8bf57ce1aa0/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57:18)
npm error at Object.onceWrapper (node:events:634:26)
npm error at ClientRequest.emit (node:events:519:28)
npm error at ClientRequest.emit (node:domain:489:12)
npm error at HTTPParser.parserOnIncomingClient (node:_http_client:780:27)
npm error at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
npm error at TLSSocket.socketOnData (node:_http_client:615:22)
npm error at TLSSocket.emit (node:events:519:28)
npm error at TLSSocket.emit (node:domain:489:12)
npm error at addChunk (node:internal/streams/readable:561:12)
npm error
npm error Node.js v22.22.2
npm error A complete log of this run can be found in: /data/test-workspaces/_shared/npm-downloads/_logs/2026-11-05T16_46_15_421Z-debug-0.log
Branch: fix/recall-scope-session

Tested source: 53764d201046071040be485a57e6e2c858d5ac3d
Tested base: d326a6419467c2205041b58533b0ebfdf4f38173
Tested commit: 53764d201046071040be485a57e6e2c858d5ac3d
These results apply only to this immutable source/base snapshot.

@saya-ch
saya-ch force-pushed the fix/recall-scope-session branch from 53764d2 to e334e0c Compare November 5, 2026 18:11
@saya-ch

saya-ch commented Nov 5, 2026

Copy link
Copy Markdown
Author

Thanks for the second pass — finding #2 was the important one, and it identified a defect my first fix introduced rather than one it missed.

retrieve.ts supplies excludeSessionId = <calling session> for turn_start recall whenever lightweightMemory is off, and buildTraceSearchFilters() turns that into session_id != @exclude_session_id. AND-ing it with the new SQL pin (session_id = @vis_session_key) yields an always-empty predicate. So on those hosts the default would have disabled trace recall entirely. I confirmed this in the source before changing anything.

Changes in e334e0c:

Current verification: pnpm lint passes with no output; retrieval + pipeline suites are 234/234. Four independent regression probes (disable sessionKey, remove only the SQL predicate, disable turn-start chaining, disable tier-2 cooperation) each produce failures in the corresponding test, so every layer is covered by an assertion that actually binds to it.

The repeated ENV ISSUE is still onnxruntime-node failing to fetch its build list (HTTP 302) during npm ci — a dependency-preparation failure in the executor, identical in both runs and unrelated to this branch. A Linux full-suite run remains needed before merge.

Thanks again; the second round caught a real regression in my own fix.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Executor error: Node dependency preparation failed for memos_local_plugin: Command failed with exit code 1: npm ci --include=dev --no-audit --no-fund
stdout:
stderr: npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
npm error code 1
npm error path /data/test-workspaces/8571292b4098200d/repo/apps/memos-local-plugin/node_modules/onnxruntime-node
npm error command failed
npm error command sh -c node ./script/install
npm error /data/test-workspaces/8571292b4098200d/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57
npm error reject(new Error(Failed to download build list. HTTP status code = ${statusCode}));
npm error ^
npm error
npm error Error: Failed to download build list. HTTP status code = 302
npm error at ClientRequest. (/data/test-workspaces/8571292b4098200d/repo/apps/memos-local-plugin/node_modules/onnxruntime-node/script/install-utils.js:57:18)
npm error at Object.onceWrapper (node:events:634:26)
npm error at ClientRequest.emit (node:events:519:28)
npm error at ClientRequest.emit (node:domain:489:12)
npm error at HTTPParser.parserOnIncomingClient (node:_http_client:780:27)
npm error at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
npm error at TLSSocket.socketOnData (node:_http_client:615:22)
npm error at TLSSocket.emit (node:events:519:28)
npm error at TLSSocket.emit (node:domain:489:12)
npm error at addChunk (node:internal/streams/readable:561:12)
npm error
npm error Node.js v22.22.2
npm error A complete log of this run can be found in: /data/test-workspaces/_shared/npm-downloads/_logs/2026-11-05T18_19_06_379Z-debug-0.log
Branch: fix/recall-scope-session

Tested source: e334e0cdd6693550c78d3beb605b20eaed921f9d
Tested base: d326a6419467c2205041b58533b0ebfdf4f38173
Tested commit: e334e0cdd6693550c78d3beb605b20eaed921f9d
These results apply only to this immutable source/base snapshot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:plugin OpenClaw & Hermes status:in-progress Someone or AI is working on it | 人工或 AI 正在处理

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants