feat(gateway): account-aware adapter registry + inbound stamping (#8287, 3/6) - #87556
Open
Hotragn wants to merge 4 commits into
Open
feat(gateway): account-aware adapter registry + inbound stamping (#8287, 3/6)#87556Hotragn wants to merge 4 commits into
Hotragn wants to merge 4 commits into
Conversation
Hotragn
marked this pull request as draft
August 16, 2026 08:43
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.
…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.
…ousResearch#8287) build_session_key read source.account with a bare getattr. A MagicMock or SimpleNamespace source auto-creates a truthy NON-STRING attribute for any name (AGENTS.md pitfall NousResearch#17, already guarded for `profile`), so such a fixture derived agent:main@<MagicMock name='mock.account' id='...'>:telegram:dm:777 instead of the default namespace — a corrupted key, and a different key on every run because the repr embeds the object id. This was latent while nothing stamped the field: before the registry slice, source.account was only ever set by an explicit test. The stamp added in build_source() is what puts a real value on the attribute for the first time, so harden the read in the same series rather than leave a trap for the next fixture that flows through. isinstance-coerce to str-or-None, matching the guard the same commit applies in _authorization_adapter, so both readers of the account dimension agree on what "no account" means.
Hotragn
force-pushed
the
feat/8287-03-adapter-registry
branch
from
August 16, 2026 08:59
4128aff to
2f99ade
Compare
Hotragn
marked this pull request as ready for review
August 16, 2026 09:00
Contributor
feat(gateway): account-aware adapter registry + inbound stamping (#8287, 3/6)
Solid, well-tested slice overall (fail-closed contract, concurrent fan-out, byte-identical single-bot keys all covered) — items (1)-(3) are the ones to resolve across the 3/6 series. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third slice of #67455, split for review. 1/6 is #86497 (config parsing), 2/6 is #86532 (session identity). This slice brings named bot accounts online and stamps their inbound traffic — which is what turns 2/6's per-account session keys from a data structure into observable behaviour.
Stacked on 2/6; the reviewable commits are the last two.
What this does
Registry.
_account_adapters[platform][name]mirrors the proven_profile_adapterstwo-level registry.self.adaptersstays the default account's map, so every existingself.adapters[...]site is untouched when no named accounts are configured — the dict is simply empty.Fail-closed resolution.
_authorization_adaptergains the account dimension with the same contract profiles already have: a stamped account with no registry entry resolves toNone, never the default bot. Replying out the wrong bot is worse than not replying. A named account inside a secondary profile is not a supported combination yet and also fails closed — checked before the active-profile fast path, so it holds when that profile is the active one too.Derived per-account config. Each account adapter is handed an ordinary
PlatformConfig— its own token, its ownhome_channel(the platform is implicit inside an account's own block), account-block settings overriding platformextra. Adapter internals stay completely account-agnostic. Theaccountsmap is stripped from the derivedextra: an account must never be able to spawn accounts.The stamp lands on the real inbound path. This is the part worth reviewing closely. The account is stamped in
BasePlatformAdapter.build_source()— the single construction site every platform's normal event flows through. Stamping only the Telegram auth helpers would leave ordinary named-bot traffic ataccount=None, routing it to the default session key and default egress: the feature would silently no-op for real messages while multi-account tests passed. Both Telegram auth-path helpers are stamped as well, so the adapter-level guard and the session store cannot derive different keys for the same event (the #64934 bug class).Startup rides the parallel fan-out from #83791
Named accounts join the same
_pending_connectsfan-out the default adapter uses, rather than being started in a loop after it._prepare_account_adapters()only creates and wires; the(platform, config, adapter)triples then connect concurrently with every other platform. A serial tail here would have reintroduced exactly the head-of-line blocking #83791 removed, at N accounts × one connect timeout each.Consequences that fall out of sharing that path, each covered by a test that drives the real
start():adapter.account_name, so shared state is mutated exactly as before.self.adaptersand never claims the platform's single_failed_platformsretry slot — that slot would respawn the default adapter from the account's config.connectingnor marks the platform down while the default bot is serving fine.Second commit: a latent bug this slice would have activated
build_session_keyreadsource.accountwith a baregetattr. AMagicMock/SimpleNamespacesource auto-creates a truthy non-string attribute for any name (AGENTS.md pitfall #17, already guarded forprofile), so such a fixture derived— a corrupted key, and a different key on every run because the repr embeds the object id.
This was latent while nothing stamped the field: before this slice,
source.accountwas only ever set by an explicit test. Thebuild_source()stamp is what puts a real value on that attribute for the first time, so it is hardened in the same series rather than left as a trap. Coerced to str-or-None, matching the guard applied in_authorization_adapter, so both readers of the account dimension agree on what "no account" means.Review findings from #67455 folded in
Two of the four findings on the umbrella PR belong to this slice and are addressed here:
self.build_source()… ordinary named-bot traffic still gets the default session key and default egress adapter." → stamped inbuild_source(), withtest_build_source_stamps_account_on_normal_event_pathas the regression guard for exactly that miss.test_failing_default_does_not_keep_named_accounts_offline.The remaining two (reconnect queueing, account-aware
hermes send) belong to slices 4/6 and 5/6.Deliberately out of scope
Per-account reconnect queueing lands with the delivery/reconnect slice (4/6). Until then a named account that fails to connect at startup is logged, disconnected cleanly (never leaked), and retried at the next gateway start — without affecting the default account or its siblings.
Tests
18 tests in
tests/gateway/test_telegram_multi_account_adapters.py.Unit: resolution (default / named / unknown-fails-closed / account-under-secondary-profile / non-string fixture reads as default), the derived config (overrides, home-channel platform defaulting,
accountsstripped, base config not mutated), and prepare-only wiring.Stamping goes through a real
TelegramAdapteron both thebuild_sourcepath and the auth-helper path, including the end-to-end tie-back that the stamp is what makes one chat two session keys under two bots.Startup is exercised through the real
GatewayRunner.start(), reusing the harness #83791 added — including its event-order (not wall-clock) technique for proving overlap, sincetime.monotonic()on Windows is too coarse to distinguish serial from parallel:test_named_accounts_connect_concurrently_with_the_defaulttest_failing_default_does_not_keep_named_accounts_offlinetest_failed_account_never_claims_the_platform_retry_slottest_accounts_only_platform_skips_the_tokenless_default_connecttests/gateway/test_startup_connect_parallel.py(#83791's own regression tests) stays green.