# NousResearch/hermes-agent · Per-user and per-chat memory scoping across components

## Open now

- 6 issues, 4 PRs open on GitHub.
- #11430: Feature: Per-user memory isolation in group chats to prevent identity contamination
- #14162: Feature: Per-conversation context isolation for multi-chat deployments
- #27182: Per-user USER.md isolation for multi-user platform deployments
- #28279: Feature: Per-Chat Memory Isolation (Scoped Memory)
- #34605: Feature request: opt-in X-Hermes-User-Id header to scope per-user long-term memory over the HTTP API
- #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
- PR #34609: feat(gateway): opt-in X-Hermes-User-Id header for per-user memory scope
- PR #42322: fix(cli): pass user_id to AIAgent for correct Hindsight bank_id_template rendering
- PR #60458: Propagate dashboard auth identity to TUI agents
- PR #71224: feat(memory): per-chat memory isolation via optional entry-level \`scope\` (#28279)

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.

## TL;DR

- 24 member(s): 6 open issue(s), 5 closed issue(s), 4 open PR(s), 1 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #60745. Verify it addresses your case.
- **Recommended:** read PR #60745 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

- #11430: Feature: Per-user memory isolation in group chats to prevent identity contamination
- #14162: Feature: Per-conversation context isolation for multi-chat deployments
- #27182: Per-user USER.md isolation for multi-user platform deployments
- #28279: Feature: Per-Chat Memory Isolation (Scoped Memory)
- #34605: Feature request: opt-in X-Hermes-User-Id header to scope per-user long-term memory over the HTTP API
- #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

## Closed / duplicate issues

- #25084: \[Feature\]: Add force_user_id config to Mem0 plugin to prevent gateway namespace fragmentation
- #35408: \[Feature\]: Dashboard authenticated user identity should propagate to the agent session
- #42321: TUI: AIAgent missing user_id — Hindsight bank_id_template renders empty user segment
- #52900: \[Bug\]: Cross-user long-term memory bleed on multi-user messaging platforms
- #64340: mem0 plugin: group chat memory stored under wrong user_id due to agent cache reuse

## Prior & competing PRs

- **#11452: feat: per-user memory isolation in group chats (#11430)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#60745: fix(dashboard): scope chat attach tokens by session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/web_server.py`, `tests/test_pty_keepalive_ws.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"]]
    ```
- **#27183: Add per-user USER.md isolation in MemoryStore**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#23784`
- **#34609: feat(gateway): opt-in X-Hermes-User-Id header for per-user memory scope**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/api_server.py`, `gateway/platforms/api_server_openai_routes.py`, `gateway/platforms/api_server_response_admissions.py`, `gateway/platforms/api_server_runs.py`, `gateway/session_api_turn.py`, `gateway/session_execution.py`, `tests/gateway/test_api_server.py`, `website/docs/user-guide/features/api-server.md`
- **#42322: fix(cli): pass user_id to AIAgent for correct Hindsight bank_id_template rendering**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/cli_agent_setup_mixin.py`
- **#52903: fix(memory): isolate long-term memory per user and per group**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`; refs `8a384628a`, `6bd43111d`, `#4845`, `#29006`, `#4845`
- **#60458: Propagate dashboard auth identity to TUI agents**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/agent_init.py`, `agent/conversation_loop.py`, `hermes_cli/dashboard_auth/routes.py`, `hermes_cli/dashboard_auth/ws_tickets.py`, `hermes_cli/web_server.py`, `tests/agent/test_agent_init_dashboard_identity.py`, `tests/agent/test_conversation_loop_identity.py`, `tests/hermes_cli/test_dashboard_auth_ws_auth.py`, `tests/hermes_cli/test_dashboard_auth_ws_tickets.py`, `tests/hermes_cli/test_web_server.py`, `tests/test_tui_gateway_server.py`, `tui_gateway/server.py`, `tui_gateway/ws.py`
- **#62563: fix: propagate dashboard auth user identity to agent construction (#62549)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#62549`, `#60458`, `#62563`
- **#62629: fix(dashboard): propagate authenticated user_id through PTY spawn to agent (#62549)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#62563`, `#62549`, `#62563`, `#62549`, `#60458`, `#62563`, `#60458`
- **#62877: fix(dashboard): propagate auth identity to agent construction**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#63217: fix(dashboard): propagate auth user identity to agent construction (#62549)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#64398: fix(mem0): use per-turn user identity for group chat memory (#64340)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `46e87b14f`
- **#71224: feat(memory): per-chat memory isolation via optional entry-level \`scope\` (#28279)**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `924bc67ee`, `9ca954a27`, `83ffe4dfa321`, `#2933`, `#60462`, `#71224`, `#2933`, `#2942`, `#4623`, `#5423`, `#60462`, `#60462`, `#71224`, `#28279`, `#71224`, `#2933`, `#60462`, `#71224`, `#60462`, `#71224`, `#2933`, `#374151`, `#28279`, `#71224`
  - files: `agent/agent_init.py`, `tests/gateway/test_scoped_memory_isolation_28279.py`, `tests/tools/test_memory_scope.py`, `tools/memory_tool.py`

## Fix status

- Merged to main in PR #60745 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `agent`
