Skip to content

feat(gateway): per-topic profile isolation for Telegram forum topics - #53048

Open
VKirill wants to merge 5 commits into
NousResearch:mainfrom
VKirill:feat/profile-isolation-pr
Open

feat(gateway): per-topic profile isolation for Telegram forum topics#53048
VKirill wants to merge 5 commits into
NousResearch:mainfrom
VKirill:feat/profile-isolation-pr

Conversation

@VKirill

@VKirill VKirill commented Jun 26, 2026

Copy link
Copy Markdown

What does this PR do?

Routes different Telegram forum topics to different Hermes profiles under a single bot, with full data isolation per profile. Bind a topic with /profile <name>; the entire turn then runs scoped to that profile's home, so each topic gets its own model, SOUL.md/identity, memory, sessions, skills, MCP servers, credentials, and background processes.

The problem: today one bot token = one profile, so running specialised agents (coding, research, marketing…) per forum topic requires N separate bot tokens and N gateway processes. This lets a single gateway dispatch each topic to a dedicated profile with hard data isolation, while unrouted topics are completely unchanged (zero-regression).

This is a smaller, scoped take on the approach explored in #18510 and #20096: the routing entry point reuses the existing per-topic binding, and isolation is applied as a per-turn HERMES_HOME + secret scope rather than a large standalone routing engine.

Related Issue

Fixes #10143
Fixes #4321

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/run.py — per-turn _profile_runtime_scope for routed topics; _resolve_profile_home_for_source validates the profile identity, auto-migrates a missing .hermes_profile.json marker, and logs a loud warning instead of silently degrading to the global home; RoutingSessionStoreProxy/RoutingSessionDBProxy re-resolve the active profile home on every call so session history is persisted to the profile's state.db even from background/executor tasks.
  • hermes_cli/profiles.py — identity marker (.hermes_profile.json) write/validate with ancestry + symlink-escape checks; hermes profile audit-isolation <name> diagnostic (reports cross-profile secret/config overlap without printing secrets).
  • tools/mcp_tool.py — MCP connections keyed by a config fingerprint so same-named servers across profiles with different .env tokens get separate connections; tool names namespaced.
  • tools/file_tools.py — hard-guard blocks writes outside the active profile home (including the global / another profile's SOUL.md); guard runs even when path resolution fails.
  • agent/auxiliary_client.py, hermes_cli/{auth,runtime_provider,env_loader}.py — provider / auth.json / credential-pool resolution scoped to the profile's .env without mutating os.environ; fail-closed on missing scoped credentials.
  • tools/{process_registry,skills_tool,skill_manager_tool,terminal_tool,memory_tool}.py, cron/jobs.py, cron/suggestions.py — process registry, skills, subprocess HOME, cron paths scoped to the active profile.
  • Tests across tests/gateway/, tests/tools/, tests/hermes_cli/, tests/agent/ for routing, session/DB scoping, MCP fingerprint isolation, skills isolation, subprocess HOME isolation, identity marker, and credential scoping.

How to Test

pytest tests/gateway/test_profile_isolation_rework_suite.py \
       tests/gateway/test_topic_profile_routing.py \
       tests/tools/test_mcp_profile_isolation.py \
       tests/tools/test_skills_profile_isolation.py \
       tests/test_subprocess_home_isolation.py \
       tests/hermes_cli/test_profiles.py -q

Manual / live (single Telegram bot, 3 routed topics):

  1. Create ~/.hermes/profiles/<name>/ with its own config.yaml, SOUL.md, .env; bind a topic with /profile <name>.
  2. Ask "who are you?" in each topic → each answers with its own SOUL/identity.
  3. Have each topic remember a distinct fact → the message history lands in that profile's state.db; the global state.db is not written.
  4. Ask the agent to write_file("SOUL.md", …) in a routed topic → it writes to profiles/<name>/SOUL.md; the global ~/.hermes/SOUL.md is untouched.
  5. Configure a same-named MCP server (e.g. github) in two profiles with different .env tokens → each resolves to a separate connection.
  6. An unrouted topic behaves exactly as before.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run the targeted isolation/foundation suites and they pass (670 passed). Note: the full pytest tests/ -q has order-dependent flakiness already present on main, unrelated to this PR; the suites touched by this change are green.
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin), single Telegram bot with 3 routed topics

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — user-facing docs for per-topic profile isolation are a planned follow-up; happy to add to docs/ in this PR if preferred
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (binding is via the existing /profile runtime command + per-profile config.yaml; no new global config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) — the scope mechanism is platform-agnostic (ContextVar home + secret scope); subprocess HOME handling preserves existing platform behavior
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (tool behavior unchanged for unrouted use; isolation is transparent)

Screenshots / Logs

Targeted suite result:

$ pytest tests/gateway/test_profile_isolation_rework_suite.py tests/gateway/test_topic_profile_routing.py \
         tests/tools/test_mcp_profile_isolation.py tests/tools/test_skills_profile_isolation.py \
         tests/test_subprocess_home_isolation.py tests/agent/test_auxiliary_client.py \
         tests/hermes_cli/test_profiles.py tests/tools/test_process_registry.py \
         tests/gateway/test_telegram_topic_mode.py tests/gateway/test_session.py -q
670 passed

@VKirill
VKirill force-pushed the feat/profile-isolation-pr branch from b1ad467 to e36d5b8 Compare June 26, 2026 11:35
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint platform/telegram Telegram bot adapter P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jun 26, 2026
@alt-glitch

alt-glitch commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to merged #64835 and open #18510. #64835 now provides static platform/chat/thread profile_routes; this still-open branch carries a large residual per-turn isolation bundle. Rebase and split the residual work before a maintainer chooses an implementation; it is not a duplicate.

@VKirill VKirill closed this Jun 26, 2026
@VKirill
VKirill force-pushed the feat/profile-isolation-pr branch from e36d5b8 to e3db1ef Compare June 26, 2026 13:41
Bind a chat/forum topic to a named profile (`/profile <name>`) and run the
entire turn scoped to that profile's `HERMES_HOME` — its own model, SOUL.md,
memory, session history, skills, MCP servers, and credentials. Topics with no
binding keep current behaviour exactly (zero regression).

Mechanism:
- `_profile_runtime_scope`: per-turn context that layers a home override plus a
  profile secret-scope (no os.environ mutation), restored on exit.
- `RoutingSessionStoreProxy` / `RoutingSessionDBProxy`: resolve the active
  profile home on every call so session history persists to the profile's
  state.db even from background tasks.
- `.hermes_profile.json` identity marker: validated when resolving a profile
  home; auto-migrated if missing and logs a loud warning instead of silently
  falling back to the global home.
- MCP servers are fingerprinted per profile so same-named servers with
  different credentials get separate connections; file tools are hard-guarded
  to the profile home; subprocess/cron inherit the scoped home.
- Per-topic `/model` and `/profile` bindings persist across `/new` and restart
  (`topic_models.json` / `topic_profiles.json`).

Adds tests covering routing, session/DB isolation, MCP and skills isolation,
and subprocess home isolation.
@VKirill VKirill reopened this Jun 26, 2026
@VKirill

VKirill commented Jun 26, 2026

Copy link
Copy Markdown
Author

Reopening after a clean rebuild on top of the latest main.

What changed since the previous version

  • Rebased onto current main — the diff is now exactly the isolation work, nothing else.
  • Stripped every unrelated change that had crept in: no per-platform model override, undo-delete, commands-pagination, todo-checklist, notify/is_agent metadata, voice-echo refactor, i18n, etc. The diff is 38 files, isolation-only.
  • Restored upstream code that an earlier pass had accidentally removed (busy-session wiring, topic auto-rename).

Approach (vs the routing-engine in #18510)
This stays deliberately lightweight: a turn bound to a profile runs inside a per-turn scope that layers a HERMES_HOME override + a profile secret-scope (no os.environ mutation). Session history, SOUL.md, memory, skills, MCP servers, credentials and subprocesses all resolve to the profile's home for that turn; unbound topics behave exactly as before (zero regression). No standalone router or long-lived per-profile processes.

Testing

  • Full isolation suite green (routing, session/DB destination, MCP fingerprint, skills, subprocess home, file-tools hard-guard, identity-marker audit).
  • Verified live end-to-end: two topics bound to two profiles, each told a different code word — each recalls only its own and denies the other's, with the persisted state.db/memory containing only its own data.

Happy to adjust naming or split anything out if it helps a maintainer pick this as the canonical mechanism for the #10143 / #4321 cluster.

Кирилл Вечкасов added 2 commits June 27, 2026 16:00
# Conflicts:
#	cron/jobs.py
#	gateway/run.py
#	tools/mcp_tool.py
Per-topic profile isolation gives each routed profile its own SessionStore
backed by a SQLite connection (db + WAL + SHM = 3 fds) to that profile's
state.db. Two defects made a long-lived multi-profile gateway leak file
descriptors until it hit EMFILE ([Errno 24] Too many open files):

1. `_profile_session_stores` and `_profile_session_dbs` were unbounded dicts
   that only ever grew and were closed solely at shutdown. Every distinct
   profile ever routed to kept its connection open for the whole process
   lifetime.
2. `_session_db` opened its OWN SessionDB to `<home>/state.db`, a SECOND
   connection to the exact file the profile's SessionStore already had open —
   doubling the fds held per profile.

On macOS the launchd gateway inherits RLIMIT_NOFILE=256, so a handful of
profiles plus normal sockets crossed the limit. Once over, every new open()
failed — including the kanban dispatcher's `kanban.db` open, which then spun
retrying on its tick loop and pinned a core.

Fix, mirroring the existing `_agent_cache` LRU pattern:
- `_profile_session_stores` becomes an OrderedDict with `move_to_end()` on hit
  and an LRU cap (`_PROFILE_STORE_CACHE_MAX_SIZE`, default 16); the evicted
  (least-recently-used, idle) store's connection is closed off-thread via
  `_close_evicted_profile_store` (SessionDB.close runs a WAL checkpoint and
  releases the db/WAL/SHM fds). The victim is never the profile served this
  turn, so live sessions aren't torn down mid-write.
- `_session_db` now reuses the profile SessionStore's connection instead of
  opening a second handle; an explicit per-home override (the setter, used by
  tests and back-compat call sites) still wins, preserving the attribute
  contract. The redundant default-home SessionDB built in __init__ is dropped
  (the store already owns it); the NFS/locking init warning is preserved.

Adds tests for LRU eviction + close, connection reuse, and override precedence.
@VKirill
VKirill force-pushed the feat/profile-isolation-pr branch from 5658098 to 8644434 Compare June 28, 2026 00:05
Кирилл Вечкасов and others added 2 commits June 28, 2026 12:10
# Conflicts:
#	gateway/run.py
#	hermes_cli/auth.py
#	tools/registry.py
…onDB

Conflict resolutions and follow-through:
- _session_db property now returns a cached per-profile AsyncSessionDB
  facade, so upstream's awaited call sites work against per-profile
  handles; explicit overrides keep the broad as-assigned contract.
- Handoff watcher keeps the per-profile-home sweep, awaiting through the
  facade instead of asyncio.to_thread on raw handles.
- Session-expiry finalization keeps the per-profile loop and grafts
  upstream's model_override reset at the conversation boundary.
- Topic->profile binding lookups no longer call the DB-touching source
  normalizer bare on the loop: async boundaries offload it via
  asyncio.to_thread (upstream contract), sync helpers take the
  already-normalized source.
- file_tools resolver keeps profile_home tilde expansion on top of
  upstream's container-path handling; runtime_provider/auth keep the
  profile-scoped env parameter alongside upstream's new helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Donmeusi

Donmeusi commented Jul 6, 2026

Copy link
Copy Markdown

Been tracking this since #18510 — the scoped per-topic isolation here is much cleaner than the full routing-engine approach. Currently running a frozen fork of pr-18510 but eager to migrate to this implementation.

Happy to validate on macOS or help with tests if it moves the review forward. Any blockers or rough ETA for merge?

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the focused per-topic routing work. The requested capability remains absent on current main: /profile is display-only (gateway/slash_commands.py:332-345), while the current multiplex adapter path refuses duplicate platform credentials (gateway/run.py:8586-8600), so one bot cannot yet serve multiple profiles by topic.

Problems

  • Blocking: gateway/run.py:16939-16948 catches profile identity failures and falls back to get_hermes_home(). For a persisted bound topic, an invalid, moved, or deleted profile would therefore run under the global profile rather than fail closed. That violates the PR's advertised isolation boundary.

Suggested changes

  • Reject the routed event before agent/session execution when profile validation fails; send a clear topic-local administrative error instead of selecting the global home.
  • Add coverage for a stale binding and invalid/missing identity marker, asserting that global sessions, identity, and credentials are not used.
  • Integrate the binding layer with current main's multiplex scope (gateway/run.py:1413-1444) and AsyncSessionDB architecture.

Automated hermes-sweeper review.

Comment thread gateway/run.py
"home — profile isolation is DEGRADED for this turn",
name, exc,
)
return get_hermes_home()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this fallback routes a topic that is explicitly bound to an invalid/deleted/rejected profile into the global profile. Do not continue the turn under get_hermes_home(); fail closed and return a topic-local administrative error so the requested profile boundary cannot silently degrade.

@alt-glitch alt-glitch added the comp/cli CLI entry point, hermes_cli/, setup wizard label Jul 15, 2026
@alt-glitch

alt-glitch commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to merged #64835 and open #18510. #64835 now provides static platform/chat/thread profile_routes; this still-open branch carries a large residual per-turn isolation bundle. Rebase and split the residual work before a maintainer chooses an implementation; it is not a duplicate.

@alt-glitch alt-glitch added the comp/cron Cron scheduler and job management label Jul 15, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 15, 2026
@alt-glitch alt-glitch added area/auth Authentication, OAuth, credential pools and removed sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 15, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Heads-up: the routing entry point of this PR (Telegram forum topic → profile) has now landed on main via PR #64835 (salvage of #20096 by @Burgunthy) — gateway.profile_routes with platform: telegram, chat_id + thread_id covers the topic→profile match with full per-profile HERMES_HOME isolation.

What this PR still offers beyond main:

  • Runtime /profile <name> topic-binding command (merged routes are static config)
  • MCP connection keying by config fingerprint (same-named servers in different profiles with different tokens)
  • Auxiliary-client / auth.json / credential-pool scoping to the profile's .env
  • Session-store proxies re-resolving profile home from background tasks
  • hermes profile audit-isolation diagnostic

These are isolation-hardening layers underneath the routing decision. If you're up for rebasing onto the merged routing layer and splitting the hardening into focused PRs, they'd be much easier to review than the current 4.4k-line combined change. Leaving open for now.

@Donmeusi

Copy link
Copy Markdown

@VKirill @teknium1

Running a frozen pr-18510 fork in production for a multi-agent Telegram setup (separate profiles per topic with hard isolation: memory, skills, MCP, cron). Evaluated switching to current main + #64835, but the isolation gap is too large for my use case — specifically missing:

  1. MCP fingerprint scoping (tools/mcp_tool.py) — same-named servers with different tokens across profiles collide on main
  2. Auth/credential pool scoping (hermes_cli/auth.py, agent/auxiliary_client.py) — auth.json currently global
  3. Background-task home resolution (tools/process_registry.py, cron/jobs.py) — cron jobs + subprocesses leak to global home
  4. Runtime /profile <name> binding (gateway/slash_commands.py) — static profile_routes in config doesn't cover dynamic topic assignment
  5. File-tools hard-guard (tools/file_tools.py) — writes to foreign profile homes are unblocked on main

These five areas are independent enough that they could each be a focused PR rebased onto the merged main routing layer. The diff would be dramatically smaller per PR and reviewable in ~15 min each instead of one 4.4k-line changeset.

Happy to validate any of these on macOS + real Telegram forum topics if that helps move review forward.

@alt-glitch alt-glitch added comp/tools Tool registry, model_tools, toolsets tool/file File tools (read, write, patch, search) tool/mcp MCP client and OAuth tool/memory Memory tool and memory providers tool/skills Skills system (list, view, manage) tool/terminal Terminal execution and process management needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 15, 2026
@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Jul 19, 2026
@alt-glitch alt-glitch added platform/whatsapp WhatsApp Business adapter platform/telegram Telegram bot adapter and removed platform/telegram Telegram bot adapter platform/whatsapp WhatsApp Business adapter labels Jul 19, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.
Related: #64835 merged profile routing, while #18510 remains an open competing topic-routing bundle. This 38-file branch has material residual scope; please rebase/split before selecting an implementation.

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

Labels

area/auth Authentication, OAuth, credential pools area/profiles Multi-profile isolation, HERMES_HOME scoping comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/file File tools (read, write, patch, search) tool/mcp MCP client and OAuth tool/memory Memory tool and memory providers tool/skills Skills system (list, view, manage) tool/terminal Terminal execution and process management type/feature New feature or request

Projects

None yet

4 participants