Skip to content

feat(gateway): multi-account Telegram — one gateway, N bots, isolated sessions (#8287) - #67455

Open
Hotragn wants to merge 8 commits into
NousResearch:mainfrom
Hotragn:feat/8287-telegram-multi-account
Open

feat(gateway): multi-account Telegram — one gateway, N bots, isolated sessions (#8287)#67455
Hotragn wants to merge 8 commits into
NousResearch:mainfrom
Hotragn:feat/8287-telegram-multi-account

Conversation

@Hotragn

@Hotragn Hotragn commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

One gateway, N Telegram bot accounts, each with fully isolated sessions — closes the gap confirmed in the #10455 review ("the underlying gap remains on current main"), built to that review's acceptance architecture item by item. Claimed on #8287 before building.

The review's checklist → where each lands:

  1. "An account-aware adapter registry … updating delivery, reconnect, status, and lifecycle consumers together"_account_adapters[platform][name], the account-dimension mirror of the proven _profile_adapters pattern: self.adapters stays the default account's map, so the ~93 existing consumers are untouched when no named accounts exist. Consumers updated together in this PR: adapter resolution (_authorization_adapter/_adapter_for_source, fail-closed like profiles — a stamped account with no live adapter never falls back to the default bot), outbound delivery (DeliveryTarget.account, router resolves account targets through the registry fail-closed), fatal-error handling (named accounts take a mirrored path — without it the existing stale-owner guard silently ignores a dying account, since the default adapter occupies the platform slot), background reconnection (a named-account pass with the same capped backoff; success re-registers into the account registry, never the platform slot), per-account runtime status (telegram@support keys), and home-channel broadcasts (startup/shutdown notices reach each account's own home).
  2. "Cover cron/home-channel … routing per account" → cron deliver strings accept telegram@support:123456 (account splits off before platform validation and rides the resolved target into DeliveryTarget); the cron-side router syncs the gateway's account registry through the module-level runner weakref gateway/run.py already maintains; broadcasts iterate default + account adapters via one shared helper.
  3. "Propagate account identity into inbound session construction"BasePlatformAdapter.account_name, stamped by the gateway at account-adapter startup and copied by the Telegram adapter onto every inbound SessionSource.account. The account rides the session-key namespace slot (agent:main@support:telegram:…) — the same mechanism profiles use — so every positional key parser keeps its layout, and single-bot gateways produce byte-identical keys (locked by test). build_session_key reads the account from the source, not a caller parameter, so the adapter guard and the session store can never derive different keys for the same event (the Two turns can run concurrently on one gateway session: interleaved transcript flushes, permanent alternation wedge, repair_message_sequence fires on every request #64934 lesson, applied by construction).
  4. "Add an isolation test for the same chat across two bots"test_same_chat_two_bots_two_sessions — same chat + same user via three accounts → three distinct sessions — plus 45 more behavior tests across config, sessions, adapters, delivery, and cron targets.

Config surface (policy-clean): tokens are secrets → TELEGRAM_BOT_TOKEN_<ACCOUNT> in .env (the convention #10455 introduced, kept); behavioral settings → platforms.telegram.accounts.<name> in config.yaml (display name, per-account home_channel with the platform implicit, any platform-extra override); account names charset-restricted ([a-z0-9][a-z0-9_-]*) so :/@ can never reach a session key; hermes gateway setup gains an optional multi-bot stanza in the existing prompt style; cli-config.yaml.example documents the block. Accounts-only configs (tokens but no default credential) start named accounts directly instead of queueing a doomed token-less default connect.

Relationship to #10455 (@yimwoo): that PR pioneered the ask and its env-token convention, and its review produced the acceptance architecture this PR implements. The architectural rework the review required (synthetic telegram@account platform keys → an account dimension on the existing registries) meant no commits survive cherry-pick — credit here is for the convention and for proving the demand; happy to add Co-authored-by if maintainers prefer it for the convention carry-over.

Deliberate limits (flagged, not hidden): named accounts inside secondary multiplexed profiles fail closed (unsupported combination — resolving it needs a product call on precedence); bare telegram@support cron home-targets (account home channel without explicit chat) are deferred — cron home resolution is env-var-based today and the account's home lives in its derived config (follow-up if wanted); this PR is Telegram-scoped per the issue, but nothing in the registry, session-key, delivery, or lifecycle layers is Telegram-specific — a second platform adopts it by stamping account_name.

Related Issue

Closes #8287. Unblocks the session-identity prerequisite shared by #10143 (topic→profile routing) and #21587 (guest bots).

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • gateway/config.pyaccounts block parsing (two-route like gateway_restart_notification), TELEGRAM_BOT_TOKEN_<ACCOUNT> env scan, name-charset validation
  • gateway/session.pySessionSource.account (+serialization), account in the key namespace slot, split_key_namespace used by both namespace readers
  • gateway/run.py — account registry + derived per-account PlatformConfig, account-adapter startup (incl. accounts-only path) and shared wiring, account fatal-error path + reconnect pass, _parse_session_key account tolerance, home-broadcast iteration helper
  • gateway/authz_mixin.py — account dimension in adapter resolution, fail-closed
  • gateway/platforms/base.pyaccount_name adapter identity
  • plugins/platforms/telegram/adapter.py — inbound SessionSource.account stamping (both construction sites)
  • gateway/delivery.pyDeliveryTarget.account, @account parse/round-trip, fail-closed router resolution
  • cron/scheduler.py@account deliver-string parsing + registry sync for cron delivery
  • hermes_cli/setup.py — optional multi-bot setup stanza
  • cli-config.yaml.exampleaccounts documentation block
  • 5 test files, 46 tests: tests/gateway/test_telegram_multi_account_{config,sessions,adapters,delivery,targets}.py

How to Test

  1. scripts/run_tests.sh tests/gateway/test_telegram_multi_account_config.py tests/gateway/test_telegram_multi_account_sessions.py tests/gateway/test_telegram_multi_account_adapters.py tests/gateway/test_telegram_multi_account_delivery.py tests/gateway/test_telegram_multi_account_targets.py — 46/46.
  2. Regression: tests/cron/test_scheduler.py (222/222), delivery/DM-topics/turn-lease/conversation-scope suites (84/84) — all green post-rebase on current main.
  3. Live: set TELEGRAM_BOT_TOKEN_SUPPORT alongside TELEGRAM_BOT_TOKEN, start the gateway, message both bots in the same chat → two isolated sessions (keys agent:main:telegram:… and agent:main@support:telegram:…); reply threads out the receiving bot; cronjob(..., deliver="telegram@support:<chat>") delivers via the support bot.

Checklist

Code

  • I've read the Contributing Guide
  • Conventional Commits (5 focused commits, one concern each)
  • Searched existing PRs — feat(gateway): add multi-account telegram routing #10455 is the prior attempt; relationship and credit documented above, architecture per its maintainer review
  • Single-topic PR (multi-account Telegram end to end)
  • Test suite run — see How to Test; failing sets on native Windows byte-identical to pristine-main baselines at every slice
  • Tests added — 46 across five files, including the review's required isolation test
  • Tested on: Windows 11 (native)

Documentation & Housekeeping

  • Documentation — cli-config.yaml.example block added; setup wizard prompt added
  • cli-config.yaml.example updated — yes (accounts block)
  • CONTRIBUTING/AGENTS — N/A (no architecture-doc surface changed; happy to add an AGENTS.md note on the account dimension if wanted)
  • Cross-platform — pure-Python control flow; developed and verified on native Windows

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cron Cron scheduler and job management comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins platform/telegram Telegram bot adapter 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 sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades needs-decision Awaiting maintainer decision before any implementation labels Jul 19, 2026

@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 carrying forward the multi-account design from #10455. The current-main premise is real, but the inbound identity and lifecycle paths need correction before this can safely land.

Problems

  • Normal Telegram events call self.build_source() (plugins/platforms/telegram/adapter.py:8953), while the PR-head implementation returns a SessionSource without account (gateway/platforms/base.py:5746-5766). The two new Telegram stamps are only auth-helper sources (plugins/platforms/telegram/adapter.py:905,983), so ordinary named-bot traffic still gets the default session key and default egress adapter.
  • Named accounts start only after the default adapter connects (gateway/run.py:7726-7742). A bad default token blocks otherwise healthy named bots.
  • Initial named-account connection failures are disconnected and dropped (gateway/run.py:9985-9999), never entering the queue processed by the account reconnect watcher.
  • hermes send remains unable to target telegram@support: it delegates through tools/send_message_tool.py (hermes_cli/send_cmd.py:346-359), whose parser requires the platform segment itself to be a Platform (tools/send_message_tool.py:362-406).

Suggested changes

  • Stamp account identity in BasePlatformAdapter.build_source() and add a real normal-event isolation/egress test.
  • Start and retry named adapters independently of default-adapter success.
  • Add account-aware hermes send routing or narrow the documented target surface.

Automated hermes-sweeper review.

@@ -979,6 +980,7 @@ def _source_from_message_for_auth(self, message: Message):
user_id=user_id,
user_name=user_name,
thread_id=thread_id,
account=getattr(self, "account_name", None),

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.

This stamps only _source_from_message_for_auth. Normal Telegram traffic is built by _build_message_event() through inherited BasePlatformAdapter.build_source(), whose returned SessionSource still has no account; named-account messages therefore retain account=None, share the default session key, and route replies through the default adapter. Propagate self.account_name in build_source() and cover the normal event path.

Comment thread gateway/run.py Outdated
# Named bot accounts on this platform (#8287) start after
# the default account; each is independent and a failed
# account never blocks the others.
connected_count += await self._start_account_adapters(

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.

Named accounts are invoked only after the default adapter succeeds. If a configured default bot has a bad token or transient connect failure, this branch is skipped and every healthy named bot is unavailable. Start named accounts independently of default-account connect outcome.

Comment thread gateway/run.py Outdated
)
await self._safe_adapter_disconnect(adapter, platform)
continue
if not success:

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.

Initial named-account connection failures are disconnected and then discarded here. The new reconnect watcher only processes _failed_account_adapters, but this path never enqueues one, so a transient startup failure for a named bot is never retried. Queue retryable initial failures with the same account-scoped backoff state.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 19, 2026
@Hotragn

Hotragn commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

All three findings were correct — the first is the important one, and you're right that the feature didn't actually work for normal traffic. Pushed fixes:

1. Account stamped on the real inbound path. You're exactly right: I stamped the two auth-helper sources but normal Telegram traffic builds its SessionSource through the inherited BasePlatformAdapter.build_source(), which I never touched — so named-bot messages kept account=None and routed through the default key and egress. My multi-account tests passed while the feature was broken because they exercised the sites I changed, not the path real messages take. Moved the stamp to build_source() itself (account=getattr(self, "account_name", None)) — one central site every platform's normal path flows through, so this is also what makes the account dimension platform-agnostic. Added test_build_source_stamps_account_on_normal_event_path as the regression guard for exactly this miss.

2. Named accounts start independently of the default adapter. Moved _start_account_adapters out of the default-adapter if success: block to run unconditionally after the default connect attempt (success, failure, or exception). A bad or absent default token no longer keeps healthy named bots offline.

3. Initial account-connect failures now queue for reconnect. Retryable startup failures (connect returns False with a retryable adapter, or raises) enqueue into _failed_account_adapters via a shared _queue_account_reconnect helper — the same queue the account reconnect watcher drains, and the same helper the fatal-error path uses, so initial and runtime failures can't diverge. Non-retryable errors are left alone (a bad token shouldn't spin). Added test_failed_initial_connect_queues_for_reconnect.

4. hermes send target surface. Narrowed rather than expanded: send targets the platform's default account, and an @account target now returns an actionable error (send via the default account, or use a cron deliver='telegram@support:<chat>') instead of the opaque "Unknown platform: telegram@support". Account-aware send routing would need the tool process to reach the gateway's account registry — worth a focused follow-up, but out of scope here.

On the CI failures (a separate self-inflicted bug the review surfaced): test_queue_consumption and test_notice_rendering went red because a MagicMock/SimpleNamespace source auto-creates a truthy account attribute (the AGENTS.md pitfall #17 those fixtures already guard for profile), which tripped my fail-closed account resolution. Fixed at the read sites — account is now isinstance-coerced to str-or-None in _authorization_adapter and build_session_key, so any source built without an explicit account reads as the default. Verified the full tests/gateway/ suite locally this time (the targeted-file runs are what let the two regressions and the build_source miss through — my mistake).

Re-verifying end to end and will confirm green.

@Hotragn

Hotragn commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

CI is green now (run 29688151750, all required checks pass). Two commits since the review: the four review fixes, then a bare-runner-safety follow-up CI caught — the account reconnect watcher and fatal-error handler are exercised by partially-constructed test runners (no __init__), and a MagicMock adapter's truthy auto-account_name (pitfall #17) was routing a default adapter down the named-account path. Both now getattr/isinstance-guarded, so the account registries degrade to empty/default on a bare runner. Ready for another look when you have a moment.

@Hotragn
Hotragn force-pushed the feat/8287-telegram-multi-account branch from edb45a7 to 72e434d Compare July 28, 2026 22:15
@Hotragn

Hotragn commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (resolves the conflicts from the god-file decomposition + the new transport-resolution layer). All 7 commits reapplied; 48/48 multi-account tests green, and the reconciled-area regression suites pass in isolation (platform_reconnect 56, discord_liveness 19, runner_fatal_adapter 7, delivery 33, queue_consumption 14, notice_rendering 9).

Conflict reconciliations worth flagging for review:

  • authz _authorization_adapter: main added an active-profile fast path; kept it, with the account+secondary-profile fail-closed guard checked first (so an account under a named active profile also fails closed).
  • delivery _deliver_to_platform: named-account targets resolve through the account registry fail-closed; the default path uses main's new resolve_delivery_transport (relay/provenance-aware).
  • fatal handler: main refactored it into the detached-task _impl wrapper; moved the account-adapter branch to the top of _handle_adapter_fatal_error_impl.
  • build_source: account stamp coexists with main's new _transport_adapter_ref provenance.
  • One deferral: main rewrote the startup 'gateway online' home broadcast to be transport/relay-aware and config.platforms-driven, which collides with per-account iteration. I took main's version there (default-account homes) rather than force a risky reconciliation into the new transport layer; the shutdown notice still reaches each account's home (main didn't touch that path). Per-account startup broadcast is a clean follow-up once the transport layer's account story is settled — noted so it's not a silent gap.

@Hotragn

Hotragn commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 980aa66 — per-account send_message routing, which was the one blocker named against merging this.

The previous head rejected telegram@support:123 from the send tool, so a reviewed consumer stayed account-blind. Now:

  • resolve_platform_account() (new, in gateway/config.py) is the single place platform[@account] is parsed. The split happens before any platform lookup, since the downstream maps and the Platform enum know telegram, not telegram@support. @default and a bare @ both mean the default account, spelled the same as omitting the suffix.
  • The named account's derived PlatformConfig then replaces the platform's for the rest of the send path — its own token, its own home channel, its own platform-extra overrides. telegram@support with no chat id reaches the support bot's home channel, not the default bot's.
  • The derivation is no longer duplicated: GatewayRunner._account_platform_config now delegates to the same derive_account_platform_config(), so account-adapter startup and send resolve an account's token/home/extra identically. A test asserts the two callers agree rather than trusting that by inspection.
  • Fails closed on a bad account. An unknown account, or one configured with no token, is an error naming the configured accounts and the exact env var to set — it never falls back to the default bot's credential, which would deliver to the wrong audience silently.

send_message builds its own PlatformConfig from gateway config rather than reaching a live adapter registry, so this needed no new plumbing — just the account's config — which is why the diff is small.

Tests: 13 new (parsing forms, the six derivation cases, runner/shared equivalence, all three fail-closed paths). Regression run: 61/61 across the five multi-account suites, and 50/50 across the nine existing send_message test files (one pre-existing skip in test_send_message_tool.py, unchanged).

Still deliberately out of scope, same as before: per-account startup home broadcast, since main has since rewritten that path to be transport/relay-aware and folding it in here would put an unrelated rewrite in this diff. Happy to do it as a follow-up once this lands.

@Hotragn

Hotragn commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

CI note on 980aa66, so the red X isn't misread: the one failing slice is pre-existing on this PR's own base, not from this diff.

  • Failing slice: tests/tools/test_vercel_sandbox_environment.py, 16 tests, all the same cause — ImportError: Feature 'terminal.vercel' unavailable: lazy installs disabled (security.allow_lazy_installs=false).
  • The identical 16 failures are present on this PR's base commit 2d40494 (slice 8/8 there vs. base job 90767099059), in a run that contains none of my changes. Slice numbering differs only because slices are generated per-run.
  • This diff touches tools/send_message_tool.py, gateway/config.py, gateway/run.py and one new test file. Nothing vercel-, dependency-, or lazy-install-related.

Everything else on 980aa66 is green: the other 7 test slices, e2e, ruff enforcement (blocking), ruff + ty diff, Windows footguns, supply-chain, attribution, common-ancestor.

Happy to send the optional-dependency guard for that test file as a separate one-file PR if it's not already being handled — I didn't find an existing report for it, but it's unrelated to this change and doesn't belong in this diff.

@Hotragn
Hotragn force-pushed the feat/8287-telegram-multi-account branch from 980aa66 to 78cee12 Compare July 30, 2026 04:47
@Hotragn

Hotragn commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Withdrawing the offer I made in my last comment — no separate PR is needed, that failure was already fixed on main and I should have checked the tip before offering.

8eb06e75b ("fix(tests): stub _ensure_vercel_sdk in vercel sandbox tests — CI has no vercel dist", @teknium1) landed at 04:18Z. My CI run started at 04:26Z, but its merge base 2d40494 was cut at 02:53Z — before the fix — so the run tested a base that still had the bug. Nothing to send; the diagnosis was right and the fix already existed.

Rebased onto 8eb06e75b and pushed 78cee12e (was 980aa66) so CI picks it up. The rebase was clean — 8 commits, zero conflicts.

Re-verified after the rebase rather than assuming 591 commits of drift were harmless: 61/61 across the five multi-account suites, 13/13 on the new per-account send tests, and the existing send_message suites green against their current upstream versions. I also ran tests/tools/test_vercel_sandbox_environment.py on this box, which has no vercel dist installed (PackageNotFoundError) — the same 16 tests now pass, which is the direct check that the previously red slice will clear.

So this PR should now go fully green. The per-account send_message routing from my earlier comment is unchanged by the rebase.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Two open PRs address #8287: #10455 adds Telegram account configuration, adapter startup, channel lookup, reconnect bookkeeping, and account-qualified sends, but does not add bot identity to session keys or consistently update delivery consumers; #67455 extends the account dimension through configuration, adapter registries, inbound identity, isolated sessions, delivery, cron, lifecycle recovery, home channels, and send-message routing.

Related pull requests

Duplicates

#10455 and #67455 substantially overlap on Telegram multi-account configuration, adapter startup, routing, reconnect behavior, and outbound sends; #67455 supersedes #10455 by also implementing account-aware session identity and the broader delivery/lifecycle architecture required by the contributor review.

Suggested consolidation

Keep #67455 open with a salvage path focused on renewed contributor review of the corrected inbound identity, independent startup, reconnect, delivery, cron, and send-message paths; it is the recorded best fix and preserves the contributor review's keep_open disposition. Close #10455 as a duplicate of #67455 despite its keep_open review: the complete diffs show that #67455 carries forward its useful configuration and routing work while adding the session isolation and consumer-wide account registry that the #10455 review required.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I8287(["issue #8287 (open)"])
    P67455["PR #67455 (open)"]
    P67455 -->|best fix| I8287
    class I8287 open
    class P67455 open
    class P67455 best
    class P67455 target
    click I8287 "https://github.com/NousResearch/hermes-agent/issues/8287"
    click P67455 "https://github.com/NousResearch/hermes-agent/pull/67455"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 2 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 145 kB of PR diffs, 15 kB of issue/PR text, 18 kB of discussion (18 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@Hotragn
Hotragn force-pushed the feat/8287-telegram-multi-account branch from 78cee12 to 2fc8a1b Compare August 3, 2026 20:08
@Hotragn
Hotragn force-pushed the feat/8287-telegram-multi-account branch from 2fc8a1b to 6654e3b Compare August 11, 2026 02:10
@Hotragn

Hotragn commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (0a60b164, 1109 commits) — this had gone dirty again. One real conflict, in _handle_adapter_fatal_error: main reordered it so retryable failures are queued before any disconnect await (#80598), because a half-dead transport could wedge close() and leave platforms permanently deaf. I kept main's ordering and moved the account-registry sync up next to delivery_router.adapters, where the default registry is already reconciled — so both fixes hold rather than one silently undoing the other.

Re-verified the premises rather than assuming they survived: the inbound stamp is still the single central site (gateway/platforms/base.py, build_source()), and derive_account_platform_config is still the one derivation shared by adapter startup and send_message. 61/61 across the five multi-account suites plus the per-account send tests; ruff clean.

First slice of the account-aware gateway: configuration surface only,
no runtime behavior change.

- TELEGRAM_BOT_TOKEN_<ACCOUNT> env vars declare additional bot accounts
  (lowercased names); the unsuffixed TELEGRAM_BOT_TOKEN remains the
  default account, so single-bot setups parse byte-identically. Tokens
  are secrets: env/.env is their supported home.
- platforms.telegram.accounts.<name> in config.yaml carries the
  behavioral per-account settings (display names, allowlists, home
  channels) and merges with env tokens on the account name; the block
  arrives top-level or bridged into extra (the same two-route pattern
  as gateway_restart_notification) and round-trips through to_dict.

Registry, session-key, and routing slices follow in this branch per
the acceptance architecture in the NousResearch#10455 review.
Second slice: the same chat reached through two bot accounts is two
sessions.

- SessionSource.account carries which bot received the message (stamped
  by the adapter in the upcoming registry slice); wire-invisible when
  unset, serialized like profile.
- The account rides in the session-key NAMESPACE slot — the same
  mechanism profiles use: agent:main@support / agent:coder@support.
  Positional parsers keep their layout (parts[2] == platform), and
  single-bot gateways produce byte-identical keys (locked by test).
- build_session_key reads the account from the SOURCE, not a caller
  parameter, so the adapter-level guard and the session store derive
  the same key for the same event — per-key guards diverging is the
  NousResearch#64934 bug class, and this keeps that door shut by construction.
- The two namespace readers are account-aware via a shared helper:
  _profile_from_session_key strips the suffix instead of resolving
  'main@support' as a profile name, and _parse_session_key accepts the
  suffixed default namespace (named-profile keys stay excluded).
- Account names are charset-restricted at config parse ([a-z0-9][a-z0-9_-]*)
  so ':' and '@' can never reach a key.

Includes the NousResearch#10455-review isolation test: same chat + user via two
accounts yields distinct keys.
…ping (NousResearch#8287)

Third slice: one adapter instance per named bot account.

- _account_adapters[platform][name] mirrors the proven _profile_adapters
  two-level registry: self.adapters stays the default account's map, so
  every existing consumer is untouched when no named accounts exist.
- _authorization_adapter gains the account dimension with the same
  fail-closed contract as profiles: a stamped account with no registry
  entry resolves to None rather than the default bot — replying out the
  wrong bot is worse than not replying. _adapter_for_source reads
  source.account, so inbound routing follows the stamp automatically.
- Each account adapter sees an ordinary derived PlatformConfig (its own
  token, its own home_channel with the platform implicit, account-block
  settings overriding platform extra, the accounts map stripped) —
  adapter internals stay account-agnostic.
- BasePlatformAdapter.account_name identifies the serving account; the
  Telegram adapter copies it onto every inbound SessionSource, which is
  where the per-account session keys from the previous slice light up.
- Startup: named accounts start after the default adapter, each
  independent (a failed account never blocks the others). Accounts-only
  configurations (tokens with no default credential) skip the doomed
  token-less default connect and start named accounts directly.

Per-account reconnect queueing, delivery/status/cron consumers, and
setup UX land in the remaining slices.
…ousResearch#8287)

Fourth slice: the outbound half and lifecycle recovery.

- DeliveryTarget grows the account dimension: 'telegram@support:123'
  addresses a chat through the support bot, 'telegram@support' its home
  channel, and origin targets inherit the account the message arrived
  on — replies always leave through the bot that received them.
- DeliveryRouter resolves account targets through the account registry
  fail-closed: account-addressed content never leaves through the
  default bot. Registry sync rides the existing three adapter-sync
  sites (shared references, so registrations propagate live).
- Fatal errors: the stale-owner guard in _handle_adapter_fatal_error
  sees the DEFAULT adapter occupying the platform slot and would
  silently ignore a dying named account — no reconnect, no status, no
  log. Named accounts now take their own path: same contract, scoped
  to the account registry (stale-guard against their own slot, pop +
  disconnect, retryable failures queued under (platform, account)),
  and an account's death never touches the default slot or the
  all-platforms-down shutdown logic.
- Reconnect watcher gains a named-account pass mirroring the platform
  pass (same capped exponential backoff); success re-registers into
  _account_adapters, never the platform slot. Adapter wiring is shared
  between account startup and account reconnect via _wire_account_adapter
  so the two can never drift.

Remaining slice: cron/home-channel account targets in the runner's
target-resolution paths, setup UX, and example-config docs.
…up UX (NousResearch#8287)

Final slice: the remaining consumers from the NousResearch#10455 review checklist,
plus the user-facing surface.

- Cron 'deliver' strings accept named-account targets: telegram@support:123
  routes through the support bot. The account splits off before platform
  validation (downstream maps know 'telegram', not 'telegram@support'),
  rides the resolved-target dict, and reaches DeliveryTarget(account=...).
  The delivery router syncs the gateway's account registry via the
  module-level runner weakref gateway/run.py already keeps for module
  consumers; with no gateway running (CLI cron), account targets fail
  closed with the router's 'No adapter configured' error.
- Home-channel broadcasts (startup notice + shutdown notification) reach
  every account's own home channel: a shared _iter_live_adapters_with_home
  yields (platform, adapter, home) for default adapters (platform-level
  home) and account adapters (their derived config's home), preserving
  the snapshot-before-iterate shutdown lesson.
- hermes gateway setup gains an optional multi-bot stanza after the home
  channel step: name-validated accounts, token prompts with the existing
  regex/retry loop, saved as TELEGRAM_BOT_TOKEN_<NAME> in .env.
- cli-config.yaml.example documents platforms.telegram.accounts with the
  name charset, the env token convention, and the delivery syntax.
…count lifecycle (NousResearch#8287)

Addresses the NousResearch#67455 review — three correct findings, plus the CI
failures the review surfaced.

- Account is now stamped in BasePlatformAdapter.build_source(), the
  single construction site every platform's NORMAL inbound event flows
  through. The prior stamps were only on Telegram auth-helper sources,
  so ordinary named-bot traffic kept account=None and routed through the
  default session key and default egress adapter — the feature silently
  no-op'd for real messages. Regression test added for this exact path.
- Named-account adapters start independently of the default adapter's
  connect outcome (moved out of the 'if success' block): a bad or absent
  default token no longer keeps healthy named bots offline.
- Retryable initial account-connect failures now queue for background
  reconnection via a shared _queue_account_reconnect helper (the same
  queue the fatal-error path and reconnect watcher use), so a transient
  startup blip is retried instead of dropped until the next restart.
- hermes send rejects @account targets with an actionable error rather
  than an opaque 'Unknown platform'; per-account send routing is a
  scoped follow-up.

CI-failure fix: a MagicMock/SimpleNamespace source auto-creates a truthy
'account' attribute (AGENTS.md pitfall NousResearch#17, already guarded for
'profile'), which tripped fail-closed account resolution. The account
read is now isinstance-coerced to str-or-None in _authorization_adapter
and build_session_key, so any source built without an explicit account
reads as the default.
…8287)

CI caught real regressions the account lifecycle introduced: the
reconnect watcher and fatal-error handler are exercised by tests that
build a partially-constructed GatewayRunner (no __init__), so they lack
_account_adapters / _failed_account_adapters — and a MagicMock adapter
auto-creates a truthy account_name (AGENTS.md pitfall NousResearch#17), routing a
default adapter's fatal error down the named-account path.

- Reconnect watcher iterates _failed_account_adapters via getattr, so a
  bare runner sees an empty account queue instead of AttributeError.
- _handle_adapter_fatal_error isinstance-guards account_name: only a real
  str routes to the account fatal path; a Mock reads as the default
  adapter.
- All three delivery_router.account_adapters sync lines and the
  account-fatal registry read use getattr defaults.

Verified: the CI-failing suites (test_platform_reconnect,
test_platform_reconnect_fd_leak, test_discord_liveness) plus the runner
lifecycle files all green in isolation.
Closes the last account-blind consumer from teknium1's review: send_message
rejected 'telegram@support:123', so the one path a reviewer flagged as
incomplete stayed incomplete.

send_message already builds its own PlatformConfig from gateway config
rather than reaching a live adapter registry, so per-account send needs no
new plumbing — just the account's config. The target's platform segment now
splits into (platform, account) BEFORE any platform lookup (downstream maps
and the Platform enum know 'telegram', not 'telegram@support'), and the
named account's derived PlatformConfig replaces the platform's for the rest
of the path: its own token, its own home channel, its own platform-extra
overrides. 'telegram@support' with no chat reaches the SUPPORT bot's home
channel, not the default bot's.

The derivation is no longer duplicated: GatewayRunner._account_platform_config
now delegates to gateway.config.derive_account_platform_config, shared with
this send path, so account-adapter startup and send resolve an account's
token/home/extra identically. resolve_platform_account() is the one place
'platform[@account]' is parsed, with '@default' and '@' both meaning the
default account.

Fails CLOSED on a bad account — unknown account, or one with no token —
naming the configured accounts and the exact env var to set, rather than
silently falling back to the default bot's credential and delivering to the
wrong audience. Tool schema documents the '@account' target form.

13 tests: parsing (plain / named / '@default' / empty / empty-input),
derivation (token override, accounts-map stripped, implicit home-channel
platform, extra override, empty block, base config untouched), the
runner-vs-shared equivalence, and all three fail-closed cases. Regression:
61/61 across the five multi-account suites and 50/50 across the nine
existing send_message test files.
@Hotragn
Hotragn force-pushed the feat/8287-telegram-multi-account branch from 6654e3b to 10df63a Compare August 14, 2026 21:30
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cd9c3de1-d5f4-479e-951b-c64bd8bcea93

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@Hotragn

Hotragn commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Splitting this into reviewable pieces rather than leaving it at 1,750 lines / 17 files.

It's been open 26 days. Looking at the last 40 merged PRs in this repo, the median is 136 lines and every one of them merged inside 24h — so a PR this size was never going to get a fair read, regardless of whether the code is right. Triage recorded it as the best fix for #8287, so the destination isn't in question; the review surface is.

First slice is up: #86497 — config parsing only, 46 lines of gateway/config.py + 122 of tests. Five more follow (session identity → adapter registry + inbound stamping → delivery routing + reconnect → per-account send_message → cron targets/home broadcasts/setup UX), each its own PR.

Keeping this open until the sequence supersedes it, so nothing is lost and the review history here stays linked. Happy to re-cut the boundaries if a different split reviews better — or to abandon the split and keep it here if you'd prefer the whole thing in one place.

The three findings from your earlier review are all folded into the relevant slices, not dropped: build_source() stamping on the real inbound path goes into 3/6, independent named-adapter startup and reconnect queueing into 4/6, and account-aware hermes send into 5/6.

Hotragn added a commit to Hotragn/hermes-agent that referenced this pull request Aug 16, 2026
…sResearch#8287)

Third slice: one adapter instance per named bot account, and the inbound
stamp that makes the previous slice's per-account session keys light up.

- _account_adapters[platform][name] mirrors the proven _profile_adapters
  two-level registry: self.adapters stays the default account's map, so
  every existing self.adapters[...] site is untouched when no named
  accounts are configured (the dict is empty).
- _authorization_adapter gains the account dimension with the same
  fail-closed contract as profiles: a stamped account with no registry
  entry resolves to None rather than the default bot — replying out the
  wrong bot is worse than not replying. _adapter_for_source reads
  source.account, so inbound routing follows the stamp automatically.
  A named account under a secondary profile is not a supported
  combination yet and also fails closed, checked before the
  active-profile fast path so it holds for a named ACTIVE profile too.
- Each account adapter sees an ordinary derived PlatformConfig (its own
  token, its own home_channel with the platform implicit, account-block
  settings overriding platform extra) — adapter internals stay
  account-agnostic. The accounts map is stripped from the derived extra:
  an account must never be able to spawn accounts.
- The account is stamped in BasePlatformAdapter.build_source(), the
  single construction site every platform's NORMAL inbound event flows
  through. Stamping only the Telegram auth helpers would leave ordinary
  named-bot traffic at account=None — default session key, default
  egress — i.e. the feature would silently no-op for real messages.
  Both Telegram auth-path helpers are stamped too, so the adapter-level
  guard and the session store cannot derive different keys for one event
  (the NousResearch#64934 bug class).

Startup rides the parallel connect fan-out added by NousResearch#83791 rather than
bolting a serial tail onto it. _prepare_account_adapters() creates and
wires the account adapters, and their (platform, config, adapter) triples
join the same _pending_connects list the default adapter uses, so named
bots connect concurrently with every other platform — connecting them in
a loop would reintroduce exactly the head-of-line blocking that PR
removed, at N accounts x one timeout each. Consequences, all covered by
tests that drive the real start():

- Named accounts no longer depend on the default adapter's outcome, so a
  bad or absent default token cannot keep healthy named bots offline
  (NousResearch#67455 review finding).
- Registration happens in the single-threaded aggregation loop keyed off
  adapter.account_name, so shared state is mutated exactly as before.
- A named account owns no entry in self.adapters and never claims the
  platform's single _failed_platforms retry slot — that slot would
  respawn the DEFAULT adapter from the account's config.
- The platform runtime-status row reports the DEFAULT account's health,
  so an account connect neither flips it to "connecting" nor marks the
  platform down while the default bot is serving fine.
- An accounts-only platform (named tokens, no default credential) skips
  the doomed token-less default connect entirely.

Per-account reconnect queueing, delivery/status/cron consumers, and
setup UX land in the remaining slices; a named account that fails to
connect at startup is disconnected cleanly and retried at the next
gateway start until then.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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 type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple Telegram bots connecting to the same agent (same gateway, different sessions) using the same Telegram account.

4 participants