Bare /reasoning and /fast now render a native one-tap picker on
picker-capable platforms, with automatic fallback to the text status
card everywhere else — parity with the /model picker UX.
- gateway/slash_commands.py: generic send_choice_picker capability gate
(detected on the adapter type, like send_model_picker); selection and
typed arguments flow through one shared application path so they can
never diverge; choices built from VALID_REASONING_EFFORTS so future
levels appear automatically
- telegram: flat inline-keyboard picker (cp:<idx> callbacks), authorized
users only (same gate as approval buttons)
- discord: ChoicePickerView select menu, auth mirrors ExecApprovalView,
2-minute timeout with expiry edit
- matrix: reaction-based picker; reaction set extended to 12 slots to
fit the full effort ladder + subcommands
- locales: picker_title + choice labels in all 16 languages
- docs: ADDING_A_PLATFORM.md capability table
Closes#61110.
When no agent is active, '/queue <prompt>' previously fell through
dispatch with its raw text intact instead of being treated as a
normal prompt. Rewrite event.text to the bare payload (mirroring the
/steer no-active-agent path just below) and return a usage hint when
the payload is empty.
Salvaged from PR #29290 (queue half only — the /footer mid-run
dispatch half already landed on main via #65521).
Replaces the salvaged tests' dict-config path (which required a
GatewayRunner.__init__ dict-coercion hack — dropped from this salvage;
the second commit on the PR branch existed only to support it) with the
established bare-runner test pattern. Also asserts full argument
passthrough to the inner task.
When multiplex_profiles is true, background tasks spawned by /background
command failed with UnscopedSecretError because _resolve_session_agent_runtime()
was called without a profile secret scope. This fix wraps the task in
_profile_runtime_scope, mirroring the pattern used by _run_agent.
Fixes#60726
Same bug class as the salvaged #65305/#65307: hmac.compare_digest (and
secrets.compare_digest) raise TypeError when given a str containing
non-ASCII characters, and these call sites feed it raw request input.
Compare as UTF-8 bytes everywhere:
- gateway/platforms/msgraph_webhook.py: clientState from request body
- gateway/platforms/whatsapp_cloud.py: hub.verify_token query param +
X-Hub-Signature-256 header (comment claimed 'works on str' — it
doesn't for non-ASCII)
- plugins/platforms/feishu: verification token + x-lark-signature
- plugins/platforms/raft: bridge token header
- plugins/platforms/line: X-Line-Signature
- plugins/platforms/sms: X-Twilio-Signature
- tools/code_execution_tool.py: sandbox RPC token (both loops)
Regression tests for the two gateway-core sites (msgraph, whatsapp).
The fix routes the Svix v1 comparison through _hmac_str_equal too, but the
existing non-ASCII tests only exercised the GitHub/GitLab/generic V1/V2
branches. Add a Svix case (valid svix-id + fresh svix-timestamp so it
reaches the v1,<sig> compare) with a non-ASCII signature, which raised
TypeError before the fix and now rejects cleanly.
_validate_signature backs the public webhook receiver. It compared each
attacker-supplied signature/token header (GitHub X-Hub-Signature-256,
GitLab X-Gitlab-Token, generic X-Webhook-Signature / -V2, and the Svix v1
header) against a computed hex/base64 digest with hmac.compare_digest on
two str values. compare_digest raises TypeError on a str containing
non-ASCII characters, and the header is raw client input on an
unauthenticated endpoint — so any internet client could POST a single
non-ASCII byte in the signature header and raise out of the handler,
returning a 500 instead of a clean 401. Fail-closed, but an on-demand
crash of the request path.
Route all five comparisons through a small _hmac_str_equal() helper that
encodes both sides to UTF-8 bytes before the constant-time compare
(compare_digest has no ASCII restriction on bytes). Semantics are
unchanged for valid signatures; a hostile non-ASCII header now fails
closed with a rejection instead of raising.
Adds regression tests: non-ASCII GitHub/GitLab/generic/V2 signature
headers return False (no raise), and a non-ASCII configured secret still
matches its exact token value.
Also maps drexux0@gmail.com in scripts/release.py AUTHOR_MAP.
_check_auth gates every OpenAI-compatible API server endpoint. It compared
the client's raw bearer token against the configured key with
hmac.compare_digest on two str values. compare_digest raises TypeError on
a str containing non-ASCII characters, and the token comes straight from
the Authorization header — so a request with a single non-ASCII byte in
the key (a stray unicode char, a smart quote, a pasted BOM) crashed the
handler with an unhandled TypeError. Every endpoint calls _check_auth
without a try/except, so the framework turned that into a 500 Internal
Server Error instead of the intended 401 Invalid API key.
Compare as bytes, matching web_server.py's dashboard-token check
(hmac.compare_digest(auth.encode(), expected.encode())). Encoding both
sides keeps the timing-safe comparison and its semantics identical for
valid keys while making a non-ASCII token fail closed with a clean 401.
Adds regression tests: a non-ASCII bearer token returns 401 (no raise),
and a non-ASCII configured key still authenticates against its exact
value.
Follow-up to the truncate_message split-loop floor. Two review points:
- CapabilityDescriptor.from_json trusted the wire max_message_length
verbatim, so a connector advertising 0 ('no limit') — or a buggy one
sending 0/negative — produced a descriptor whose bound flowed straight
into the adapter's MAX_MESSAGE_LENGTH and truncate_message. Normalize
it to the documented 4096 default (mirrors from_platform_entry's
'or 4096' and docs/relay-connector-contract.md), fixing the degenerate
budget at its source rather than only surviving it downstream.
- Document the truncate_message length contract for a budget too small
for one codepoint (max_length=1 with a 2-unit surrogate pair under
utf16_len): the chunk intentionally exceeds max_length by that one
indivisible codepoint, because emitting it whole preserves content
where the alternatives are data loss or an infinite loop.
Tests: from_json normalizes 0 and negative bounds to 4096 and passes a
real positive bound through unchanged; the sub-codepoint budget emits
whole codepoints with no data loss (all emojis preserved) and a chunk
that necessarily exceeds the 1-unit budget.
BasePlatformAdapter.truncate_message() splits an over-length reply into
chunks. When max_length is 0 or 1 (and the content is longer), the split
loop makes no progress and spins forever, appending empty chunks — an
unbounded hang that pins a CPU and grows the chunk list until OOM:
- headroom = max_length - INDICATOR_RESERVE - ... goes negative, and the
< 1 fallback (max_length // 2) is also 0;
- so _cp_limit is 0, the region is empty, no split point is found, and
split_at falls back to _cp_limit (0);
- chunk_body is remaining[:0] = "", remaining never shrinks, loop repeats.
The same stall is reachable under utf16_len (Telegram) whenever the next
char is a surrogate-pair emoji wider than the whole budget, so _cp_limit
maps to 0 codepoints even for max_length >= 2.
A pathological max_length is not hypothetical: the relay capability
descriptor's max_message_length is taken verbatim from the connector
(gateway/relay/descriptor.py from_json) and assigned straight to the
adapter's MAX_MESSAGE_LENGTH (gateway/relay/adapter.py), and 0 is a
documented "no limit" value there.
Guarantee forward progress: floor headroom at 1, and floor the
final split_at at max(1, _cp_limit) so at least one codepoint is always
consumed per iteration. Normal splitting is unaffected (both floors only
bite when the budget is already degenerate).
Adds regression tests that run truncate_message on a worker thread and
fail if it doesn't return: max_length 0/1/2 terminate and preserve every
character, and the utf16 emoji case terminates too.
Rebuilt from PR #61283 onto the /p/<profile>/ routing world (7aa21e336):
_profile_scope(None) now enters the DEFAULT profile's runtime scope when
multiplexing is active instead of returning nullcontext(). api_server is
a port-binding platform living on the default profile, so plain requests
(no /p/ prefix) are the primary path — with fail-closed get_secret they
crashed with UnscopedSecretError on the first credential read (#61276).
All three wrapped call sites (chat-completions executor, /v1/runs agent
construction and _run_sync) inherit the fix through the one seam.
Single-profile gateways keep the no-op. Regression tests ported from
the original PR to the _profile_scope seam.
Fixes#61276
Subset of PR #61985: _make_adapter_auth_check gains a profile_name
parameter and secondary-profile adapters (started in
_start_one_profile_adapters) bind it, so the auth callback's
SessionSource resolves the routed profile's adapter and pairing store
instead of silently falling back to the default profile. This is the
gap left open by the #65629 merge — adapter-internal auth checks (e.g.
Slack thread-context fetch) fire outside the wrapped message handler.
The PR's authz_mixin.py hunks are dropped: main's _auth_env (merged via
PR #65629) already covers the scoped allowlist reads they targeted.
With the mode-conditional check centralized, default (websocket) Feishu
no longer counts as port-binding in the secondary batch report — pin the
fixture to connection_mode=webhook so the test still exercises the
multi-platform report path.
Feishu was unconditionally listed in _PORT_BINDING_PLATFORM_VALUES,
causing the multiplexer to reject ALL Feishu secondary profiles. But
Feishu in websocket mode (the default) uses an outbound WebSocket
connection and does NOT bind an HTTP port — only webhook/callback mode
needs a listener.
Add _platform_binds_port() helper that checks connection-dependent
platforms (currently only Feishu) against their actual config before
raising MultiplexConfigError. Feishu websocket profiles are now allowed;
Feishu webhook profiles still raise as before.
Fixes#52563
Review follow-up: honor source.profile and enter _profile_runtime_scope
only when gateway.multiplex_profiles is on, mirroring the gating in
_run_agent, _reset_notice_session_info, and _resolve_profile_for_key.
When multiplexing is off (the default) a stamped source is ignored and
/profile reports the active profile and default home, byte-identical to
before this PR.
The stamped-source test now enables multiplexing (it previously
exercised the ungated path under the default config), and a new
regression asserts the stamp is ignored when multiplexing is off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a multiplexed gateway the process-level active profile is always the
multiplexer's own (usually "default"), so /profile answered "default" in
every chat regardless of which profile actually served it — making
per-chat persona routing look broken when it was working.
Report source.profile (stamped by the /p/<profile>/ URL prefix, a
per-credential adapter, or a room->profile map) and resolve the
displayed home under that profile's runtime scope, mirroring the scoped
/reset banner (#59003). Unstamped sources fall back to the active
profile and default home, so single-profile gateways are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare False from connect() on EADDRINUSE made the gateway reconnect
watcher treat a port conflict as transient and retry forever at the
backoff cap — 1568+ retries over 5 days in a multi-profile production
setup, filling errors.log and leaking 2 ResponseStore fds per retry.
Set a non-retryable fatal error (api_server_port_in_use) in the bind
OSError branch so the platform drops from the reconnect queue;
recover via /platform resume api_server after changing the port.
Re-implementation of #52132 by @msalles1 against the direct-bind path
from #65621 (the pre-probe block their patch targeted no longer
exists). Their production diagnosis and test scenario preserved.
Follow-ups on top of the cherry-picked cluster commits:
- slack: scope-authoritative app-token read — get_secret() with a
narrow UnscopedSecretError fallback to os.getenv. Keeps @kohoj's
correct semantics (scoped profile can never silently inherit the
default profile's Socket Mode app) while fixing the regression where
the default-profile startup loop and background reconnect rebuild,
which call connect() unscoped under multiplex, would raise and
fail-loop. Supersedes the 'or os.getenv' variant from #64461 which
reintroduced the cross-profile fallback leak.
- test: unscoped-multiplex fallback regression test for connect().
- run.py: convert the last legacy self.adapters.get(source.platform)
site (_rename_discord_auto_thread) to _adapter_for_source(source)
so profile-routed Discord sources rename threads on the right
adapter (from #57417's sweep).
- AUTHOR_MAP entry for @aguung.
Partial cherry-pick of a7ffbbff7 from PR #63256: secondary-profile
adapter creation errors no longer abort the whole secondary startup
(try/except around _create_adapter + loud warning on None return), and
Home Assistant's check_ha_requirements() becomes dep-only with the
credential moved to a new validate_ha_config() so secondary profiles
whose HASS_TOKEN lives in the profile secret scope are not silently
dropped by the registry gate.
Telegram diagnostic hunks and profile-label stamping dropped: the
regression they targeted does not exist on current main and they
conflict with the connect() teardown fence.
The single-family pre-probe (_port_is_available) raced the real bind and
reported a lingering TIME_WAIT socket as 'in use', failing gateway
restarts for up to ~60s (#10297). Port the webhook adapter's bind
mechanics (#63711/#65482): delete the probe, bind directly with clean
OSError handling and runner teardown, and scope reuse_address=False to
macOS only so Linux restarts rebind past TIME_WAIT instantly.
Credit to @lrawnsley (#10297) for identifying the TIME_WAIT restart
failure.
Add a telegram.free_response_topics config list of '<chat_id>:<thread_id>'
entries (plus the TELEGRAM_FREE_RESPONSE_TOPICS env bridge) so a single
forum topic can be free-response — the bot replies without a mention —
without opening the whole chat via free_response_chats. A missing
message_thread_id is normalized to the General topic ('1') via
_effective_message_thread_id.
Re-ported from PR #36049 (by @wesleion): the original patched
gateway/platforms/telegram.py, which has since moved to
plugins/platforms/telegram/adapter.py with a second gating site
(_should_observe_unmentioned_group_message) and plugin-hook config
bridging (_apply_yaml_config). Both gating sites now honor
free_response_topics.
Salvaged-from: #36049
load_transcript is now a live-replay restore site that heals alternation
violations on load (#64934), so the old all-user 120-row fixture was
merged into a single message and the precondition len==120 failed. The
fixture was never a valid conversation shape; alternate user/assistant
so it exercises the same bloat scenario without tripping the repair.
Telegram only auto-derives a voice/audio clip's duration from container
metadata for short recordings; clips longer than ~4:50 are delivered with
duration 0 and render as 0:00 in the player. Probe the length locally
(stdlib wave -> mutagen -> ffprobe) and pass duration explicitly to
sendVoice/sendAudio. Best-effort: when nothing can read the file we omit
duration and fall back to Telegram's prior behavior.
Extracts and hardens the Telegram-only part of the stale, Piper-bundled
PR #7815 (ffprobe-only, predates the send_voice retry/anchor refactor);
relates to #8508.
When an agent is running, the gateway runner's running-agent block routes
"session-level toggles that are safe to run mid-agent" through a membership
set before the catch-all that rejects everything else with
"Agent is running — /<cmd> can't run mid-turn".
A dedicated /footer dispatch branch already sat inside that guard, but the
set listed only {"yolo", "verbose"}, so the footer branch was unreachable:
/footer fell through to the catch-all and was rejected, forcing users to
/stop a running agent just to toggle the runtime-metadata footer. /footer
is a pure display toggle like its sibling /verbose — it only writes
display.runtime_footer.enabled and returns a status string — so it belongs
in the same set.
Add "footer" to the set so the existing dispatch branch becomes reachable.
Regression test (tests/gateway/test_footer_command_mid_run.py): asserts
/footer and /footer <arg> dispatch to _handle_footer_command while an agent
is running, with a /verbose parity guard. Verified failing before the fix
(handler awaited 0 times) and passing after.
When gateway.multiplex_profiles is on, the default-profile GatewayRunner
used to call load_gateway_config() unscoped. Platform tokens that lived
only in a profile .env (often a secondary profile) never reached the
primary Telegram adapter, producing "No bot token configured" and an
infinite reconnect watcher loop (#64674).
- Load primary config under the default profile secret scope when multiplex
is enabled (same path secondary adapters already use).
- Skip starting token platforms on the default profile when no credential
is present under multiplex; secondary profiles still connect with their
scoped tokens.
- Drop empty-token configs from the reconnect queue so they cannot spin
forever.
Regression coverage in tests/gateway/test_64674_multiplex_primary_token_scope.py.
Extends @AlexFucuson9's 3-site fix (#58594) across the full adapter:
every logger call and SendResult.error that interpolates a raw PTB
exception now routes through _redact_telegram_error_text(). Covers
polling conflict/retry/network ladders, overflow-split edits, draft
sends, prompt/approval/clarify/picker sends, media send fallbacks,
media cache failures, reactions, and chat-info lookups (48 additional
sites). Telegram Bot API exceptions embed the token in the request URL
(/bot<TOKEN>/<method>), so any raw str(exc) is a leak surface.
Adds regression tests for SendResult.error redaction (update prompt,
clarify) and delete_message debug-log redaction.
MEDIA: tags whose path had an unknown extension (.py, .log, .toml,
.weirdext, ...) fell between both extraction passes: the anchored
extension allowlist (MEDIA_TAG_CLEANUP_RE) did not match them, and the
extension-less pass explicitly skipped any path that HAD a suffix. The
file was never delivered even though the intended design (universal
ingress/egress) says any non-credential file should ship.
Widen _path_lacks_deliverable_extension() so the validated delivery
pass (MEDIA_EXTENSIONLESS_TAG_RE + validate_media_delivery_path)
covers every path the extension allowlist does not — unknown
extensions and extension-less files alike. Security posture is
unchanged: unknown-extension paths only deliver after full validation
(exists, symlinks resolved, credential/system denylist, strict-mode
allowlist+recency), and unvalidated tags stay visible in the text
instead of being silently dropped. Known extensions keep their
unconditional pre-existing behavior.
Because extract_media, _strip_media_tag_directives (non-streaming
dispatch), and strip_media_directives_for_display (streaming) all
share the same two regexes + predicate, all delivery paths pick up the
widened behavior with no per-site changes. Dispatch partition in
gateway/run.py already routes non-image/video extensions through
send_document.
Closes the gap reported in PR #36060; supersedes the allowlist-append
approach there (an extension allowlist can never enumerate every file
type a user asks the agent to produce).
Co-authored-by: Randimt <randimt@users.noreply.github.com>
Disable address reuse so an existing family-specific listener cannot silently split traffic with the webhook server. Normalize wildcard bind hosts for local CLI URLs and align setup documentation with the dual-stack default.
The webhook adapter defaulted to host='0.0.0.0' — IPv4 only. On Fly.io
hosted agents the edge router (hermes-agent-router) reverse-proxies public
webhook traffic to <app>.internal:8644 over 6PN, Fly's private network,
which is IPv6-only (.internal resolves to an fdaa:… address). An IPv4-only
listener is unreachable there, so public webhook POSTs to
https://<agent>.agents.nousresearch.com/webhooks/<route> never landed on
the adapter — the router's dial was refused.
Fix: DEFAULT_HOST = None, which makes aiohttp/asyncio create_server bind
BOTH address families. '::' is NOT a valid substitute: on hosts where the
kernel sets bindv6only=1 (verified on Fly machines) it yields an IPv6-only
socket, breaking the IPv4 loopback /health check and the AF_INET
port-conflict probe in connect(). None binds per-family regardless of the
sysctl. An explicit empty-string/null host in config now also normalises to
None (dual-stack) rather than an invalid host=''. Users can still pin a
specific host via platforms.webhook.extra.host.
Validated live on a Fly staging agent: with this default and no config
override, the adapter binds both v4 and v6 (127.0.0.1:8644 and [::1]:8644
both answer), and a public signed webhook POST through the router returns
202 (valid sig) / 401 (bad sig) instead of the router's 502.
Tests: new TestDualStackBind asserts the None default, config resolution
(missing/empty→None, pinned preserved), and a real dual-stack bind opens
both AF_INET and AF_INET6 listeners. Red-proof: these fail on the old
'0.0.0.0' default.
The served-profiles block in _start_secondary_profile_adapters references
PairingStore, but the class's only import in gateway/run.py is method-local
inside __init__ — so the reference raised NameError at runtime, silently
swallowed by the enclosing try/except ('could not record served_profiles').
Result: multiplexing gateways never created per-profile pairing stores, and
authz pairing checks for secondary profiles fell through to the global
whitelist. Also masked the served_profiles runtime-status write.
One-line fix (local import alongside write_runtime_status) + regression
tests that drive the real method and assert the stores materialize,
verified red without the import and green with it.
Surfaced during the profile-routing sweep by @CocaKova's PR #61689, which
included the same fix as part of a larger feature.
The #54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The #54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.
These two fixes compose incorrectly: when the #54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. #54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.
Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.
No open upstream issue tracks this specific interaction as of 2026-07-12
(checked #54878, #54947, #59580, #59597, #61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).
Fix: before applying #54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.
Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [#54947 intact],
cross-process invalidation preserved [#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
GatewayRunner._prepare_inbound_message_text() interpolated
source.user_name — the platform-supplied, user-settable display name —
directly into the message text of every turn in a shared multi-user
session: f"[{source.user_name}] {message_text}". Shared sessions are the
default for any threaded conversation (thread_sessions_per_user defaults
to False) and apply to any group with group_sessions_per_user=False, so
no special configuration is needed to reach this path.
An unescaped display name containing embedded newlines could therefore
masquerade as a new markdown section (a fake "## Override" heading)
inside the live conversation the model reads on every turn — the same
indirect-prompt-injection vector gateway/session.py's
build_session_context_prompt() already guards against for the identical
user_name field via _format_untrusted_prompt_value(), which was never
applied to this sibling call site.
Add neutralize_untrusted_inline_text() alongside the existing helper in
gateway/session.py: it collapses embedded newlines/control characters to
a single inert line without JSON-quoting, so inline "[Name] message"
formatting is preserved byte-for-byte for the common case (unlike
reusing _format_untrusted_prompt_value directly, which would add visible
quote marks to every sender prefix). Wire it into the sender-prefix
construction in gateway/run.py.
Follow-ups on the salvaged #20096 profile-routing feature:
- _profile_name_for_source now returns None unless gateway.multiplex_profiles
is on. Routing stamps source.profile, which namespaces session/batch keys,
but the profile-scoped agent run only activates under multiplexing — without
the gate, configured routes with multiplexing off split batch/session keys
into agent:<profile> while the agent still ran from agent:main.
- Widen the profile-aware _text_batch_key fix from Discord to every adapter
that builds batch keys via build_session_key (telegram, whatsapp, matrix,
feishu, wecom, weixin) — routing is platform-generic, so the batch-key
namespace fix must be too.
- Downgrade the no-route-matched log from INFO to DEBUG (fired on every
unrouted inbound message).
- GatewayConfig.to_dict(): serialize profile_routes as plain dicts
(ProfileRoute dataclasses are not JSON-safe).
- Docs: correct the 'independent of multiplexing' claim in
docs/profile-routing.md (routing requires multiplexing), fix the
platform-only specificity row (0, not 1), and document profile_routes in
website/docs/user-guide/multi-profile-gateways.md.
- Tests: pin the multiplex gate (routes ignored when off, active when on,
build_source end-to-end stays in agent:main when off).
Completes the review's ask for "adapter-to-session-key integration coverage
for Discord and a non-Discord platform" on #20096.
Drives a concrete adapter's real BasePlatformAdapter.build_source with an
injected gateway_runner, asserts the matched route's profile is stamped on
the source, and that build_session_key scopes the key under agent:<profile>:
(versus the shared agent:main: namespace). Covers Discord and Telegram — the
Telegram case is the bug-#2 path that previously fell through to default.
Adds a regression anchor: without gateway_runner, profile stays None and the
key lands in agent:main (the silent fallback the fix removes for non-Discord).
Co-Authored-By: Claude <noreply@anthropic.com>
Addresses hermes-sweeper review on #20096.
Problem 1 (profile_routing.py): route matching returned True on a chat_id
hit before the guild_id constraint was consulted, so a route declaring both
guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND)
semantics — every declared discriminator must hold; hierarchical parent_chat_id
matching is preserved. Added a regression test for the guild+chat case.
Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter
pre-declared the attribute, and only Discord did — so build_source never called
_profile_name_for_source for Telegram/Feishu/Slack/etc., despite the
platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made
the plugin-registry injection unconditional, so profile routing now reaches
every platform. Added non-Discord (Telegram) resolution coverage and an
injection-inheritance test.
Also adds docs/profile-routing.md documenting gateway.profile_routes
(matching rules, specificity, profile isolation) — requested in review.
Co-Authored-By: Claude <noreply@anthropic.com>
load_gateway_config only forwarded the top-level multiplex_profiles
key, ignoring the nested gateway.multiplex_profiles form. The latter
is what `hermes config set gateway.multiplex_profiles true` writes,
so users who ran that command got multiplex_profiles=False silently
— no warning, no fallback, profile_routes just stopped matching.
Loader now checks the top-level key first, falls back to the nested
gateway section, and only then defaults to False. Same precedence is
applied to other nested-form keys (profile_routes already did this).
Tests cover: top-level honored, nested honored (regression test for
the silent-fallback bug), default False, top-level overrides nested.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
_adapter_credential_fingerprint only looked at adapter.token directly,
but Discord (and similar) adapters store the bot token on their config
sub-object, not on self. Every Discord adapter in a multiplexed
gateway therefore returned None, the same-token conflict check was
silently skipped, and N adapters all polled the same bot token —
producing a per-message race where whichever adapter won the GIL
answered the user.
Adds a config-token fallback (token, then bot_token) so the check
actually fires for config-backed adapters. Direct adapter.token
still takes precedence when both exist.
Tests cover: config-backed token produces a fingerprint, distinct
tokens produce distinct fingerprints, direct token wins over config,
config without token attributes returns None.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>