OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.
The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
Review fold (round 1, simplify reuse + quality):
- tokenhub_effort() in agent/reasoning_effort.py: the tencent-tokenhub profile and the host-based
branch base_url-only agents take used the same "high unless chosen, else clamp" expression in
two places, so the two paths could drift apart.
- generic_nested_reasoning(): the OpenAI-compatible extra_body fallback auxiliary calls send when a
profile declares no reasoning shape. The lmstudio profile's auxiliary branch was a line-for-line
copy (it must produce it itself because overriding build_api_kwargs_extras turns the aux
fallback off). Both now call the helper.
- agent/lmstudio_reasoning.py docstring names its real caller (the lmstudio profile).
Behaviour unchanged: 42/42 main-loop request cells and every auxiliary projection cell are
byte-identical to origin/main.
- Plugin hooks written against the older build_api_kwargs_extras keywords (no **context) no
longer raise TypeError on every request: lmstudio_reasoning_options is forwarded only when the
agent probed it (LM Studio). Pinned by TestProfileHookKeywordCompat, which fails if the field is
passed unconditionally.
- tencent-tokenhub: drop base_url and supports_model_listing=False. With them, first-time
`hermes model` setup reached the profile path and offered only the 3 curated Hy models instead
of probing {base}/models as main does; without a profile base_url, setup and the /model picker
take the same paths they took with no profile.
- One name for LM Studio's probed options end to end (lmstudio_reasoning_options), matching the
other provider-prefixed hook keys (ollama_num_ctx, qwen_session_metadata).
- New base-url:github-models-chat snapshot cell (openai/o4-mini, asserted chat_completions): the
gpt-5.5 cell goes to Responses and never reached the legacy GitHub reasoning branch.
- Comment/docstring truth: the host branches serve agents with no registered profile (not only
base_url-only ones); the LM Studio effort omission is done by the profile.
lmstudio and tencent-tokenhub were the last chat-completions providers with no ProviderProfile,
so their requests were built by the legacy flag branch in ChatCompletionsTransport.build_kwargs
(name check for LM Studio, host check for TokenHub). They now have profiles that reproduce the
same wire through build_api_kwargs_extras:
- lmstudio: top-level reasoning_effort from the model's published allowed_options. The agent's
cached probe is passed to the hook as reasoning_options; auxiliary calls (no probe) keep the
generic nested reasoning they already sent. base_url is left empty on purpose: a loopback
base_url would map every 127.0.0.1 / localhost endpoint to LM Studio in URL-to-provider
inference and turn off local-server detection for Ollama, llama.cpp and vLLM. Model listing
and the doctor probe stay off so the picker keeps LM Studio's own chat-model probe.
- tencent-tokenhub: top-level reasoning_effort clamped to low/medium/high, omitted when thinking
is off, high when no level is chosen (default_reasoning_config on main-loop turns). Model
listing and the doctor probe stay off so the picker keeps the curated Hy list.
The name-based LM Studio branch is removed from the legacy path. The host-based branches stay:
an agent built from a base_url alone (run_agent.py main, the batch runner, library callers)
carries provider="" and still reaches the legacy path. 11 base_url-only snapshot cells now pin
those host-detected requests (OpenRouter, Nous, Kimi, Kimi coding, TokenHub, GitHub Models,
NVIDIA NIM, Qwen portal, a localhost LM Studio port, Ollama, a generic endpoint) so the change
that retires the legacy path can prove it sends the same requests.
Deliberate deltas: model ids prefixed lmstudio: / tencent-tokenhub: are now stripped like every
other profiled provider's; TokenHub auxiliary calls with a reasoning config send top-level
reasoning_effort (the main-loop shape) instead of nested extra_body.reasoning.
Review of #130067: traits described kernel meaning nothing reads (and two
descriptions were incomplete: archived also satisfies a parent gate; the
kernel re-checks parents on ready/review). Same reasoning as the dropped
event model: Phase 0 keeps only data that is consumed or checked against
live code; column semantics are designed with the kernel switch.
Nits: VALID_STATUSES is the frozenset itself; precise tuple/frozenset types.
Review of #130067: the event edges claimed every block lands in 'blocked',
but _route_block routes to todo / triage / blocked, and ~20 kernel status
writes are conditional (crash/reclaim return, failure breaker, parent
reopen, import). Rather than publish a partial contract, Phase 0 keeps only
what is checked now (columns, traits, manual matrix, derived copies); the
event model is designed with the kernel switch in Phase 1.
Also from review:
- can_move rejects unknown column keys instead of defaulting (archive of a
bogus key returned True).
- the matrix test only accepts 400/409 as a refusal; 5xx or 422 fails.
Groundwork for user-defined columns and workflow per board (#54818).
- hermes_cli/kanban_workflow.py: Column/Workflow + DEFAULT_WORKFLOW, today's
board written down as data (column order, traits, default kernel-event
edges, manual move allow-list). Not consulted by the kernel yet.
- VALID_STATUSES, dashboard BOARD_COLUMNS, the kanban_list status enum and
CLI status icons now derive from it. Fixes drift: the tool enum lacked
scheduled/review; CLI icons lacked triage/review.
- GET /workflow on the dashboard plugin API.
- Tests pin DEFAULT_WORKFLOW to real behavior: every default event edge vs.
its kernel verb, and all 64 PATCH src->dst moves vs. can_move.
Telegram echoes back the client-controlled inline_query.offset string on
every inline query. build_inline_results passed it through int() and
used it directly as a slice start, so a negative offset indexed the
catalog from the tail: small catalogs served their last few entries with
negative-prefixed result ids, and larger catalogs could return an empty
page with a misleading next_offset that skipped most of the catalog.
Clamp the parsed offset to >= 0. Malformed offsets still fall back to
page zero via the existing except path, and valid pagination is
unchanged.
Desktop Thinking Off can arrive as {effort: "none"} without enabled=False,
so V4 kept sending thinking:enabled and billed reasoning tokens.
Co-authored-by: Cursor <cursoragent@cursor.com>
Greptile 4198572811: the guard now finds subprocess/os/asyncio calls as ast.Call nodes, so multi-line calls are checked, and the 27 calls it newly flagged (pixel-art ffmpeg and 26 others) pass stdin=DEVNULL.
* fix(web): keyless search fails over when a free tier refuses the client
Firecrawl's anonymous endpoint answers 403 (an IP reputation gate) for some
clients, and a free tier can also answer 402 (credits gone) or a 5xx. Main
already moves a keyless search to the next ring vendor on a structured
401/403 (2f78707a79, from #91619), but two cases still stopped the walk:
- Keenable's keyless client raised only the response body, without the
status code, so its 401/403 never matched the structured status check.
- The keyless Firecrawl client raised httpx's default "Server error '503 ...'"
text for 5xx, and no status pattern covered 402 or 5xx.
The status pattern now covers 401/402/403 and 5xx, and both keyless clients
raise "HTTP <code>: <vendor reason>", so the walk sees the status and the
error keeps the vendor's reason. Extract keeps main's rule: it fails over
only when every URL is rate-limited, because a 403 there can come from the
page rather than the vendor.
* fix(web): a search error that echoes the query no longer walks the keyless ring (review)
The vendor-refusal status match now judges only the status the error starts with, so 'HTTP 400: invalid query "http 503"' stops the walk instead of trying every free vendor.
`GET /api/plugins/kanban/board` returns an `assignees` list used by the
dashboard for the lane-by-profile sub-grouping in the running column.
Previously this came from a `SELECT DISTINCT assignee FROM tasks WHERE
... status != 'archived'` query, so a freshly-added profile only got
its own lane after being assigned its first non-archived task.
Switch the lookup to `kanban_db.known_assignees()`, the helper that
already backs the `/assignees` picker endpoint at plugin_api.py:1660.
It unions profiles configured on disk with profiles currently holding
non-archived tasks, so a new profile appears in the lane set
immediately. This also keeps the two endpoints from drifting —
previously they could disagree about who's "known."
Updates `test_board_empty` to reflect the new semantics: the implicit
`default` profile that the fixture's `HERMES_HOME` creates now shows
up in `assignees` even on an empty board.
Test plan:
- `scripts/run_tests.sh tests/plugins/test_kanban_dashboard_plugin.py`
— 94/94 pass (was 43/44 pre-test-update).
- Manual: add a new profile via `hermes profile create foo`, then open
the dashboard kanban; `foo` appears as an empty lane in the running
column without needing to assign it work first.
Platforms tested: Linux (WSL2). Pure Python, no platform-specific code.
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.
Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.
Fixes#54808, Fixes#55121
Both dashboard-auth JWT verification paths (self-hosted OIDC ID token and the
Nous Portal access token, via the shared verify_jwt) called PyJWT jwt.decode
with the default leeway=0 while requiring iat. Any host whose clock lags the
issuer's — unsynced containers/VMs, NTP jitter — failed login with
ImmatureSignatureError, and the failure was folded into ProviderError, i.e.
HTTP 503 'provider unreachable', discarding already-rotated refresh tokens
and forcing unrecoverable re-logins.
- verify_jwt gains leeway (default 60s, RFC 7519 §4.1.4-4.1.6) and classifies
ImmatureSignatureError as InvalidCodeError (validation failure -> 401 /
refresh) instead of ProviderError/503.
- New parse_leeway helper: fail-closed parsing (unparseable / negative / NaN /
inf -> 0, never widens the window); 0 honours strict mode.
- Self-hosted OIDC: id_token_leeway kwarg + dashboard.oauth.self_hosted.id_token_leeway.
- Nous: token_leeway kwarg + dashboard.oauth.token_leeway.
- No new env vars (config.yaml-only knobs).
Fixes#47815
plugin-catalog/mem0.yaml (#131267) points at mem0ai/mem0's own
integrations/hermes-plugin-mem0 at a reviewed pin. The entry stays dormant
while a bundled copy exists (bundled providers win in find_provider_dir), so
deleting the in-tree copy is what lets hermes_cli/memory_provider_migration.py
install it for every home whose memory.provider is "mem0". The vendor copy
keeps the provider name, mem0.json, the MEM0_* keys and the setup wizard.
Also dropped: the hermes-agent[mem0] extra, its win-arm64 platform row and
exclude-newer row, the pm/extras.py anchor (uv lock removes only mem0ai,
posthog, qdrant-client and sqlalchemy), the mem0-specific doctor check (the
generic check now names `hermes plugins install mem0`), and the tests that
imported plugins.memory.mem0. Prose that lists the bundled set now names mem0
as a catalog provider. MEM0_* env hints and redaction stay: the catalog copy
still reads them.
Desktop's web picker had no way to send one capability through the Nous
Tool Gateway while the other used your own key. "Use for Search/Extract"
on the Nous Subscription row wrote the row's vendor string (firecrawl)
into web.<cap>_backend, which the runtime reads as BYOK Firecrawl: the
gateway was never used, and the managed and BYOK rows lit up together.
- The managed per-capability pick now stores `nous`; search/extract
dispatch, the registry and the Firecrawl client resolve the route per
capability (search: managed Perplexity, extract: managed Firecrawl).
- A whole-toolset Nous pick clears stale per-capability pins (they
outranked the new selection).
- /api/tools/toolsets/web/config reports search_via_nous /
extract_via_nous and each row's managed_nous_feature; the panel lights
the right row, labels the route, and refetches after a pick instead of
mirroring the vendor string.
Fixes#100513. Builds on the approach in #100539 by @liuhao1024 and the
self-healing pin clear from #122676 by @praggybuilds.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Murphy (praggybuilds) <praggybuilds@users.noreply.github.com>
Gate finding C02-2 (Low): the function-local import in
discover_fallback_ips did not match the adapter, which already imports it
at module top. agent.ssl_verify imports only stdlib at module level, so
there is no cycle or import cost.
adapter.py is over the 2000-line target and frozen at its main size by the
code-health ratchet; the local import + named context variable grew it by 3
lines. A module-level import and calling the cached accessor at each use site
(it returns the same identity) keeps the change net-zero, and the test now
patches the name where production reads it.
Co-authored-by: luwen <142317085+LuwenOosten@users.noreply.github.com>
Every Telegram httpx client (DoH fallback discovery, both PTB HTTPXRequest
pools, and each lazily rebuilt TelegramFallbackTransport pool) was built with
verify=True, so httpx ran ssl.create_default_context() and a full CA load on
the gateway event loop per client, per reconnect. A slow trust store stalled
the loop for most of the connect deadline on every attempt.
agent.ssl_verify already caches one platform-trust client context
(_shared_context(None)); expose it as platform_ssl_context() and pass it as
verify= to every Telegram client and transport. Verification is unchanged
(CERT_REQUIRED + check_hostname via the truststore-injected context), proxies
still flow through httpx's proxy/env handling, and the first build is ~3 ms
because the platform verifier loads no CA file.
This is the minimal slice of #133340: the PR's single-flight Future, worker
thread and to_thread client construction are unnecessary once the context is
cached and shared.
Fixes#133339
Salvaged from #133340.
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
to the generic PKCE flow as token_request. A refresh that NAS rejects
(it folds every Google failure into 502) is treated as a dead grant
needing re-login.
- transport.py: a GeminiNativeClient subclass on the
generateContentPerUserQuota methods with Bearer auth (~30 lines, where
the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
default_aux_model).
Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
Per-user-quota OAuth inference for Hermes Agent, hidden by default until
public launch is cleared. Auth (OAuth2 + PKCE with the user's account) is
brokered by nous-account-service, which holds the confidential client secret;
inference goes direct to the per-user-quota endpoint.
Built fresh on current main; commit history carries only the codename.
Provider surface:
- plugins/model-providers/solstice/ — profile + hidden enable gate
- hermes_cli/solstice.py — PKCE loopback login, NAS-brokered config/exchange/
refresh, token store, quarantine; callback path unchanged
- agent/solstice_adapter.py — per-user-quota transport over the native
adapter's OpenAI-compatible facade
- providers/: _HIDDEN_GATES + register_hidden_provider_gate + a hidden filter
in list_providers (kept out of the picker/registry/doctor until enabled)
- wired into main.py (_PROVIDER_MODEL_FLOWS), auth_commands.py (_PROVIDER_ALIASES,
_OAUTH_ADD_SPECS, _is_known_provider allowlist), models.py (catalog fetcher),
runtime_provider.py (resolution rung), model_setup_flows.py, agent_runtime_helpers.py
Kept verbatim (the live wire contract, until the broker is renamed): the
inference host, the OAuth scope, the deployed broker routes, and the
registered redirect callback path.
The enable gate is unchanged (open item: gate on stored credentials).
Verified: 226 focused tests pass; runtime resolution, client construction
and auth-add all exercise the real paths.
Gate finding C01.r1.2c [High]: the hand-back added for failed candidates
also ran in the post-install exception/cancel branches. After a successful
secondary install (or a primary publish) the predecessor already forwards
to the new adapter; if the task was cancelled during redelivery (gateway
teardown cancels secondary reconnect tasks) the hand-back linked the new
adapter back to the predecessor, and any hold - e.g. the disconnect()
teardown salvage - ping-ponged between the two until RecursionError.
Break the cycle at both ends: adopt_held_inbound clears the adopter's own
successor link (it owns the queue again), and the runner skips the
hand-back when the adapter is the one installed in its slot. Covered by a
cancel-after-install row on the secondary hand-back test.
join_voice_channel(channel, text_channel_id=..., source=...) is the programmatic join API, and it
kept the requested binding only on a cold join: the already-connected paths (same voice channel, or a
move) returned before writing it, so a successful join to another text channel kept transcribing
into the old one. All three paths now apply the binding through one helper. Moving to another text
channel drops speech still buffered for the old one, as /voice join does, and the old channel's bound
source unless a new one is given: its chat would keep routing turns into the old conversation.
With no bound source, a voice turn built a synthetic one with chat_type "channel". A typed message in
a guild text channel is "group" (or "thread" under its parent), so the voice turn keyed another
session. It now goes through build_source as a typed message does, with the guild, thread and parent.
/voice join, the only in-repo caller, already writes its binding itself after joining, so this is
latent until something calls join_voice_channel with a text channel (the open auto-join PRs would).
Reported by @andrexibiza as F7 in #132474.
Code-health ratchet: plugins/platforms/discord/adapter.py was 7389 lines
against a cap of 7370 after this stack added the live-auth confirmation to
the component click check. Move the shared component authorization helper
(byte-identical body) into a topical sibling; the view's _check_auth
late-imports it, and the direct-helper tests import it from its new home.
A '*' in the connect-time DISCORD_ALLOWED_USERS snapshot still
short-circuited component auth, so removing the wildcard from .env after
connect left every button open until a restart. The gateway's live check
already honours '*' itself, so route the wildcard through it like a
listed ID. Also drop the redundant _authorization_check guard
(_is_sender_authorized already returns None when no check is wired) and
trim the docstring.
Component views authorized clicks against the adapter's connect-time
_allowed_user_ids, so a user removed by `hermes pairing revoke` (CLI or
dashboard, another process) or a .env edit kept approving exec commands
until a gateway restart. A user found only in that snapshot is now
confirmed with the gateway's per-call authorization check, as Matrix
approval reactions are since #130920; role, allow-all and pairing grants
are unchanged, and an unwired adapter keeps the snapshot.
(cherry picked from commit a6ced15a6000247b9736cede3cbf4c6ed0c11265)
_evaluate_slash_authorization moves unchanged into adapter_slash_auth.py beside the new _slash_role_grant, so adapter.py stays within its code-health file cap. The thread-starter test passes a thread, as _dispatch_thread_session now takes one, and the voice role-grant test gains type hints.
The salvaged #125620 commits stamp role_authorized on _build_slash_event
and _dispatch_thread_session from bool(_allowed_role_ids), which is only
right because today's two callers run _check_slash_authorization first.
Neither builder checks its actor itself, so any later caller that builds
an event before (or without) the gate would mint a grant for a member the
gate refuses.
The grant is now re-evaluated from the interaction in both builders
(_slash_role_grant): a role allowlist exists AND this actor passes the
slash gate, the meaning _discord_message_admission gives the flag. A
role-only member still reaches the agent, and an unrelated-role actor's
event is refused by the gateway even when built directly. The two
salvaged tests fold into one parametrized invariant over both builders
with the refusal row.
Recomputing the verdict from the actor follows #118966 by KoNit-K.
Reported by andrexibiza (F4, native half) on #128788 and #130303.
Co-authored-by: KoNit-K <konit.block@protonmail.com>
(cherry picked from commit 357e77d09263d25d7779d42f0eda3d352b3032a9)
Review feedback: the comment read as if the stamped role_authorized
value were a per-user role match. It is the same adapter-level verdict
the message path stamps: a role allowlist exists and the user passed
the slash authorization gate.
(cherry picked from commit e4165fc2ed7f65b9356ac75f90cab02aec85d70c)
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:
- plugins/computer_use/__init__.py discovers bundled
plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
(marker: register_computer_use_provider / ComputerUseProvider) and loads
ONLY the provider computer_use.backend names (default "cua"), read per
call so each profile gets its own. Unselected providers are never
imported; listing reads plugin.yaml only. An unknown/unloadable name
returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
plugins/computer_use/cua/ (driver implementation stays in
tools/computer_use). tools/computer_use/backend.py gains
ComputerUseProvider (create_backend(permission_mode=...), is_available(),
optional doctor()).
- The tool's check_fn and backend construction go through the selected
provider; the model-facing schema is unchanged for every provider
(prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
(not eagerly imported, not listed as toggleable) and skips the bundled
computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
to cua; computer-use doctor/status/permissions and the Desktop status
payload skip cua-driver/TCC probes when another provider is selected.
The receiver buffers PCM per speaker with no conversation identity, and a same-voice-channel /voice join keeps that receiver. The poll and the leave flush then read the binding after draining, so audio captured for channel A was stamped with B and passed the post-STT check. The gateway join handler now discards the receiver's unemitted audio, under its lock, before moving the binding to a different text channel; a first join or same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.
Reported by andrexibiza (F1) on #133471 and #130312.
get_voice_channel_info and get_voice_channel_context move unchanged into adapter_voice_info.py, and the new comments and docstring are folded, so adapter.py stays within its code-health file cap. Add type hints to the new test.
The listen loop transcribes a check_silence() batch serially, so a later
utterance read its binding after a /voice join landed during an earlier
utterance's STT and was posted to the new channel. Capture the binding
once per batch (and per leave flush) and pass it in.
(cherry picked from commit e4b020a0fd21833a89fc1fa7f904db489d797d9c)
_process_voice_input awaits WAV conversion and STT, then calls the
gateway callback with only guild, user and transcript, and the callback
routes by the guild's binding as it is at that moment. A `/voice join`
from another text channel while the bot is already connected rewrites
_voice_text_channels and _voice_sources, so an utterance captured for
the old conversation became a turn in the new one, with its channel
prompt, bound skills, transcript echo and replies.
The bound text channel is captured when the utterance is accepted and
compared after STT; if the binding moved, the stale work is dropped and
logged. Rejoining from the same channel keeps the conversation and is
dispatched as before. Nothing awaits between that check and the
callback's own read of the binding.
Reported by andrexibiza (F6) on #128788.
(cherry picked from commit 47d515eb4e1633945212e42d6cf77aba41906ea8)
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.
The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
Review fixes for the per-module plugin load lock:
- A second caller waits at most 10 s for another thread's load of the same
module, then refuses with a named warning and returns None. While that
import is still stuck, later callers refuse at once. A hung provider import
no longer blocks every gateway turn, and there is no inline fallback load.
- The wait polls a wait-for graph spanning the loader locks and importlib's
own module locks. When thread A holds a helper's import lock and waits on
the plugin while thread B loads the plugin and imports that helper, the
cycle is detected and the waiter takes the partial module, the same way
importlib handles a concurrent circular import. Before, both threads hung.
- The lock is still a same-thread RLock with no worker thread, so recursive
loads and ContextVars are unchanged.
- conversation_compression.py: compact the fork-checkpoint exemption so the
file stays under its line cap after the rebase.
cmd_install chose the memory.provider path with _is_memory_provider_dir, a
substring search for "MemoryProvider" in __init__.py. A hook plugin whose
docstring says "Not a MemoryProvider" passed, and --enable replaced the
user's memory.provider with a name load_memory_provider returns None for
(exit 0). _defines_memory_provider parses the package's modules (no import)
for what _load_provider_from_dir can load: a register_memory_provider(...)
call or a MemoryProvider subclass.
ensure_aiohttp flattened every PM failure into the non-retryable
whatsapp_aiohttp_missing. That covered PM's post-install
"sms installed; restart Hermes to activate it" (sync succeeded, process
could not adopt the generation), a busy dependency lock and a failed
download. With WhatsApp as the only platform the gateway exited 78 and
systemd's RestartPreventExitStatus=78 kept it down.
ensure_aiohttp now returns (why, retryable). Final only when PM can never
install here (lazy installs disabled, or a foreign interpreter not on the
install's selected or older generation), when aiohttp is present but
broken, or when it is still absent after a successful install. A plain
shadowing aiohttp.py (no __path__) is reported as an incomplete install
instead of escaping as AttributeError.
A gateway whose dependency environment lacks aiohttp (a sealed env from
`hermes update` with a partial messaging extra, #126358) spawned the bridge,
then every /health poll raised ModuleNotFoundError inside
`_poll_bridge_health`'s blanket except. connect() returned False with no
fatal code, so the reconnect watcher retried forever while the bridge
answered /health 200, and telemetry counted each attempt as error_class
"other".
- connect() now obtains aiohttp through PM (`ensure_import("sms")`, the
extra that is exactly aiohttp) before starting the bridge, the same
lazy-install path Slack/Teams use. When PM cannot install it (lazy
installs disabled, foreign interpreter, install failure) or aiohttp is a
half-removed namespace shell (#71308), it fails with the named,
non-retryable `whatsapp_aiohttp_missing` and never spawns the bridge.
- The bridge-died / "did not start in 15s" paths set a retryable fatal code
(`whatsapp_bridge_exited` / `whatsapp_bridge_timeout`), so reconnect
status and connect telemetry name the cause instead of "other".
- Folds the salvaged test file into test_whatsapp_connect.py as two
invariant tests.
A sealed env built by `hermes update` can ship a partial messaging extra
without aiohttp. check_whatsapp_requirements only verified Node.js, so
connect() spawned the bridge and every health poll raised
ModuleNotFoundError — swallowed by `except Exception: continue` — and
the gateway looped forever on the misleading "Bridge HTTP server did not
start in 15s" (#126358).
Add _aiohttp_available() and wire it into _preflight() as a named,
non-retryable whatsapp_aiohttp_missing fatal error with install guidance,
so the failure surfaces before any bridge process is spawned.
Salvaged from #126557 (Finn763), whose lazy-install route through
tools/lazy_deps no longer exists on main (the module is now updater
shims); this keeps the fail-fast core and adapts it to the current
_preflight() structure.
Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
GLM-5.3 rejects thinking.type=disabled with HTTP 400 code 1210
(issue #85890, #96373). An explicit off preference now sends
enabled plus reasoning_effort=low. 4.5/4.6/5.2 still send disabled.
VoiceReceiver.start() captured conn.secret_key (by value) and
conn.dave_session (by reference) exactly once. Both go stale in normal
operation:
- op 4 SESSION_DESCRIPTION rotates the transport secret_key on every
voice (re)connect — the snapshot then fails NaCl decrypt forever.
- voice_state.reinit_dave_session() REPLACES conn.dave_session with a
NEW DaveSession when none existed yet, so a receiver that starts
before DAVE finishes negotiating keeps None forever and treats E2EE
as off — every frame reaches opus as ciphertext for the whole
session.
The failure is silent by construction: decrypt failures warn for the
first 10 packets then go dark, and shredded opus output raises nothing.
The operator-visible symptom is "the bot ignores me."
Fix — resolve, don't copy:
- Credentials live in ONE tuple (secret_key, dave_session,
dave_protocol_version, dave_downgraded), re-resolved from the live
connection and swapped in a single reference store; the receive
thread reads one consistent generation per packet, so a refresh can
never pair a new key with an old session.
- Refresh triggers: the voice-ws hook on op 4 and op 24 (the hook runs
AFTER received_message dispatches the op, so it reads the new
values); membership changes in the bot's channel (DAVE epoch bump)
via the existing on_voice_state_update handler; and a
25-consecutive-NaCl-failure streak as the catch-all, retried every
streak window while the failure persists.
- Unmapped-SSRC hole closed without recreating it elsewhere: with E2EE
actively on (protocol_version > 0 and outside a passthrough window),
frames for an SSRC with no user mapping are dropped (after the
existing sole-member inference) instead of being fed to opus as
ciphertext. A non-null session alone is NOT the predicate — the
session object survives protocol downgrades to 0 and passthrough
transitions, where plaintext is legitimate. Passthrough windows
mirror discord.py's own set_passthrough_mode calls: op 21
pending-downgrade opens 120s; op 22 opens a 10s grace ONLY on the
dave_downgraded True -> False edge (upgrade-after-downgrade), with
replace-semantics so the short grace supersedes a residual long
window; a receiver starting mid-pending-downgrade seeds the window
from conn.dave_pending_transitions.
- Silence made visible: decrypt failures keep emitting one warning per
250 after the first 10, and stop() logs decode-health counters
(ok / decrypt_failed / dave_unmapped_dropped).
Tests: tests/gateway/test_discord_voice_rekey.py (24) — credential
resolution/rotation, the late-DAVE-session upgrade, failure-streak
refresh + reset, op 4/24/5 hook behavior, original-hook passthrough,
the DAVE-state drop predicate (downgraded-plaintext decodes, op-21
window decodes, expired window drops, upgrade edge replaces the long
window, same-version and duplicate op 22 open nothing,
mid-pending-downgrade startup seeds), unmapped-drop vs mapped flow,
sole-member rescue, tuple-generation consistency, teardown health
line. Adjacent voice_command / voice_mixer / send / opus suites green
under the diff (111 passed).
Fixes#77968
Add type hints to the parity test and fold two new adapter comments into one line each, so adapter.py stays at its size on main (code-health file cap). The channel-label helper lives beside the thread-name formatter in adapter_thread_titles.py.
A spoken turn rebuilds its source from the copy `/voice join` stored, and
three of its prompt inputs still differed from a typed message in the same
conversation:
- voice joined from a thread looked up the channel prompt and bound skills
for the thread alone, while a typed message there falls back to the
parent channel's entry, so a parent-only binding never reached speech;
- the chat name and topic stayed at their join-time values, so after a
rename or topic edit every typed/spoken switch re-rendered the pinned
context prompt and back;
- `guild.get_member()` is cache-only, and on a miss the speaker was named
by numeric id even when they were the member who ran `/voice join`.
The resolvers now get the stored parent id, the name and topic come from
the bound channel through the same formatters a message uses (the stored
values stay as the fallback when the channel is not cached), and an
uncached joiner keeps the name bound at join; any other uncached speaker
still gets their id, never the joiner's name.
Reported by andrexibiza (F1, F2) and teknium1 (get_member) on #128788.
Co-authored-by: Matt McFarland <mattttmcfarland@gmail.com>
(cherry picked from commit b993d20b240b34a61cf9c54dbd52d9050680f497)
Native slash commands (/skill, /plan, /queue), the /thread starter and
voice-channel input are human turns, so each re-pins the session-context
prompt and the channel prompt. They built their event without the fields
on_message sets:
- a DM slash turn had no chat name, so the "origin" line rendered the
channel id instead of the user's name;
- a thread slash turn and the /thread starter dropped the parent channel
from the thread label, and the starter took the parent channel's topic;
- a spoken turn named the speaker by numeric id, not display name;
- none carried the channel's bound skill, so a session opened by one of
them never loaded it.
Each switch between one of these turns and a typed message re-rendered
the pinned context prompt and back again, a prompt-cache miss each way.
They now use the same formatters (_format_thread_chat_name,
_get_effective_topic on the thread, the member display name) and
_resolve_channel_skills as the message path.
(cherry picked from commit c2727ba500f22249dd729aba6365af3bb3c5e970)
Add type hints, drop the channel-prompt and skill-binding assertions (they guard behaviour the fix does not change), and fold the new adapter comment into one line so adapter.py stays within its code-health cap.
The first turn of an auto-threaded @mention already points its source at the new thread, but it read chat_topic from the text channel the mention was posted in. The thread's next message reads the thread's own topic (none, outside forums), so the pinned session-context prompt was re-rendered on turn 2 of every auto-threaded conversation in a channel with a topic: a prompt-cache miss, and a Channel Topic line the model saw once and then lost.
(cherry picked from commit d9fedccacd120ca1833269ed0608c3c89c4c1015)
The mask record was installed only after the REST edit returned and was
retired from the cached on_thread_update callback. Gateway updates can land
while the edit is in flight, and discord.py mutates the cached thread before
scheduling callbacks, so batched updates all report the final name. Either
way a moderator's restore of Hermes's title could render the opening name.
Record the guarded attempt before awaiting the edit, advance or retire it
from on_raw_thread_update, and on failure or cancellation drop only the
record that attempt still owns. The record store and _format_thread_chat_name
move into adapter_thread_titles.py so adapter.py does not grow.
Rename-only part of #132758's f67ef417e0; its message-id, slash/voice and
composed-contract changes are left to their own PRs.
Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>