Per-user and per-chat memory scoping across components

NousResearch/hermes-agent · cluster 11430 · agent

Data as of

10 open items. Fix merged in #60745. Check it covers your case.

The short answer

6open issues
4open PRs
24items grouped
Known?
Yes. 24 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #60745. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
6 issues and 4 PRs.

These issues and PRs address propagating user identity through agents, gateways, APIs, and memory plugins to isolate long-term memory per user, per chat, or per session. Work spans bug fixes for identity bleed in multi-user contexts and features for opt-in scoping headers and store-level isolation.

Open issues6

  • Issue#11430Feature: Per-user memory isolation in group chats to prevent identity contamination
  • Issue#14162Feature: Per-conversation context isolation for multi-chat deployments
  • Issue#27182Per-user USER.md isolation for multi-user platform deployments
  • Issue#28279Feature: Per-Chat Memory Isolation (Scoped Memory)
  • Issue#34605Feature request: opt-in X-Hermes-User-Id header to scope per-user long-term memory over the HTTP API
  • Issue#62549[Bug]: Dashboard auth identity (`Session.user_id`) never reaches agent construction — memory providers silently fall back to the default user, breaking multi-user isolation

Open PRs4

  • PR#34609feat(gateway): opt-in X-Hermes-User-Id header for per-user memory scope
  • PR#42322fix(cli): pass user_id to AIAgent for correct Hindsight bank_id_template rendering
  • PR#60458Propagate dashboard auth identity to TUI agents
  • PR#71224feat(memory): per-chat memory isolation via optional entry-level `scope` (#28279)

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60745 fix(dashboard): scope chat attach tokens by session

    merged to main

    files: hermes_cli/web_server.py, tests/test_pty_keepalive_ws.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -15149,7 +15149,8 @@ async def pty_ws(ws: WebSocket) -> None:
               return
       
           # --- spawn PTY ------------------------------------------------------
      -    resume = ws.query_params.get("resume") or None
      +    raw_resume = ws.query_params.get("resume") or None
      +    resume = raw_resume
           profile = ws.query_params.get("profile") or None
           channel = _channel_or_close_code(ws)
           sidecar_url = _build_sidecar_url(channel) if channel else None
      @@ -15192,6 +15193,12 @@ async def pty_ws(ws: WebSocket) -> None:
       
       
           attach_token = [redacted:secret-assignment]"attach") or None
      +    registry_resume = raw_resume
      +    if raw_resume and env:
      +        registry_resume = env.get("HERMES_TUI_RESUME") or raw_resume
      +    if attach_token is not None and (registry_resume or profile):
      +        # Key explicit resumes on their canonical target, never the active-session fallback.
      +        attach_token = f"{attach_token}\0{profile or ''}\0{registry_resume or ''}"
       
           def _spawn():
               return PtyBridge.spawn(argv, cwd=cwd, env=env)
    • tests/test_pty_keepalive_ws.py
      @@ -1,53 +1,136 @@
      +import json
      +
       import pytest
       
       from hermes_cli import web_server
       
       
      [redacted:email]
      -async def test_attach_token_reuses_same_session(monkeypatch):
      -    """Two connects with the same ?attach= token hit one spawned bridge."""
      -    spawned = []
      +class FakeBridge:
      +    def __init__(self):
      +        self.alive = True
      +
      +    def read(self, timeout):
      +        return b""        # idle forever
       
      -    class FakeBridge:
      -        def __init__(self):
      -            self.alive = True
      +    def write(self, data):
      +        pass
       
      -        def read(self, timeout):
      -            return b""        # idle forever
      +    def resize(self, cols, rows):
      +        pass
       
      -        def write(self, data):
      -            pass
      +    def close(self):
      +        self.alive = False
       
      -        def resize(self, cols, rows):
      -            pass
       
      -        def close(self):
      -            self.alive = False
      [redacted:email]
      +def pty_keepalive_harness(monkeypatch):
      +    spawned = []
       
           def fake_spawn(argv, cwd=None, env=None):
               b = FakeBridge()
      -        spawned.append(b)
      +        spawned.append(argv)
               return b
       
           monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn))
      -    # bypass auth + argv resolution for the test
           monkeypatch.setattr(web_server, "_ws_auth_reason", lambda ws: (None, "test"))
           monkeypatch.setattr(web_server, "_ws_host_origin_reason", lambda ws: None)
           monkeypatch.setattr(web_server, "_ws_client_reason", lambda ws: None)
       
           async def fake_argv(**kw):
      -        return (["x"], "/tmp", {})
      +        resume = "child" if kw.get("resume") == "parent" else kw.get("resume")
      +        env = {"HERMES_TUI_RESUME": resume} if resume else {}
      +        return (["x", resume or "fresh"], "/tmp", env)
       
           monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_argv)
       
      -    from starlette.testclient import TestClient
      -
           try:
      -        client = TestClient(web_server.app)
      -        with client.websocket_connect("/api/pty?attach=TOK1") as ws1:
      -            ws1.send_bytes(b"hi")
      -        with client.websocket_connect("/api/pty?attach=TOK1") as ws2:
      -            ws2.send_bytes(b"again")
      -        assert len(spawned) == 1                # reattached, did not respawn
      +        yield spawned
           finally:
               web_server.PTY_REGISTRY._sessions.clear()
      +
      +
      [redacted:email]
      +async def test_attach_token_reuses_same_session(pty_keepalive_harness):
      +    """Two connects with the same ?attach= token hit one spawned bridge."""
      +    from starlette.testclient import TestClient
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1") as ws1:
      +        ws1.send_bytes(b"hi")
      +    with client.websocket_connect("/api/pty?attach=TOK1") as ws2:
      +        ws2.send_bytes(b"again")
      +    assert len(pty_keepalive_harness) == 1                # reattached, did not respawn
      +
      +
      [redacted:email]
      +async def test_attach_token_reuses_same_resume(pty_keepalive_harness):
      +    from starlette.testclient import TestClient
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws1:
      +        ws1.send_bytes(b"hi")
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws2:
      +        ws2.send_bytes(b"again")
      +    assert pty_keepalive_harness == [["x", "same"]]
      +
      +
      [redacted:email]
      +async def test_attach_token_does_not_reuse_different_resume(pty_keepalive_harness):
      +    """A keep-alive token must not pin /chat to an old selected session."""
      +    from starlette.testclient import TestClient
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=old") as ws1:
      +        ws1.send_bytes(b"hi")
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=new") as ws2:
      +        ws2.send_bytes(b"again")
      +    assert pty_keepalive_harness == [["x", "old"], ["x", "new"]]
      +
      +
      [redacted:email]
      +async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness):
      +    from starlette.testclient import TestClient
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=parent") as ws1:
      +        ws1.send_bytes(b"hi")
      +    with client.websocket_connect("/api/pty?attach=TOK1&resume=child") as ws2:
      +        ws2.send_bytes(b"again")
      +    assert pty_keepalive_harness == [["x", "child"]]
      +
      +
      [redacted:email]
      +async def test_attach_token_does_not_reuse_different_profile(pty_keepalive_harness):
      +    from starlette.testclient import TestClient
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1&profile=alpha") as ws1:
      +        ws1.send_bytes(b"hi")
      +    with client.websocket_connect("/api/pty?attach=TOK1&profile=beta") as ws2:
      +        ws2.send_bytes(b"again")
      +    assert len(pty_keepalive_harness) == 2
      +
      +
      [redacted:email]
      +async def test_attach_token_reuses_default_chat_after_active_session_fallback(
      +    pty_keepalive_harness, tmp_path, monkeypatch
      +):
      +    from starlette.testclient import TestClient
      +
      +    active_session_file = tmp_path / "active-session.json"
      +    monkeypatch.setattr(
      +        web_server,
      +        "_active_session_file_for_channel",
      +        lambda app, channel: active_session_file,
      +    )
      +
      +    client = TestClient(web_server.app)
      +    with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws1:
      +        ws1.send_bytes(b"hi")
      +
      +    active_session_file.write_text(json.dumps({"session_id": "existing"}))
      +
      +    with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws2:
      +        ws2.send_bytes(b"again")
      +
      +    assert pty_keepalive_harness == [["x", "fresh"]]

8 PRs closed without merging

From the close comments: duplicate 3 · won't fix / by design 1 · other 4

  • #11452 feat: per-user memory isolation in group chats (#11430) · other
  • #27183 Add per-user USER.md isolation in MemoryStore · other → #23784
  • #52903 fix(memory): isolate long-term memory per user and per group · won't fix / by design → 8a38462, 6bd4311, #4845, #29006
  • #62563 fix: propagate dashboard auth user identity to agent construction (#62549) · other → #62549, #60458, #62563
  • #62629 fix(dashboard): propagate authenticated user_id through PTY spawn to agent (#62549) · duplicate → #62563, #62549, #60458
Show 3 more
  • #62877 fix(dashboard): propagate auth identity to agent construction · duplicate
  • #63217 fix(dashboard): propagate auth user identity to agent construction (#62549) · duplicate
  • #64398 fix(mem0): use per-turn user identity for group chat memory (#64340) · other → 46e87b1
Closed issues · 5
  • Closed#25084[Feature]: Add force_user_id config to Mem0 plugin to prevent gateway namespace fragmentation
  • Closed#35408[Feature]: Dashboard authenticated user identity should propagate to the agent session
  • Closed#42321TUI: AIAgent missing user_id — Hindsight bank_id_template renders empty user segment
  • Closed#52900[Bug]: Cross-user long-term memory bleed on multi-user messaging platforms
  • Closed#64340mem0 plugin: group chat memory stored under wrong user_id due to agent cache reuse
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#60745
Closed issues
5
Attempts
8 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.