Skip to content

fix(gateway): voice-channel turns render the same session prompt as text turns - #127944

Closed
matttmcfarland wants to merge 5 commits into
NousResearch:mainfrom
matttmcfarland:fix/voice-session-prompt-cache
Closed

matttmcfarland wants to merge 5 commits into
NousResearch:mainfrom
matttmcfarland:fix/voice-session-prompt-cache

Conversation

@matttmcfarland

Copy link
Copy Markdown

What does this PR do?

A Discord voice-channel turn renders a different "Current Session Context" block from a text message sent by the same person in the same channel. That block sits near the top of the system prompt, so every switch between typing and talking misses the prompt cache for the whole conversation.

On a self-hosted vLLM (Qwen3.6, prefix caching on), the first voice reply after a text message re-prefilled 90,294 tokens: one model call took 24 s for a 33-token answer. The text turn a minute earlier, on the same conversation, took 3.2 s. Across two days of logs, most first voice turns after text took 11 to 70 s. With this fix, the first call after a voice→text switch took 4.3 s at 113K tokens and 4.7 s at 125K, and the same cached agent served both kinds of turn.

Two differences, both fixed:

  • User name. GatewayVoiceMixin._voice_input_source set user_name = str(user_id), so **User:** and the **Source:** description showed the bare Discord ID where text turns show the display name. Voice sources now carry the member's display name, the same value text messages carry (author.display_name), through a new DiscordAdapter.voice_member_display_name. If the member isn't cached, it falls back to the name bound at /voice join for that same user, else the ID.
  • Triggering message line. The Discord note rendered "Triggering message: provided per-turn…" only when the turn had a message_id, and voice and slash-command turns never do. The line is now unconditional, worded for turns without one ("when the turn comes from a Discord message, its id is provided per-turn…"). message_id presence is removed from _ephemeral_change_key, since it no longer changes the rendered bytes.

Related Issue

None found for voice turns. #126167 fixes the same message-id-presence re-key for synthetic goal/heartbeat continuations by preserving the pin. The two are complementary: that PR keeps the pin for events that shouldn't re-render; this one removes the message-id difference from the rendered prompt itself, and fixes the user-name difference, which #126167 doesn't touch. Both change _ephemeral_change_key, so whichever lands second needs a small rebase.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • gateway/run_voice.py: _voice_input_source uses the speaker's display name.
  • plugins/platforms/discord/adapter.py: DiscordAdapter.voice_member_display_name(guild_id, user_id).
  • gateway/session.py: the Discord "Triggering message" note no longer depends on message_id.
  • gateway/run_agent_cache.py: _ephemeral_change_key drops message-id presence.
  • tests/gateway/test_voice_session_prompt.py (new):
    • a voice turn and a text turn render byte-identical session prompts and change keys
    • the note doesn't depend on a message id
    • other speakers get their own name
    • fallbacks when the member isn't cached
    • the adapter lookup

How to Test

  1. pytest tests/gateway/test_voice_session_prompt.py tests/gateway/test_prompt_tail_freeze.py tests/gateway/test_internal_event_pin_wiring.py tests/gateway/test_voice_command.py -q
  2. Without the fix, 6 of the 7 new tests fail. Every test file that references the session-context prompt, the ephemeral pin or _voice_input_source passes (106), with no new failures against main (99).
  3. Manual: with a prefix-caching provider, send a text message in a channel bound to a voice session, then speak. The first voice call no longer re-prefills the conversation.

Checklist

Code

Documentation & Housekeeping

  • Docs, cli-config.yaml.example, CONTRIBUTING.md/AGENTS.md, tool schemas: N/A
  • Cross-platform impact: N/A

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working P0 Critical — data loss, security, crash loop comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) labels Sep 29, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review — #127944

Verdict: correct fix, cache-safe, ready once CI dispatches. Reviewed head 056a2a2; pushed two review-fix commits on top (below), so the PR head is now dce6efe. Merge decision stays with the maintainer.

Premise (verified on origin/main, real renderer): a voice turn and a text turn from the same person in the same channel render different build_session_context_prompt bytes at two lines:

-**User:** "Matt"
+**User:** "42"
-  - Triggering message: provided per-turn in the incoming user message (…)

On this head the two renders are byte-identical and _ephemeral_change_key matches, so the pinned prompt is reused across the switch instead of re-rendered. message_id was the only presence-gated input in the Discord note; nothing else in session.py reads it, so dropping it from the key is safe (the key invariant in test_prompt_tail_freeze.py still passes).

Tests: 81 passed / 0 failed across test_voice_session_prompt.py, test_prompt_tail_freeze.py, test_internal_event_pin_wiring.py, test_voice_command.py, test_discord_triggering_note_persistence.py. Red-on-base confirmed: with the three source files swapped to origin/main, 6 of 7 new tests fail, as the body claims.

Live test: renderer-level only (no Discord voice session on this host). The 24 s → 4 s numbers in the body are the contributor's.

Review-fix pushed (c28ab03): the bound _voice_sources entry is the /voice join message's source, message_id included, when the join was typed rather than issued as a native slash command. _voice_input_source rebuilt every voice turn from it, so voice turns carried the join message's id as their own trigger (run.py exports it as HERMES_SESSION_MESSAGE_ID), and on main that also set the presence bit — which is why the note-gating miss only showed up for native-slash joins. Cleared on the way out; test_voice_turn_renders_the_same_prompt_as_a_text_turn now binds a source that carries an id so the voice.message_id is None assertion is load-bearing (red without the line, green with it). dce6efe is an empty CI retrigger.

Suggestions (non-blocking):

  • voice_member_display_name: when the member cache misses (no members intent, or a speaker who never typed), fall back to self._client.get_user(int(user_id)) before giving up — User.display_name is what a text message from that person would carry too, and a speaker-only member is exactly the case the ID fallback still leaves cache-unstable. Inline note on the line.
  • Seven tests for a two-line prompt invariant is more than this repo keeps per fix (1–2 invariants); the byte-identity test and the fallback parametrize carry the contract, the adapter mock test and the change-key test are change-detectors on top. Fine to leave for the maintainer's call.

Looks good: message-id note stays out of the cached block (the run.py per-turn injection is untouched); same_user keeps the join invoker's bound name when the member lookup misses instead of regressing to the ID; the suppress(Exception) around the lookup keeps voice input alive if the adapter shape changes.

CI: as of 15:16Z every non-approval-gated ci.yaml dispatch in the repo is a zero-job startup_failure (several unrelated PRs in the same minutes), so the red on this head is a GitHub dispatch fault, not this diff. Needs a retrigger once dispatch recovers.

Comment thread plugins/platforms/discord/adapter.py Outdated
def voice_member_display_name(self, guild_id: int, user_id) -> Optional[str]:
"""Display name of a guild member, as text messages carry it (``author.display_name``)."""
guild = self._client.get_guild(int(guild_id)) if self._client else None
member = guild.get_member(int(user_id)) if guild else None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

guild.get_member is cache-only. Without the members intent (or for a speaker who has never sent a message since the bot connected) it returns None and the turn falls back to the bare ID — the same prompt-cache miss for that speaker. self._client.get_user(int(user_id)) covers most of those cases and its display_name is what message.author.display_name would carry for a user outside a guild context:

member = guild.get_member(int(user_id)) if guild else None
user = member or (self._client.get_user(int(user_id)) if self._client else None)
return getattr(user, "display_name", None) or None

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Supplemental review at head dce6efedfca2ae5960e05ed17325938bd6d10322

This pass supplements the existing review at this head. The fix itself reproduces: the mechanism, the RED/GREEN split, and the review-fix commit c28ab03 all hold up under independent execution. Three items below were not covered, and one of them must be handled when the second of the two related PRs lands, regardless of order. Nothing here disputes the existing review's conclusions.

Reproduced receipts (this host: Python 3.11.11, pytest 9.1.1, no custom HERMES_HOME, -p no:cacheprovider):

  • Head dce6efe, the five files listed in the PR's test instructions: 81 passed, 0 failed, 4 skipped. The author's RED claim also reproduces: with the three source files reverted to base (5eb1381f) and the new test kept, 6 of 7 new tests fail (only the friend-fallback case passes at base, where the fallback is also the ID).
  • Deletion mutation: removing source.message_id = None (gateway/run_voice.py:249) makes test_voice_turn_renders_the_same_prompt_as_a_text_turn fail — the review-fix line is load-bearing, verified by removal rather than by trusting the suite.
  • _ephemeral_change_key no longer keys on message-id presence while the test_prompt_tail_freeze.py invariant still passes (9/9): the note is now unconditional, and its remaining inputs (discord_tools, the id slots) are still in the key.

Findings

  • F1 (P2) — the #126167 collision is a test-text collision, not a key collision, and it is provable now. A local merge of both open heads is textually clean, but the union suite fails on an assertion that hardcodes the sentence this hunk rewrites. See the inline comment on gateway/session.py:336.
  • F2 (P2) — channel_prompt is a second parity input that voice turns still resolve without the thread's parent. Residual prompt divergence and cache miss for voice sessions joined from a thread whose parent carries a channel_prompts entry. See inline on gateway/run_voice.py:237.
  • F3 (P2) — the uncached-speaker fallback still renders the bare user ID where a text turn renders a name. Same defect class this PR fixes; reachable in ID-allowlist intent configurations; the existing inline suggestion on plugins/platforms/discord/adapter.py remains open and is load-bearing. See inline on gateway/run_voice.py:246.

CI state. The head still has no executed CI: ci.yaml run 36588496104 completed as a zero-job startup failure, and the Nix/Docker checks are action_required (fork approval pending). Dispatch elsewhere has since recovered — e.g. run 36606583588 created and ran 25 jobs at 17:40Z — so a retrigger of this head should now produce real signal. Untested boundary: nothing on dce6efe has executed in CI yet.

Prior items. The existing review's get_user fallback suggestion is unresolved at this head and is not cosmetic (F3 supplies the reachability model). Its seven-tests remark needs no action.

Comment thread gateway/session.py
# slash-command turns (no message id) render a different block from text turns, so every
# switch between talking and typing re-prefilled the whole conversation.
lines.append(
" - Triggering message: when the turn comes from a Discord message, its id is provided "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 — the #126167 collision is not where the description says it is, and it is provable now.

Both PRs are still open, and #126167's head (40d79b1e) does not modify _ephemeral_change_key at all: git log -S '_ephemeral_change_key' <current-main>..40d79b1e -- gateway/ is empty, its run_agent_cache.py diff touches _pinned_session_context_prompt / _pinned_channel_inputs (the internal → preserve_pin rename) with the key line (_eph_key = self._ephemeral_change_key(context, redact_pii)) present only as unchanged context, and session.py is untouched. The real collision is this sentence. #126167 adds assert "Triggering message: provided per-turn" in ephemeral[0] at tests/gateway/test_internal_event_pin_wiring.py:448, and this hunk rewrites exactly that phrase to "when the turn comes from a Discord message, its id is provided per-turn…".

Receipt (local, both heads at their current pins): git merge-tree --write-tree between dce6efe and 40d79b1e is clean (single tree 705cfb97…), but the union suite is not — with the two heads merged, test_non_internal_synthetic_event_preserves_all_prompt_pins fails:

E  assert 'Triggering message: provided per-turn' in '## Current Session Context\n\nTreat chat names, …'
tests/gateway/test_internal_event_pin_wiring.py:448: AssertionError

Either order requires action by the second lander: if #126167 lands first, this PR's rebase goes red in a test file this PR does not currently touch; if this PR lands first, #126167 rebases into the same failure. Git's mergeability check will not surface this — it is a test-semantics collision, not a textual one.

Required repair (either is sufficient): update the assertion in test_internal_event_pin_wiring.py when rebasing, or word the sentence so the old substring survives, e.g. keep "Triggering message: provided per-turn in the incoming user message" and append the Discord-conditional clause. Also worth tightening in the PR body: "Both change _ephemeral_change_key" — only this PR changes the key; the description of the collision shape matters to whoever plans landing order.

Regression contract: merge the two heads and run tests/gateway/test_internal_event_pin_wiring.py together with tests/gateway/test_voice_session_prompt.py; zero failures after the assertion update.

Comment thread gateway/run_voice.py
session), else a synthetic one."""
session), else a synthetic one. The speaker's name is the display name a text message from
them carries: the session-context system prompt renders it, so the bare user ID made every
switch between typing and talking re-prefill the whole conversation (prompt-cache miss)."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 — the parity argument has a second input: channel_prompt still resolves without the thread's parent on voice turns.

This docstring states the contract — the speaker's name is what a text message carries, and the bare ID made every talk/type switch re-prefill. The same class survives one function down. _handle_voice_channel_input resolves the per-channel prompt as resolver(str(text_ch_id)) (gateway/run_voice.py:299, no parent argument), while text turns resolve self._resolve_channel_prompt(_chan_id, _parent_id or None) (plugins/platforms/discord/adapter.py:6185-6187), and resolve_channel_prompt's documented behavior is "threads inherit the parent prompt" (gateway/platforms/base.py:1810).

If /voice join is issued inside a thread whose parent carries a channel_prompts entry, the text turn's ephemeral system prompt includes the parent hint and the voice turn's does not — the two renders differ, so the talk/type switch still re-prefills in exactly the configuration the prompt-hint feature is for.

Receipt (head dce6efe, probe):

resolve_channel_prompt({"channel_prompts": {PARENT: "Parent persona hint."}}, THREAD, PARENT)  # -> "Parent persona hint."
resolve_channel_prompt({"channel_prompts": {PARENT: "Parent persona hint."}}, THREAD)          # -> None  (voice path)

The new byte-identity test only covers chat_type="group" with no parent id, so nothing in the suite exercises the thread shape.

Required repair: pass the bound source's parent_chat_id (it survives SessionSource.to_dict/from_dict) into the resolver — resolver(str(text_ch_id), getattr(source, "parent_chat_id", None)) — and extend test_voice_turn_renders_the_same_prompt_as_a_text_turn with a thread-shaped source carrying the parent id.

Regression contract: the thread-shaped render equality fails before the change and passes after; the existing group-shaped test stays green.

Comment thread gateway/run_voice.py
source.user_id = source.user_name = str(user_id)
same_user = source.user_id == str(user_id)
source.user_id = str(user_id)
source.user_name = name or (source.user_name if same_user else None) or str(user_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 — the raw-ID fallback is the same defect class this PR fixes, and it is reachable in the common intent configuration (open item from the existing review's inline suggestion).

name or (bound name if same_user) or str(user_id) writes the bare ID into source.user_name whenever guild.get_member() misses and the speaker is not the join invoker. build_session_context_prompt then renders **User:** "42" where a text message from the same person renders **User:** "Sam" — the exact divergence this PR exists to remove, because text-turn names come from message.author.display_name (plugins/platforms/discord/adapter.py:6135), which is User.display_name (global name or username) even for uncached users.

Reachability: Hermes requests intents.voice_states unconditionally but intents.members only when username/role allowlists need it (plugins/platforms/discord/adapter.py:1293-1302, _needs_server_members_intent). With only the voice-states intent, discord.py's MemberCacheFlags.from_intents sets the voice flag and not joined, and parse_voice_state_update caches a member only when it observes a voice-state event — so a speaker already connected when the bot joined, or one who never toggles mute/deafen afterwards, stays uncached and takes the str(user_id) path.

Evidence (head dce6efe, probe): GatewayVoiceMixin._voice_input_source on a bound source with the member lookup returning None yields user_name == str(user_id) and a session-context render that differs from the text render for the same person. Limits: with the Server Members intent enabled, login chunking usually caches the whole guild and this path is rare — the affected population is ID-based allowlist deployments, which are the configurations that skip the members intent by design.

Required repair (matches the existing inline suggestion at adapter.py:3739): fall back to self._client.get_user(int(user_id)) before the ID —

member = guild.get_member(int(user_id)) if guild else None
user = member or (self._client.get_user(int(user_id)) if self._client else None)
return getattr(user, "display_name", None) or None

— and mirror the same fallback in the synthetic branch at gateway/run_voice.py:253.

Regression contract: a member-cache miss for a non-invoker renders the same **User:** line as that person's text turn; the bound-name fallback for the join invoker is unchanged.

andrexibiza commented Sep 30, 2026 •

Copy link
Copy Markdown

The advertised text/voice parity is still incomplete at dce6efedfca2ae5960e05ed17325938bd6d10322. Teknium's review says “correct fix, cache-safe, ready once CI dispatches.” The two narrow renderer repairs and the follow-up trigger-ID repair are valid. The broader readiness conclusion is unsupported: independent producer-to-consumer probes leave nine unresolved finding groups, covering twelve failing cases, alongside two passing controls. Seven of those groups, F4–F10, extend the earlier review. Dispatching CI does not repair these counterexamples.

Clickable subsystem diagrams: Open the interactive viewer. Select any node to open its subsystem at the reviewed commit. The native Mermaid diagrams below also link directly from their nodes to pinned code and evidence.

This is a reconciliation of the actual implementation and claims, including corrections to my own supplemental review. Its sentence “Nothing here disputes the existing review's conclusions” was too broad. The narrow repair reproduces; the unconditional parity/readiness conclusion does not. F1 has since been repaired in the current sibling head and is retired below. My earlier F3 explanation that a speaker already connected at login necessarily remains uncached was also wrong; the corrected SDK analysis is below. Neither correction removes the independently reproduced residual failures.

All repository code links below are pinned to the reviewed head; SDK links are pinned to the repository's declared SDK version. These are conditional P2 findings under the stated configurations and schedules, not nine newly introduced regressions or nine claims about every voice turn. The base replay and source comparison identify defects left unresolved by this patch. Findings outside the prompt-cache mechanism are explicitly identified as adjacent routing, admission, or authorization defects. The related issue's P0 label is not imported into this review.

Claim-by-claim adjudication

ID Claim or fact being adjudicated Disposition Evidence and exact limit
C1 Original voice/text session-context renders differed in user display and the conditional triggering-message note. Supported. Real renderer controls and the original seven-test base overlay reproduce the old behavior. This establishes the motivating defect, not universal post-fix parity.
C2 The display-name helper gives voice the same name as text. Partial. The guild-member-hit group control is green. voice_member_display_name only consults guild.get_member; the available-global-user miss in F3 remains red. Native thread labels and stale channel metadata independently differ in F4/F6.
C3 The unconditional Discord note permits removing message-ID presence from _ephemeral_change_key. Supported for this renderer input. The note no longer branches on message-ID presence; the key retains the remaining note/render inputs. The existing prompt-tail invariant passes 9/9. Per-turn trigger handling remains separate. This does not prove every effective prompt input is equal.
C4 c28ab03 correctly clears the typed /voice join message's ID from later voice turns. Supported and load-bearing. Removing only source.message_id = None from the bound-source path produces 1 failed / 6 passed in the original seven tests. A join message's ID is not a subsequent utterance's trigger.
C5 same_user preserves the join invoker's name, and lookup exceptions do not immediately kill source construction. Supported, with a freshness limit. The implementation has both protections. A bound-name fallback is a snapshot, not proof that it matches later guild display metadata. Preserving this fallback is necessary when composing related work.
C6 “On this head the two renders are byte-identical” and the prompt is reused across the switch. Supported for the selected group/member-hit case; disproved as a general contract. F2/F3/F4/F6 each yield two effective prompt byte strings and two actual _agent_config_signature values in a text → voice → text sequence. F2 even retains one context change key while the separate channel-prompt input changes.
C7 Five named test files have 81 passed / 0 failed. Independently reproduced: 81 passed, 0 failed, 4 skipped. The five-file review selection is listed in the replay instructions. The PR body's command names four files; the review adds test_discord_triggering_note_persistence.py. Green selection coverage is not whole-class coverage.
C8 Six of seven new tests fail on base. Independently reproduced: 6 failed, 1 passed. Retained the original new tests while restoring the four changed production files to base. One failure checks the newly added adapter API's existence; six red tests do not establish six distinct bugs.
C9 The body's wider 106-pass / 99-on-main comparison establishes all relevant tests are green. Not independently established here. The exact wider selection was not supplied. The 81-test review selection neither verifies nor disproves that different aggregate. The twelve added counterexamples establish missing coverage regardless of that historical count.
C10 Reported 24 s / 90,294-token prefill versus 3.2 s text, 11–70 s voice, then 4.3 s at 113K / 4.7 s at 125K after the fix. Contributor measurements, not independent measurements. The existing review correctly attributes them. The body also reports the same cached agent serving both turn types. This replay measures actual local prompt bytes/signatures and turn behavior; it does not independently verify that live agent reuse, a live Discord session, remote prefix-cache hit rate, latency, or re-prefill token count.
C11 The remaining member-cache fallback is “non-blocking,” and get_user covers the relevant misses. The parity implication is contradicted; the proposed remedy is partial. F3 ignores an available get_user display name and returns the ID. A global user may itself be absent and cannot generally supply a guild nickname. The earlier universal no-members-intent/initial-speaker explanation is withdrawn.
C12 Seven tests are more than this repository normally keeps for a two-line invariant. Reviewer preference; no required deletion. Test count is not a correctness argument. These additional tests exercise missing producers, state lifetimes, and consumer boundaries. Removing the current tests would not repair any finding.
C13 #126167 and #127944 both change _ephemeral_change_key and therefore necessarily need a small rebase. The specific key-overlap claim is false at the compared heads. #126167 changes pin ownership/wiring, not that function. Shared files do overlap, but the current heads merge textually cleanly. F1's old prose assertion is gone; the current two-file union is 21 passed / 0 failed.
C14 “None found for voice turns” / no related issue history. Incomplete related-history statement, not proof of a dishonest search. #50149 predates this PR and discusses voice channel prompts, including parent inheritance. It was closed completed on July 28. #128787 is later related evidence and cannot falsify the contributor's earlier search attestation.
C15 A change in user_name changes both **User:** and the guild-bound **Source:** description. Documentation overstatement for group/thread sources. SessionSource.description uses the chat label for non-DM sources. The user display changes the User block; guild Source differences in F4/F6/F7 arise from channel/type metadata. DM descriptions do use the user label.
C16 Once CI dispatches, the PR is ready. Not established. At this head, CI run 36588496104 is completed/failure with zero jobs. Nix and Docker are completed/action_required. This supplies no passing head-CI result and no executed test failure attributable to the diff. Local counterexamples remain independent of dispatch.
C17 The existing supplemental review's F1–F3 and broad endorsement all remain valid unchanged. Reconciled, not carried forward unchanged. F1 is retired at current #126167. F2 remains independently reproduced with actual ingress/pins/signatures. F3's available-name loss remains, while its earlier SDK reachability rationale and the broad endorsement are corrected here.
C18 Checklist: contributing guide read, commit format, related-PR search, platform testing, AI attribution, documentation/cross-platform N/A. Attestations or scope statements, not independent functional evidence. The five changed files and seven added tests are observable. The body explicitly says the full suite was not run; this review also makes no full-suite claim. Debian/vLLM platform results and personal compliance attestations are not independently verified by this host.

Checked September 30, 2026. The current REST PR metadata reports mergeable: true and mergeable_state: blocked. That is a mechanical/status result, not a functional approval. The recorded base is 5eb1381f3965fd96454d1c32f6bbefcd99cda347; the original patch head was 056a2a2434260ef47e3149d6e9ea87b9e775c002, the substantive review fix was c28ab03fe6f61ffbbebf6787e5e569a27cf2afe3, and dce6efe is an empty CI retrigger with the same tree as c28ab03. A retrigger commit provides no additional behavioral repair.

Finding reconciliation: trigger → actual producer → failing consumer

The counterexamples use explicitly authorized fake users Alice/42 and Sam/43, guild 10, text channels 700/701, and thread 800. Ordinary typed turns traverse the real Discord message-admission and message-building path. Voice turns traverse the real gateway source builder and admission path. The observed values below come from those producers, not hand-authored SessionSource objects intended to agree by construction.

Finding Exact condition and subsystem Observed result versus required behavior Adjudication and source
F1 — retired Prior #126167 head hardcoded the old triggering-note sentence. Current #126167 head ee67e13c132469b6b4085b4dbe68c57fef599c76 removed the brittle assertion while retaining the byte-equality invariant. Current composition: 21 passed / 0 failed. Historical integration issue repaired. Do not continue counting it as an active blocker.
F2 — parent channel prompt Join from a thread whose parent has a YAML channel_prompts entry. Typed hint = Parent hint; voice hint = None despite voice retaining parent_chat_id=700. Two combined prompt byte strings and two agent signatures; one context change key. Unresolved prompt-input parity. Text passes the parent; voice passes only the thread ID. This is not loss of all parent metadata or all override inheritance.
F3 — available speaker name discarded Non-invoker Sam speaks; guild.get_member(43) misses while client.get_user(43).display_name == "Sam" is available. Typed User = Sam; voice User = 43. Same conversation key, two context keys, two effective prompts/signatures. Unresolved display parity on a specified cache miss. Guild-only lookup plus ID fallback. A global-name fallback repairs this case; it is not a universal guild-nickname solution.
F4 — native thread join label Native slash /voice join inside a thread; no channel hints are configured. Actual typed source = Guild / #ops / incident; slash-bound voice source = Guild / #incident. Same conversation key, two context keys/prompts/signatures. Unresolved producer mismatch. Slash source construction differs from ordinary text construction. F2 is deliberately absent from this probe.
F5 — voice-first bound skill A new/reset session's first agent turn is voice; skill binding is direct or inherited from the thread's parent. Typed auto_skill=["recon-marker"] and actual model-boundary payload contain RECON_SKILL_127944. Voice auto_skill=None; payload lacks the marker in both cases. Two failing cases in one unresolved skill-dispatch group. Text resolves channel/parent skills; voice event omits auto_skill; new-session preparation consumes that field. An already initialized session is a different condition.
F6 — join-time channel snapshot reused indefinitely Rename the bound text channel or change its topic after join; metadata then remains stable while typing/talking alternates. Rename: typed Guild / #renamed-ops versus voice Guild / #ops. Topic: typed Updated topic versus voice Original topic. Each case has two context keys/prompts/signatures. Two failing cases in one unresolved metadata-lifetime group. Join stores a serialized source; voice reuses it; text reads current metadata. Repeated fresh/stale flips are not the legitimate one-time invalidation caused by a rename.
F7a — source-less programmatic join Supported join_voice_channel(vc, text_channel_id=700) cold join, with the optional source omitted. Actual typed key = agent:main:discord:group:700:42; voice key = agent:main:discord:channel:700:42. Voice also lacks guild scope/name. Unresolved binding invariant: different sessions, not merely a prefix miss. Optional-source join API, synthetic fallback, session key. This finding is explicitly about the supported API; no unverified in-repo caller is asserted.
F7b — successful hot join ignores explicit new binding Programmatic join is fully bound at 700, then the same connected voice channel is joined with explicit text_channel_id=701 and the new source. Cold fully bound control routes correctly. Hot API returns True, but bound/dispatched chat stays 700. Second case of the same binding-invariant group: early return precedes applying both arguments. The ordinary slash runner writes bindings after the API returns, so this is not a claim that every slash rebind fails.
F8 — in-flight STT rehomes old speech Start conversion/STT while linked to 700; successfully rebind to 701 while STT awaits; release STT. Actual callback dispatches the utterance captured under 700 into chat 701. Adjacent unresolved async routing/provenance defect. Callback carries only guild/user/transcript; runner reads the later mutable binding. A capture-bound conversation or explicit obsolete-generation cancellation is required.
F9 — duplicate window crosses conversations Same authorized speaker repeats the same request after rebinding from 700 to 701 within the 12-second duplicate window. Initial turn accepted; unchanged-binding duplicate control correctly suppressed; valid new-conversation turn accepted = 0. Adjacent unresolved admission-scope defect. Recent history is keyed by guild/user, not canonical conversation/binding or transport. This is a separate schedule from F8.
F10 — guild-role authorization lost User has configured guild role 55, with no user-ID grant, pairing grant, or allow-all grant. Actual text admission and voice intake both approve that speaker. Text role_authorized=True and real gateway authorization pass. Voice source rebuild has False; actual voice loop/STT callback yields 0 accepted turns at the gateway gate. Adjacent unresolved authorization-provenance/availability defect. Serialization omits the transient role decision; voice does not recompute it; real gateway policy checks it. The omission from serialized data is intentional; copying the join invoker's grant to every speaker is not a repair.

F2 matters even when _ephemeral_change_key is unchanged. Context pins and channel-input pins are separate; _combined_ephemeral_prompt joins the session context, channel hint, and effective channel override; _agent_config_signature includes that combined prompt. The measured F2 result is exactly one context key, two effective prompts, two agent signatures. A renderer-only identity test or a session-context-key identity assertion cannot establish identity of the entire system input.

F4 is a source-label mismatch even when both speaker names are correct. F5 changes the first-turn skill payload even if the context renderer is perfectly stable. F7 can select a different conversation entirely. F8 sends a completed utterance to a later conversation. F9 prevents a valid new-conversation turn from reaching the consumer. F10 loses a valid permission decision between two real admission gates. These are distinct consumers; counting only prompt-render assertions misses them.

Open this diagram with clickable subsystem nodes

%%{init: {"theme":"dark","themeVariables":{"darkMode":true,"background":"#090d14","primaryTextColor":"#f8fafc","lineColor":"#94a3b8","clusterBkg":"#0f172a","clusterBorder":"#475569","edgeLabelBackground":"#111827"}}}%%
flowchart TD
 subgraph CANVAS[" "]
 direction TB
    subgraph FLOW["Flow"]
      direction TB
    T["Typed admission"]
    M["Current metadata"]
    S["Native slash join"]
    J["Bound source"]
    P["Programmatic join"]
    X["Incomplete binding: F7"]
    V["Voice reconstruction"]
    TE["Text event"]
    VE["Voice event"]
    L["Thread label: F4"]
    N["Stale snapshot: F6"]
    U["Name miss: F3"]
    C["Conversation key"]
    I["Prompt pins"]
    H["Parent hint: F2"]
    A["Agent signature"]
    K["First-turn skills"]
    Z["Missing skill: F5"]
    T --> M
    S --> J
    P --> J
    P --> X
    J --> V
    M --> TE
    V --> VE
    S -.-> L
    L -.-> VE
    M -.-> N
    N -.-> V
    V -.-> U
    U -.-> VE
    TE --> C
    VE --> C
    X --> C
    TE --> I
    VE --> I
    VE -.-> H
    H --> I
    I --> A
    TE --> K
    VE --> K
    VE -.-> Z
    Z --> K
 click T "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L6120-L6142" "Pinned implementation"
 click M "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L6120-L6142" "Pinned implementation"
 click S "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L4699-L4740" "Pinned implementation"
 click J "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L148-L185" "Pinned implementation"
 click P "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3592-L3629" "Pinned implementation"
 click X "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3592-L3629" "Pinned implementation"
 click V "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L233-L259" "Pinned implementation"
 click TE "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L6183-L6199" "Pinned implementation"
 click VE "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"
 click L "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L4699-L4740" "Pinned implementation"
 click N "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L233-L259" "Pinned implementation"
 click U "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3736-L3740" "Pinned implementation"
 click C "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/session.py#L684-L725" "Pinned implementation"
 click I "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_agent_cache.py#L665-L716" "Pinned implementation"
 click H "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"
 click A "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_agent_cache.py#L98-L139" "Pinned implementation"
 click K "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_turn.py#L2042-L2077" "Pinned implementation"
 click Z "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"

    end
LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Amber: transition / pending<br/>Red: failure"]

    classDef entry fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px;
    class T,S,P,TE,VE entry;
    classDef authority fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px;
    class C,I,A,K authority;
    classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px;
    class M,J,V transition;
    classDef failure fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px;
    class X,L,N,U,H,Z failure;
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px

 style FLOW fill:#0f172a,stroke:#475569,color:#f8fafc
    FLOW ~~~ LEGEND

 end
 style CANVAS fill:#090d14,stroke:#090d14,color:#f8fafc
Loading

The graph separates conversation identity, prompt reuse, and skill preparation. Equality at one sink does not establish equality at the other two.

The F3 cache model needs a correction, not another unsupported certainty. The repository pins discord.py[voice]==2.7.1 in pyproject.toml. In that SDK, initial guild construction loads initial voice states before members and can cache members who are already in voice under the voice cache flag. MemberCacheFlags distinguishes voice and joined caching. Therefore “no members intent” or “already connected and never toggled mute” alone is not proof of a missing guild member. That part of the earlier inline explanation is withdrawn.

The narrower failure is verified: when the guild lookup actually misses and a usable global display name exists, this implementation ignores it. Message author handling can obtain author/member information from message payloads. Member display names prefer guild nicknames, then global names, then usernames. Also, no-members state initialization can replace user storage with a non-caching factory, so get_user is not guaranteed to populate or reproduce a guild nickname. This is source-level SDK verification and a controlled cache-miss replay, not a claim that a particular live cache-expiry sequence was measured. “Non-blocking” is inconsistent with an unconditional display-parity contract; “add get_user” is useful but insufficient to promise universal parity.

Metadata lifetime and capture lifetime must be repaired separately.

State Correct owner and lifetime Current failure Required invariant
Conversation binding, receiving transport/profile, and binding generation Capture-time immutable provenance through conversion, STT, and dispatch. Rebinding creates a new generation. F7 has incomplete/ignored bindings; F8 reads a later binding after an await. Every accepted utterance belongs to its captured conversation, or is explicitly cancelled as obsolete. The receiving adapter remains attached to that provenance.
Channel/thread presentation fields and topic One canonical current metadata policy shared by text, slash, and voice for the chosen conversation. F4 constructs different labels; F6 freezes presentation at join while text refreshes it. Once metadata is stable, modality switches do not alternate current and stale labels/topics. A genuine metadata change may legitimately invalidate the prompt once.
Speaker ID and display metadata ID identifies the actual speaker; guild display metadata uses the same authority as text, with an explicit degraded policy when unavailable. F3 discards available metadata; a join-time name is only a fallback snapshot. The chosen display policy is consistent across ingress; missing guild information is not silently treated as proof of equality.
Role authorization Current receiving-bot policy for the actual speaker, rechecked at admission; transient trusted decision. F10 approves at voice intake, drops the decision through serialization, then denies at the runner. Authorized speakers pass; removed/other-user roles do not inherit a persisted grant. Recompute the actual speaker's role decision; never serialize or copy the join invoker's authority as the repair.
Channel hint and bound skills Channel and parent resolution using the event's conversation; skill injection follows the new/reset-session contract. F2 omits parent prompt resolution; F5 omits voice skill selection. Effective hint and first-turn skill payload match the same conversation's text behavior. Preserve inherited channel_overrides.system_prompt behavior.
Duplicate suppression Canonical conversation, receiving transport, and relevant binding/utterance identity within the duplicate window. F9 uses only guild/user, crossing conversations. Suppress actual duplicate delivery; accept a new utterance in a different bound conversation.
Triggering message ID Current event only, outside the stable prompt's presence discriminator. c28ab03 repairs the inherited join-ID error. Voice has no borrowed join-message trigger; changing ID presence alone does not change stable prompt bytes. Preserve this repair.

Freezing every field at join would preserve F6's stale presentation and F10's stale permission risk. Refreshing the conversation binding after STT would preserve F8's misrouting. A coherent repair must retain captured routing provenance while applying the shared presentation and current authorization policies to the actual speaker/conversation.

Open this sequence with clickable subsystem participants

%%{init: {"theme":"dark","themeVariables":{"darkMode":true,"background":"#090d14","primaryTextColor":"#f8fafc","lineColor":"#94a3b8","clusterBkg":"#0f172a","clusterBorder":"#475569","edgeLabelBackground":"#111827"}}}%%
flowchart TD
 subgraph CANVAS[" "]
 direction TB
    subgraph FLOW["Flow"]
      direction TB
 A["1 · Capture utterance under chat 700"] --> B["2 · STT awaits transcription"]
 B --> C["3 · Speaker asks join handler<br/>to rebind to chat 701"]
 C --> D["4 · Mutable binding is now 701"]
 D --> E["5 · STT returns guild, user,<br/>transcript only"]
 E --> F["6 · Voice runner reads<br/>current binding 701"]
 F --> G["7 · Session pipeline receives<br/>old utterance in chat 701"]
 G --> H["F8: capture-time conversation<br/>provenance is lost"]
 click A "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3856-L3907" "Pinned implementation"
 click B "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3856-L3907" "Pinned implementation"
 click C "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L148-L185" "Pinned implementation"
 click D "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L148-L185" "Pinned implementation"
 click E "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3856-L3907" "Pinned implementation"
 click F "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"
 click G "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"
 click H "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Implementation related to the published observation"

 EVIDENCE0["Existing review / source context"]:::context
 click EVIDENCE0 href "https://github.com/NousResearch/hermes-agent/pull/127944#issuecomment-5917833745" "Preserved source context"
 classDef context fill:#1e293b,stroke:#94a3b8,color:#f8fafc,stroke-width:2px
    end
LEGEND["Legend<br/>Blue: entry / incoming<br/>Amber: transition / pending<br/>Red: failure<br/>Gray: context / transient"]

    classDef entry fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px;
    class A entry;
    classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px;
    class B,C,D transition;
    classDef failure fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px;
    class E,F,G,H failure;
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px

 style FLOW fill:#0f172a,stroke:#475569,color:#f8fafc
    FLOW ~~~ LEGEND



 end
 style CANVAS fill:#090d14,stroke:#090d14,color:#f8fafc
Loading

This replay uses a deterministic STT barrier, an actual successful join-handler rebind, actual PCM-to-WAV conversion, and the production callback/dispatch path. It does not require a speculative interleaving inside the model. Retaining the callback object alone is insufficient because the callback reads mutable routing state.

Open this diagram with clickable subsystem nodes

%%{init: {"theme":"dark","themeVariables":{"darkMode":true,"background":"#090d14","primaryTextColor":"#f8fafc","lineColor":"#94a3b8","clusterBkg":"#0f172a","clusterBorder":"#475569","edgeLabelBackground":"#111827"}}}%%
flowchart TD
 subgraph CANVAS[" "]
 direction TB
    subgraph FLOW["Flow"]
      direction TB
    R["Guild-role grant"]
    T["Text admission"]
    V["Voice intake"]
    TT["Role grant present"]
    ST["PCM conversion and STT"]
    J["Serialized source"]
    RB["Voice rebuild"]
    VF["Role grant lost"]
    G{"Gateway authorization"}
    A["Text admitted"]
    D["Voice dropped: F10"]
    R --> T
    R --> V
    T --> TT
    V --> ST
    J --> RB
    ST --> RB
    RB --> VF
    TT --> G
    VF --> G
    G -->|Text grant| A
    G -->|Voice has no other grant| D
 click R "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/authz_mixin.py#L629-L675" "Pinned implementation"
 click T "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L6120-L6142" "Pinned implementation"
 click V "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3856-L3907" "Pinned implementation"
 click TT "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L6120-L6142" "Pinned implementation"
 click ST "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/plugins/platforms/discord/adapter.py#L3856-L3907" "Pinned implementation"
 click J "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/session.py#L124-L160" "Pinned implementation"
 click RB "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L233-L259" "Pinned implementation"
 click VF "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/session.py#L124-L160" "Pinned implementation"
 click G "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/authz_mixin.py#L629-L675" "Pinned implementation"
 click A "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/authz_mixin.py#L629-L675" "Pinned implementation"
 click D "https://github.com/NousResearch/hermes-agent/blob/dce6efedfca2ae5960e05ed17325938bd6d10322/gateway/run_voice.py#L261-L307" "Pinned implementation"

    end
LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Amber: transition / pending<br/>Green: success<br/>Red: failure"]

    classDef entry fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px;
    class T,V entry;
    classDef authority fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px;
    class R,TT,G authority;
    classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px;
    class ST,J,RB transition;
    classDef success fill:#052e16,stroke:#4ade80,color:#f0fdf4,stroke-width:2px;
    class A success;
    classDef failure fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px;
    class VF,D failure;
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px

 style FLOW fill:#0f172a,stroke:#475569,color:#f8fafc
    FLOW ~~~ LEGEND

 end
 style CANVAS fill:#090d14,stroke:#090d14,color:#f8fafc
Loading

The repair is to recompute the current guild-role decision for the actual voice speaker under the receiving adapter's policy before the gateway gate. Treating voice as internal, bypassing the gate, or persisting another speaker's role grant would violate the intended authorization boundary.

Repair obligations and tests that actually distinguish fixed from unfixed behavior

Finding Subsystem repair required Meaningful regression contract
F2 Pass the bound conversation's parent ID into voice channel-prompt resolution. Real typed/join/voice text → voice → text sequence with inherited YAML channel_prompts: equal effective bytes/signatures. Retain the inherited channel_overrides.system_prompt passing control.
F3 Share a canonical speaker-display resolution policy with text; consume usable cached global metadata on a guild miss and explicitly handle missing/nickname-only metadata. Guild-hit control; non-invoker guild miss with available global name; same-user bound fallback; missing metadata and guild-nickname policy. Do not call a global fallback universally equivalent.
F4 Share canonical thread-label formatting across ordinary messages and native slash source construction. Native thread /voice join produces the same session source label and effective prompt as text. Cover text/forum thread variants and isolate hints from the label invariant.
F5 Resolve channel skills with both channel and parent, and populate voice MessageEvent.auto_skill. New/reset voice-first direct-channel and inherited-thread skill cases reach the model boundary with the real marker skill loaded; typed controls remain green.
F6 Refresh presentation metadata for the captured text conversation through a shared metadata builder, without mutating captured routing identity. Rename/topic changes invalidate once; after stabilization, repeated text/voice switches retain one effective prompt/signature.
F7 Establish a complete canonical bound source for supported programmatic joins; apply explicit routing/source arguments on cold, hot, and moved joins under the binding lock. Source-less supported join preserves group/thread session identity; fully bound cold control passes; hot explicit rebind and moved-channel cases apply both arguments. Preserve normal slash-runner binding behavior.
F8 Carry immutable capture binding plus generation across STT, or explicitly cancel obsolete-generation utterances. Barrier-controlled rebind during STT retains the captured conversation or implements the documented cancellation policy; it never dispatches old speech into a new conversation. Keep current authorization checks.
F9 Scope duplicate history to the canonical conversation/transport/binding, and actual utterance identity when available. Genuine same-binding duplicate remains suppressed; same words in a newly bound conversation are admitted.
F10 Recompute current actual-speaker role authorization at voice admission, through the receiving transport's policy; keep the trusted decision transient. Role-only authorized speaker reaches the agent; revoked role and a different unauthorized speaker do not inherit permission. Test the actual intake and gateway gates together.

These are repair obligations, not an assertion that an unimplemented patch has passed. F4–F10 are seven additional finding groups; the parameterized skill and metadata cases and the two programmatic-join cases explain the twelve failures without inflating the bug count.

Related-work reconciliation

Related work and pinned head What is established What remains / composition requirement
#126167, ee67e13c132469b6b4085b4dbe68c57fef599c76 Actual merge-tree composition with this head is clean: tree a3811b1ab49fd211bbb893e96e4f386678854f7c. All twelve sibling-changed file blobs in the composed checkout match that tree. Current test_voice_session_prompt.py + test_internal_event_pin_wiring.py: 21 passed, 0 failed. F1 retired. This verifies the selected current integration boundary, not the full sibling suite or all repository merge readiness. The body's “both change the key” explanation should be corrected.
#128788, c2727ba500f22249dd729aba6365af3bb3c5e970, open Source proposes native thread/DM slash-label corrections and voice auto_skill population, overlapping F4 and direct F5. Both voice prompt and skill resolvers still receive only the text ID, so parent inheritance remains incomplete. Its overlapping voice-source implementation also lacks this PR's message-ID clear and same-user bound-name fallback. Composition must preserve those repairs and add parent arguments. No independently green union with #128788 is claimed.
#128787, open Related report of slash/thread/voice skill loss and pinned-context flips. Corroborating issue/report, not a substitute for the independent counterexamples and not proof every proposed repair is complete.
#50149, closed completed July 28 Earlier voice channel-prompt issue explicitly includes parent/thread inheritance in the expected repair. Closure status does not establish that the remaining parent case is fixed at this head; F2 directly demonstrates it is not.

The two passing controls matter. The ordinary group/member-hit case has one effective prompt, one agent signature, one context key, and clears the join message ID. Separately, inherited channel_overrides.system_prompt reaches both typed and voice turns. F2 is specifically inherited YAML channel_prompts loss; claiming that every parent override is lost would be incorrect.

Executable receipts and their limits

Lane Result What this proves / does not prove
Current head, five original review files 81 passed, 0 failed, 4 skipped Independently confirms the selected narrow repairs. The four skipped tests are not counted as executed successes.
Base production-file overlay, original seven new tests retained 6 failed, 1 passed Confirms the motivating RED/GREEN split, including the API-presence test. It is not six separate defect classes.
Current head, deletion of only source.message_id = None in an isolated copy 1 failed, 6 passed Confirms the review-fix line is exercised and necessary.
Current head, independent probe below 12 failed, 2 passed F2–F10 produce the tabled outcomes through the described production boundaries; group and inherited-override controls pass.
Base production-file overlay, same independent probe 13 failed, 1 passed The group control becomes red too; inherited override remains green. Source comparisons identify the residual paths already present on base. These are unresolved defects, not twelve new regressions introduced by this diff.
Current #126167 + this head, selected integration files 21 passed, 0 failed F1 is fixed at the current sibling; no obsolete assertion is carried forward as an active finding.
Current head CI CI failure with 0 jobs; Nix/Docker action_required No executed passing head-CI lane. Zero jobs do not identify a code-test failure.
Live Discord SDK, provider cache/latency, activated Nix shell, repository-wide suite Not executed in this replay SDK statements are pinned source verification; latency remains contributor-attributed. No whole-suite or provider-prefill guarantee is made.

Host replay: Python 3.14.7, pytest 9.1.1, openai 2.24.0, matching the repository's Python ≥3.14 OpenAI pin. Used the canonical file-isolated scripts/run_tests.sh runner. The local checkout was reduced by unavailable unrelated UI/translation blobs; the tested Python/import closure and the four changed production files were hydrated and verified against the exact GitHub head blobs. This is a bounded, source-verified Python replay, not a claim of a complete checkout or activated Nix environment.

Changed source Git blob at reviewed head
gateway/run_voice.py 6780ca88d795ecfbe51d3283f6021d3e061d738b
gateway/run_agent_cache.py 10a0e108b11e5c752f2b588624814e52f9d9fc75
gateway/session.py 8d335c5da1926a208e4e9ae3a98865b1ffd58739
plugins/platforms/discord/adapter.py 715fb621d34b5275c71ffb9ab9efd0b93842c2b5
tests/gateway/test_voice_session_prompt.py de3ce0e3c5a13f0da241ff7748020252550a264b

The probe uses the repository's existing Discord stub and fake transport objects; no real Discord client or remote model is contacted. Real code executes message admission, event construction, source reconstruction, gateway authorization, session-key building, context/channel pins, combined prompt assembly, agent signature generation, and new-session skill loading. F8/F10 additionally execute the real PCM-to-WAV conversion with ffmpeg; only transcription results/timing are controlled at the provider boundary. Config/capability lookups are held constant, and model calls are captured at the existing test boundary. The role-only test removes the ordinary ID grants and uses the production authorization implementation. Failures are the stated behavioral assertions, not import, collection, or unauthorized fixture-precondition failures.

The original five-file selection can be replayed from the reviewed head with the repository interpreter:

HERMES_PYTHON=/path/to/repository/python scripts/run_tests.sh \
  tests/gateway/test_voice_session_prompt.py \
  tests/gateway/test_prompt_tail_freeze.py \
  tests/gateway/test_internal_event_pin_wiring.py \
  tests/gateway/test_voice_command.py \
  tests/gateway/test_discord_triggering_note_persistence.py \
  -j 2 -q -- --tb=short -p no:cacheprovider

For the original base comparison, use an isolated copy at the reviewed head, restore gateway/run_voice.py, gateway/run_agent_cache.py, gateway/session.py, and plugins/platforms/discord/adapter.py from 5eb1381f3965fd96454d1c32f6bbefcd99cda347, retain the new tests, and run test_voice_session_prompt.py. For the mutation, use another isolated head copy and remove only the message-ID clear. For the current #126167 composition, combine the two pinned heads and run test_voice_session_prompt.py plus test_internal_event_pin_wiring.py through the same file-isolated runner.

The evidence supports the narrow fix and contradicts the complete-fix claim. The selected 81 green tests and the twelve production-shaped counterexamples are simultaneously true. The missing parent hint changes the agent signature even when the context key is stable; other cases change presentation, first-turn skills, conversation routing, duplicate admission, or authorized access. Maintainer and contributor verdicts need the same producer-to-consumer evidence. “Ready once CI dispatches” does not account for these unresolved conditions.

Runnable independent counterexamples: 12 failing cases, 2 passing controls

Save the following exact file as tests/gateway/test_pr127944_reconciliation_probe.py in an isolated checkout at dce6efedfca2ae5960e05ed17325938bd6d10322. Use the repository's test dependencies and ffmpeg. The expected current-head result is 12 failed, 2 passed; these are intentional behavioral oracles against the unfixed conditions, with passing preconditions/controls. The same file with the base production overlay yields 13 failed, 1 passed.

HERMES_PYTHON=/path/to/repository/python scripts/run_tests.sh \
  tests/gateway/test_pr127944_reconciliation_probe.py \
  -j 1 -s -q -- --tb=short -p no:cacheprovider

File SHA-256: 0c77d23558498338fa1464b46f6bd25170b58b11d262dc269c8c8487ea7a6428.

"""Independent prompt-parity probes for PR 127944; no transport or model I/O."""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from tests.gateway.test_voice_command import _ensure_discord_mock, _make_runner
_ensure_discord_mock()
import plugins.platforms.discord.adapter as discord_platform
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.run_turn_runner import TurnRunner
from gateway.session import build_session_context, build_session_key
from gateway.turn_context import TurnContext

GUILD, PARENT, THREAD, USER, FRIEND = 10, 700, 800, 42, 43


class TextChannel:
    def __init__(self, channel_id=PARENT, name="ops", topic="Original topic"):
        self.id, self.name, self.topic = channel_id, name, topic
        self.guild = SimpleNamespace(id=GUILD, name="Guild")
        self.send = AsyncMock()


class ThreadChannel:
    def __init__(self, parent):
        self.id, self.name, self.parent, self.parent_id = THREAD, "incident", parent, parent.id
        self.guild, self.topic = parent.guild, None
        self.send = AsyncMock()


class DMChannel:
    pass


@pytest.fixture(autouse=True)
def transport_types(monkeypatch):
    monkeypatch.setattr(discord_platform.discord, "Thread", ThreadChannel)
    monkeypatch.setattr(discord_platform.discord, "DMChannel", DMChannel)
    monkeypatch.setattr("gateway.session._discord_tools_loaded", lambda: True)
    monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
    monkeypatch.setenv("DISCORD_ALLOWED_USERS", f"{USER},{FRIEND}")


def adapter_for(channel, extra=None):
    adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake", extra={
        "require_mention": False, "auto_thread": False, "history_backfill": False,
        "allowed_users": [str(USER), str(FRIEND)],
        **(extra or {}),
    }))
    adapter._allowed_user_ids = {str(USER), str(FRIEND)}
    users = {
        USER: SimpleNamespace(id=USER, name="alice", display_name="Alice", bot=False),
        FRIEND: SimpleNamespace(id=FRIEND, name="sam", display_name="Sam", bot=False),
    }
    guild = SimpleNamespace(id=GUILD, name="Guild", get_member=users.get)
    adapter._client = SimpleNamespace(
        user=SimpleNamespace(id=999), get_channel=lambda cid: channel if int(cid) == channel.id else None,
        get_guild=lambda gid: guild if int(gid) == GUILD else None, get_user=users.get,
    )
    adapter._discord_history_backfill = lambda: False
    adapter._text_batch_delay_seconds = 0
    adapter.handle_message = AsyncMock()
    return adapter, users, guild


def runner_for(adapter, tmp_path):
    runner = _make_runner(tmp_path)
    runner.config = GatewayConfig(platforms={Platform.DISCORD: adapter.config})
    runner.adapters = {Platform.DISCORD: adapter}
    del runner.__dict__["_is_user_authorized"]
    return runner


async def typed(adapter, channel, user, text="Typed message", *, admit=True):
    before = adapter.handle_message.await_count
    message = SimpleNamespace(
        id=1234 + before, content=text, mentions=[], attachments=[], reference=None,
        created_at=datetime.now(timezone.utc), channel=channel, guild=channel.guild,
        author=user, type=discord_platform.discord.MessageType.default,
    )
    if admit:
        adapter._ready_event.set()
        await adapter._dispatch_discord_message(message)
    else:
        await adapter._handle_message(message)
    assert adapter.handle_message.await_count == before + 1, "typed producer did not dispatch"
    return adapter.handle_message.await_args.args[0]


async def bind_join(runner, adapter, join_event):
    vc = SimpleNamespace(id=900, name="Voice", guild=SimpleNamespace(id=GUILD))
    adapter.get_user_voice_channel = AsyncMock(return_value=vc)
    adapter.join_voice_channel = AsyncMock(return_value=True)
    await runner._handle_voice_channel_join(join_event)
    assert adapter._voice_sources[GUILD]["chat_id"] == join_event.source.chat_id


async def spoken(runner, adapter, user_id=USER, text="Spoken message"):
    before = adapter.handle_message.await_count
    await runner._handle_voice_channel_input(GUILD, user_id, text, adapter=adapter)
    assert adapter.handle_message.await_count == before + 1, "voice handler did not dispatch"
    return adapter.handle_message.await_args.args[0]


def effective(runner, event, key="parity-probe"):
    context = build_session_context(event.source, runner.config)
    prompt = runner._pinned_session_context_prompt(context, False, key)
    channel_prompt, source = runner._pinned_channel_inputs(
        key, event.channel_prompt, event.source, internal=False,
    )
    return TurnRunner(runner, TurnContext(
        source=source, context_prompt=prompt, channel_prompt=channel_prompt,
    ))._combined_ephemeral_prompt()


def assert_prompt_sequence_equal(runner, text_event, voice_event):
    assert build_session_key(text_event.source) == build_session_key(voice_event.source)
    values = [effective(runner, e) for e in (text_event, voice_event, text_event)]
    signatures = [runner._agent_config_signature(
        model="probe", runtime={}, enabled_toolsets=[], ephemeral_prompt=value,
        user_id=text_event.source.user_id,
    ) for value in values]
    context_keys = [runner._ephemeral_change_key(
        build_session_context(event.source, runner.config), False,
    ) for event in (text_event, voice_event, text_event)]
    print("PARITY", {
        "typed_name": text_event.source.chat_name, "voice_name": voice_event.source.chat_name,
        "typed_topic": text_event.source.chat_topic, "voice_topic": voice_event.source.chat_topic,
        "typed_user": text_event.source.user_name, "voice_user": voice_event.source.user_name,
        "typed_hint": text_event.channel_prompt, "voice_hint": voice_event.channel_prompt,
        "distinct_effective_prompts": len(set(values)),
        "distinct_agent_signatures": len(set(signatures)),
        "distinct_context_keys": len(set(context_keys)),
    })
    assert len(set(values)) == 1, "text -> voice -> text changed the effective system prompt"


@pytest.mark.asyncio
async def test_control_group_member_hit(monkeypatch, tmp_path):
    channel = TextChannel()
    adapter, users, _ = adapter_for(channel, {"channel_prompts": {str(PARENT): "Direct hint"}})
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER])
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    voice = await spoken(runner, adapter)
    assert voice.source.message_id is None
    assert_prompt_sequence_equal(runner, text, voice)


@pytest.mark.asyncio
async def test_control_parent_override_is_retained(tmp_path):
    """YAML channel_prompts and channel_overrides.system_prompt are different inputs."""
    from gateway.config import ChannelOverride
    channel = ThreadChannel(TextChannel())
    adapter, users, _ = adapter_for(channel)
    runner = runner_for(adapter, tmp_path)
    runner.config.platforms[Platform.DISCORD].channel_overrides = {
        str(PARENT): ChannelOverride(system_prompt="PARENT_OVERRIDE_127944"),
    }
    text = await typed(adapter, channel, users[USER])
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    voice = await spoken(runner, adapter)
    present = ["PARENT_OVERRIDE_127944" in effective(runner, event) for event in (text, voice)]
    print("OVERRIDE_CONTROL", {"typed": present[0], "voice": present[1]})
    assert all(present), "inherited channel override was lost; the negative control failed"


@pytest.mark.asyncio
async def test_f2_thread_inherited_prompt(tmp_path):
    parent, channel = TextChannel(), None
    channel = ThreadChannel(parent)
    adapter, users, _ = adapter_for(channel, {"channel_prompts": {str(PARENT): "Parent hint"}})
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER])
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    voice = await spoken(runner, adapter)
    assert text.channel_prompt == "Parent hint"
    assert voice.source.parent_chat_id == str(PARENT)
    assert_prompt_sequence_equal(runner, text, voice)


@pytest.mark.asyncio
async def test_f3_guild_miss_ignores_global_user(tmp_path):
    channel = TextChannel()
    adapter, users, guild = adapter_for(channel)
    runner = runner_for(adapter, tmp_path)
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    text = await typed(adapter, channel, users[FRIEND])
    guild.get_member = lambda uid: users[USER] if int(uid) == USER else None
    assert adapter._client.get_user(FRIEND).display_name == "Sam"
    voice = await spoken(runner, adapter, FRIEND)
    assert_prompt_sequence_equal(runner, text, voice)


@pytest.mark.asyncio
async def test_f4_native_thread_join_label(tmp_path):
    channel = ThreadChannel(TextChannel())
    adapter, users, _ = adapter_for(channel)
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER])
    join = adapter._build_slash_event(SimpleNamespace(
        channel=channel, channel_id=channel.id, guild=channel.guild,
        guild_id=GUILD, user=users[USER],
    ), "/voice join")
    await bind_join(runner, adapter, join)
    voice = await spoken(runner, adapter)
    assert text.channel_prompt is None and voice.channel_prompt is None
    assert_prompt_sequence_equal(runner, text, voice)


@pytest.mark.asyncio
@pytest.mark.parametrize("inherited", [False, True])
async def test_f5_first_voice_turn_loads_channel_skill(monkeypatch, tmp_path, inherited):
    from tools.skills_tool import _skills_dir
    from tests.gateway.test_internal_event_pin_wiring import _make_runner as full_runner, _capture, KEY
    skill_dir = _skills_dir() / "recon-marker"
    skill_dir.mkdir(parents=True, exist_ok=True)
    (skill_dir / "SKILL.md").write_text(
        "---\nname: recon-marker\ndescription: Isolated test marker\n---\nRECON_SKILL_127944\n",
        encoding="utf-8",
    )
    channel = ThreadChannel(TextChannel()) if inherited else TextChannel()
    adapter, users, _ = adapter_for(channel, {
        "channel_skill_bindings": [{"id": str(PARENT), "skill": "recon-marker"}],
    })
    ingress = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER])
    await bind_join(ingress, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    voice = await spoken(ingress, adapter)
    payloads = {}
    for label, event in (("typed", text), ("voice", voice)):
        runner = full_runner(monkeypatch, ingress.config)
        entry = runner.session_store.get_or_create_session.return_value
        entry.updated_at = entry.created_at
        captured = []
        _capture(runner, captured)
        await runner._handle_message_with_agent(event, event.source, KEY, 1)
        assert len(captured) == 1, "real turn handler did not reach model boundary"
        payloads[label] = captured[0]["message"]
    assert text.auto_skill == ["recon-marker"]
    assert "RECON_SKILL_127944" in payloads["typed"], "typed control did not load the real skill"
    print("SKILLS", {"typed": text.auto_skill, "voice": voice.auto_skill,
        "voice_payload_contains_marker": "RECON_SKILL_127944" in payloads["voice"]})
    assert "RECON_SKILL_127944" in payloads["voice"], "voice-first session omitted bound skill"


@pytest.mark.asyncio
@pytest.mark.parametrize("field,updated", [("name", "renamed-ops"), ("topic", "Updated topic")])
async def test_f6_bound_metadata_lifetime(tmp_path, field, updated):
    channel = TextChannel()
    adapter, users, _ = adapter_for(channel)
    runner = runner_for(adapter, tmp_path)
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join"))
    setattr(channel, field, updated)
    text = await typed(adapter, channel, users[USER])
    voice = await spoken(runner, adapter)
    assert_prompt_sequence_equal(runner, text, voice)


@pytest.mark.asyncio
async def test_f7_programmatic_join_without_source(monkeypatch, tmp_path):
    channel = TextChannel()
    adapter, users, _ = adapter_for(channel)
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER])
    voice_channel = SimpleNamespace(id=900, name="Voice", guild=SimpleNamespace(id=GUILD))
    vc = SimpleNamespace(channel=voice_channel, is_connected=lambda: True)
    voice_channel.connect = AsyncMock(return_value=vc)
    adapter._reset_voice_timeout = lambda gid: None
    class Receiver:
        def __init__(self, *a, **kw): pass
        def start(self): pass
    monkeypatch.setattr(discord_platform, "VoiceReceiver", Receiver)
    async def idle(gid):
        import asyncio
        await asyncio.Event().wait()
    adapter._voice_listen_loop = idle
    runner._bind_voice_input_callback(adapter)
    assert await adapter.join_voice_channel(voice_channel, text_channel_id=channel.id)
    assert GUILD not in adapter._voice_sources
    try:
        voice = await spoken(runner, adapter)
        typed_key, voice_key = build_session_key(text.source), build_session_key(voice.source)
        print("SESSION_KEYS", {"typed": typed_key, "voice": voice_key,
            "voice_scope": voice.source.scope_id, "voice_chat_name": voice.source.chat_name})
        assert typed_key == voice_key, "source-less supported join split the text and voice sessions"
    finally:
        for task in adapter._voice_listen_tasks.values():
            task.cancel()
            try: await task
            except BaseException: pass


@pytest.mark.asyncio
async def test_f7_programmatic_join_updates_existing_binding(monkeypatch, tmp_path):
    """Explicit source metadata must be applied on a successful hot join as on a cold one."""
    import asyncio
    first, second = TextChannel(), TextChannel(PARENT + 1, "other")
    adapter, users, _ = adapter_for(first)
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, first, users[USER])
    voice_channel = SimpleNamespace(id=900, name="Voice", guild=SimpleNamespace(id=GUILD))
    vc = SimpleNamespace(channel=voice_channel, is_connected=lambda: True)
    voice_channel.connect = AsyncMock(return_value=vc)
    adapter._reset_voice_timeout = lambda gid: None
    class Receiver:
        def __init__(self, *a, **kw): pass
        def start(self): pass
    monkeypatch.setattr(discord_platform, "VoiceReceiver", Receiver)
    async def idle(gid):
        await asyncio.Event().wait()
    adapter._voice_listen_loop = idle
    runner._bind_voice_input_callback(adapter)
    assert await adapter.join_voice_channel(
        voice_channel, text_channel_id=first.id, source=text.source.to_dict())
    try:
        # A fully bound cold programmatic join is the positive control.
        first_voice = await spoken(runner, adapter, text="First spoken request")
        assert first_voice.source.chat_id == str(first.id)
        assert build_session_key(text.source) == build_session_key(first_voice.source)
        next_text = await typed(adapter, second, users[USER])
        assert await adapter.join_voice_channel(
            voice_channel, text_channel_id=second.id, source=next_text.source.to_dict())
        next_voice = await spoken(runner, adapter, text="Another distinct spoken request")
        print("HOT_JOIN", {"requested_chat": second.id, "bound_chat": adapter._voice_text_channels[GUILD],
            "dispatched_chat": next_voice.source.chat_id})
        assert next_voice.source.chat_id == str(second.id), "successful hot join ignored explicit new binding"
    finally:
        for task in adapter._voice_listen_tasks.values():
            task.cancel()
            try: await task
            except asyncio.CancelledError: pass


@pytest.mark.asyncio
async def test_f8_stt_rebind_keeps_captured_conversation(monkeypatch, tmp_path):
    """A successful /voice join rebind while STT awaits must not re-home old speech."""
    import asyncio
    import threading
    first, second = TextChannel(), TextChannel(PARENT + 1, "other")
    adapter, users, _ = adapter_for(first)
    runner = runner_for(adapter, tmp_path)
    first_join = await typed(adapter, first, users[USER], "/voice join")
    await bind_join(runner, adapter, first_join)
    runner._bind_voice_input_callback(adapter)
    loop = asyncio.get_running_loop()
    stt_started, release = asyncio.Event(), threading.Event()
    def blocked_stt(wav_path):
        loop.call_soon_threadsafe(stt_started.set)
        assert release.wait(5), "probe did not release the STT boundary"
        return {"success": True, "transcript": "Captured in first conversation"}
    monkeypatch.setattr("tools.transcription_tools.transcribe_audio", blocked_stt)
    task = asyncio.create_task(adapter._process_voice_input(GUILD, USER, b"\0" * 96000))
    try:
        await asyncio.wait_for(stt_started.wait(), 3)
        await bind_join(runner, adapter, await typed(adapter, second, users[USER], "/voice join"))
        before = adapter.handle_message.await_count
        release.set()
        await asyncio.wait_for(task, 3)
        assert adapter.handle_message.await_count == before + 1, "STT did not reach the bound callback"
        event = adapter.handle_message.await_args.args[0]
        print("STT_REBIND", {"captured_chat": first.id, "dispatched_chat": event.source.chat_id})
        assert event.source.chat_id == str(first.id), "old speech was dispatched into the new binding"
    finally:
        release.set()
        if not task.done():
            task.cancel()
            try: await task
            except asyncio.CancelledError: pass


@pytest.mark.asyncio
async def test_f9_duplicate_window_is_conversation_scoped(tmp_path):
    """Same speaker and words in a different linked text conversation are a new turn."""
    first, second = TextChannel(), TextChannel(PARENT + 1, "other")
    adapter, users, _ = adapter_for(first)
    runner = runner_for(adapter, tmp_path)
    await bind_join(runner, adapter, await typed(adapter, first, users[USER], "/voice join"))
    await spoken(runner, adapter, text="Repeatable request in each conversation")
    # The unchanged-binding duplicate control is suppressed.
    before = adapter.handle_message.await_count
    await runner._handle_voice_channel_input(GUILD, USER, "Repeatable request in each conversation", adapter=adapter)
    assert adapter.handle_message.await_count == before, "same-binding duplicate control was not suppressed"
    await bind_join(runner, adapter, await typed(adapter, second, users[USER], "/voice join"))
    before = adapter.handle_message.await_count
    await runner._handle_voice_channel_input(GUILD, USER, "Repeatable request in each conversation", adapter=adapter)
    accepted = adapter.handle_message.await_count - before
    print("DEDUPE_REBIND", {"new_chat": second.id, "new_conversation_turns_accepted": accepted})
    assert accepted == 1, "the previous conversation's duplicate window suppressed a new conversation"


@pytest.mark.asyncio
async def test_f10_authorized_role_speaker_keeps_voice_access(monkeypatch, tmp_path):
    """An explicit role grant must authorize the real voice ingress as it does text."""
    channel = TextChannel()
    monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
    adapter, users, guild = adapter_for(channel, {"allowed_roles": [55], "allowed_users": []})
    adapter._allowed_role_ids = {55}
    adapter._allowed_user_ids = set()
    users[USER].roles = [SimpleNamespace(id=55)]
    users[USER].guild = guild
    runner = runner_for(adapter, tmp_path)
    text = await typed(adapter, channel, users[USER], admit=True)
    assert text.source.role_authorized is True, "typed producer did not establish the role grant"
    assert runner._is_user_authorized_for_source(text.source), "real text authorization control failed"
    await bind_join(runner, adapter, await typed(adapter, channel, users[USER], "/voice join", admit=True))
    runner._bind_voice_input_callback(adapter)
    assert adapter._is_allowed_user(str(USER), guild=guild, is_dm=False), "voice intake role control failed"
    class Receiver:
        _running = True
        def check_silence(self):
            self._running = False
            return [(USER, b"\0" * 96000)]
    adapter._voice_receivers[GUILD] = Receiver()
    adapter._reset_voice_timeout = lambda gid: None
    from unittest.mock import patch
    before = adapter.handle_message.await_count
    with patch("tools.transcription_tools.transcribe_audio", return_value={
        "success": True, "transcript": "A role-authorized speaker's request",
    }):
        await adapter._voice_listen_loop(GUILD)
    voice_source = runner._voice_input_source(adapter, GUILD, USER, PARENT)
    accepted = adapter.handle_message.await_count - before
    print("ROLE_ACCESS", {"typed_grant": text.source.role_authorized,
        "voice_grant": voice_source.role_authorized, "voice_turns_accepted": accepted})
    assert accepted == 1, "authorized voice input lost its role grant before runner admission"

Matt McFarland and others added 4 commits October 3, 2026 14:26
…ext turns

A Discord voice-channel turn rendered a different "Current Session Context"
block from a text message by the same person in the same channel, so every
switch between typing and talking missed the prompt cache for the whole
conversation (one voice reply: 24 s for a 90K-token re-prefill where the text
turn a minute earlier took 3.2 s).

- _voice_input_source used the bare Discord user ID as user_name; it now uses
  the member's display name (what text messages carry), via a new
  DiscordAdapter.voice_member_display_name, falling back to the name bound at
  /voice join for that user, else the ID.
- The Discord note rendered "Triggering message: ..." only when the turn had
  a message id, which voice and slash-command turns never do. It is now
  unconditional (worded for turns without one), and message-id presence is
  dropped from the ephemeral change key since it no longer affects the bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A typed `/voice join` binds that message's source, message_id included, and
`_voice_input_source` rebuilt every voice turn from it. The join message is
not the voice turn's trigger: run.py exports source.message_id as
HERMES_SESSION_MESSAGE_ID (reply anchoring), and on main it also set the
presence bit in the prompt-pin key, so the note-gating cache miss this PR
fixes only surfaced for native slash-command joins. Clear it on the way out.

Review-fix for NousResearch#127944.
guild.get_member() is cache-only, so a speaker who had never typed in the
guild (no member intent, or joined voice straight away) rendered as the bare
user id while their text turns render a display name: the session-context
prompt differed again on every switch. Fall back to the cached global user
(global name / username) before giving up; a guild nick still wins when the
member is cached. Suggested in the first review, reproduced independently
(F3 on the thread).
@teknium1
teknium1 force-pushed the fix/voice-session-prompt-cache branch from dce6efe to 7eef630 Compare October 3, 2026 21:27

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review — #127944 (re-review at head 7eef630f)

Verdict: still the correct, cache-safe fix; the PR now has executing CI for the first time. Merge decision stays with the maintainer.

What changed on the branch (maintainer edits, authorship preserved):

  • Rebased the branch onto current origin/main (it was 2,107 commits behind; both substantive commits cherry-picked clean, zero conflicts). The old head had never executed a single CI job: every ci.yaml dispatch on it was a zero-job startup_failure, which is what a fork merge-base that predates a reusable workflow ci.yaml now calls looks like. On the rebased head CI dispatched 34 jobs at once (run 37155145773); I approved the held fork runs for this head only. Nix and Docker are already green.
  • 295a01f3 fix(discord): a voice speaker outside the member cache still gets a name — the get_user fallback the first review suggested, independently reproduced as F3 on this thread. guild.get_member() is cache-only; a speaker who never typed in the guild rendered as the bare id while their text turns render a name, so the prompt diverged again on every switch. A guild nick still wins when the member is cached; the global user's display name is the fallback; a MagicMock-shaped client no longer leaks a non-string "name" (the isinstance(str) guard). The existing adapter test gained the member-miss/global-hit case and the global-only case; its old is None assertions were only true because nothing else was consulted.
  • dce6efe's empty retrigger commit is carried as 7eef630f (top of branch) rather than dropped.

Premise re-verified on today's origin/main (not fixed elsewhere in the 2,107 commits): with run_voice.py, run_agent_cache.py, session.py and discord/adapter.py swapped to origin/main and the new test file kept, 6 of 7 tests fail; on the head, test_voice_session_prompt.py + test_prompt_tail_freeze.py + test_internal_event_pin_wiring.py + test_voice_command.py + test_discord_triggering_note_persistence.py + test_discord_slash_commands.py = 95 passed, 0 failed, 4 skipped (file-isolated runner).

Disposition of the supplemental findings (F2–F10) relative to this PR:

  • F3 — fixed here (above).
  • F2 (voice channel_prompt resolved without the thread's parent), F4 (native-slash thread label), F5 (voice auto_skill), F6 (join-time channel name/topic snapshot) — all real, all pre-existing on main, and all already implemented with tests in #128788 (b993d20b), whose scope is exactly the Discord producers. Not duplicated onto this branch on purpose: the two PRs are complementary (this one = renderer + change key + join-message-id clear, #128788 = slash/thread/voice producer inputs) and they overlap textually in _voice_input_source. Composition note for whichever lands second: keep source.message_id = None (load-bearing, verified by deletion) and the voice_member_display_name lookup with its global-user fallback; #128788's current head has neither.
  • F7–F10 (source-less programmatic join key, hot-join rebind, in-flight STT rehoming, dedupe window across rebinds, role grant lost through serialization) — real-looking but adjacent routing/admission/authz defects on main, out of this PR's prompt-parity scope; they deserve their own issue rather than holding this fix.

Not done here: a live Discord voice session (no bot/voice on this host); the 24 s → 4 s latency numbers remain the contributor's.

@andrexibiza

andrexibiza commented Oct 3, 2026 •

Copy link
Copy Markdown

@teknium1 Issue #132474 created: voice lifecycle follow-through

@andrexibiza

Copy link
Copy Markdown

@teknium1 F10 (role-admitted speaker loses admission before dispatch, #132474) now has an independent live repro. w32zhong, on main@158fd638da: a role-only Discord user passes the slash gate, reaches the gateway without role_authorized, and is rejected "Unauthorized user"; normal messages work. They read #130303 as the broad fix (re-evaluates full actor admission, covers voice; #118966/#125620 don't) and agree with the split from #128788. Their local patch: 2 failures unpatched, 8/8 patched, from an isolated adapter harness, not #130303's tests or a live gateway. The routing in #132474 is holding: #130303 (comment)

The check-attribution lane was the only red on the rebased head: the
contributor commit carries mattttmcfarland@gmail.com, which had no
contributors/emails/ mapping. Maps it to @matttmcfarland (name verified
against the GitHub users API).
@teknium1

teknium1 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed 4f5b8830 (maintainer edit, no source change): contributors/emails/mattttmcfarland@gmail.com -> matttmcfarland. The check-attribution lane was the only red on the rebased head 7eef630f — the contributor email had no mapping file, which All required checks pass aggregates. Held fork runs for this head approved; everything else from the 7eef630f review stands.

unsupportedpastels pushed a commit that referenced this pull request Oct 6, 2026
A typed `/voice join` binds that message's source, message_id included, and
`_voice_input_source` rebuilt every voice turn from it. The join message is
not the voice turn's trigger: run.py exports source.message_id as
HERMES_SESSION_MESSAGE_ID (reply anchoring), and on main it also set the
presence bit in the prompt-pin key, so the note-gating cache miss this PR
fixes only surfaced for native slash-command joins. Clear it on the way out.

Review-fix for #127944.

[adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, so the
clear lands in main's block; the test moved to test_discord_turn_prompt_parity.py, a typed
`/voice join` id on the bound source]
(cherry picked from commit fe70c11)
@unsupportedpastels

Copy link
Copy Markdown
Collaborator

Thank you @matttmcfarland. Your diagnosis and the vLLM timings made this problem easy to understand and fix.

Everything in this PR is now on main, so I'm closing it:

Thanks also to @andrexibiza for independently reproducing the uncached-speaker case.

jigo-hyunjin added a commit to jigo-ai-team/hermes-agent that referenced this pull request Oct 6, 2026
* fix(discord): capture the voice binding when a poll batch is collected

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)

* refactor(discord): move voice-channel info helpers to a sibling mixin; annotate the stale-binding test

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.

* fix(discord): a /voice join to another text channel drops speech the receiver still holds for the old one

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.

* test(discord): typed stand-ins in the batch stale-binding test

The two-utterance batch test assigned lambdas over adapter methods and a fake receiver into a VoiceReceiver map; use MagicMock and cast, as the newer rebind tests do, so the file adds no ty diagnostics.

* feat(browser): public captured CDP call seam for trusted plugins

Plugins that drive the agent's browser below the browser tools (Secure ENV,
catalog review on #132272) had no public way to send a raw CDP command over a
task's supervisor connection, so they bumped CDPSupervisor._next_call_id,
inserted futures into _pending_calls and sent on _ws. That breaks silently on
any internal change and collides between plugins.

SUPERVISOR_REGISTRY.capture(task_id) now returns a CapturedCDP pinned to the
supervisor's current socket and default page session:

  call(method, params=None, *, session_id=None, timeout=10.0) -> raw reply
  is_valid() / page_session_id / task_id / cdp_url

The validity check and the send run in one supervisor-loop step, so a call
never reaches a newer connection: after a reconnect, stop or registry
replacement every call raises CapturedCDPInvalid. Calls in flight when the
socket drops now fail at once (the supervisor fails pending replies on
teardown) instead of sitting out their timeout.

No model tool, config default or new capability: in-process Python could
already reach this. The handle enforces no origin/consent/ownership policy.

Reduced-scope salvage of #132752 (API shape and names from that PR); its
attach-reply handoff, old-wire cleanup and dispatch fences are not needed
because the supervisor closes the old socket on every reconnect, which drops
its sessions with it.

Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>

* refactor(gateway): move plugin message injection into its own runner mixin

run_inbound.py was past the 2,000-line gate; the injector install/clear,
scheduler and dispatch move verbatim to gateway/run_plugin_injection.py
(GatewayPluginInjectionMixin). No behaviour change.

* feat(plugins): inject_message(origin=...) starts a gateway session in the plugin's own profile

`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.

`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:

- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
  name another profile, the gateway's own routing for that chat through that profile's adapter
  must land in the plugin's profile, and the profile needs its own connected adapter for the
  platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
  reply send after it inherit that profile's home and secrets (applies to the session_key path too,
  which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
  the "no home channel" nudge), which were posted into the plugin's fresh thread.

Requested by canonicle-desk in #132609.

Closes #132609

* feat(computer-use): pluggable single-select computer-use providers

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.

* feat(plugins): let auxiliary tasks inherit a base slot at read time

register_auxiliary_task's defaults start from a fixed shape and every key
is a full replace, so a plugin whose side model is a near-copy of a
built-in has to restate the whole block and silently drifts when that
built-in's shape changes.

inherit_from names a built-in or already-registered task whose effective
config becomes the base, resolved at read time so the plugin tracks the
base's current values instead of snapshotting them. Precedence is base,
then plugin defaults, then user config.

Self-referential inherit_from is rejected at registration rather than
left to the read-time guard: same-owner re-registration is allowed, so
the bad value would otherwise surface as unbounded recursion in a config
read. The guard stays for a hand-edited registry.

Closes #129189

* fix(plugins): keep an inherited aux base from being shadowed by write-only empties

_get_auxiliary_task_config merged the raw auxiliary.<key> block over the
inherited base, and Hermes's own config writers persist model, base_url,
api_key and reasoning_effort as "" when the operator expresses no preference
for them. _save_aux_choice writes those four fields unconditionally, and
_reset_aux_to_auto clears them for every known task including plugin-registered
ones via _all_aux_tasks(). So picking a provider for one task, or hitting
"Reset all to auto" once, left every routing key resolving to the empty string,
which maps to None and falls back to the main agent model. That discarded the
inherited base for exactly the keys the operator UI writes, which is the whole
point of inherit_from.

Filter "" out of the user layer when a task actually inherits. Only "" is
dropped, never any falsy value: provider "auto" is a deliberate operator choice
and reasoning_effort false is an explicit request for no reasoning. Tasks
without inherit_from keep today's behaviour, including their empty placeholders.

The picker also rendered the raw stored block, so a task inheriting a base's
provider reported "auto" while actually running on that provider. Display now
resolves the inherited view for inheriting tasks only, and falls back to the
stored block whenever the runtime resolver is unavailable.

Reported-by: Enough1122 (AI code review on this PR).

Refs #129189

* fix(dashboard,desktop): surface plugin-registered auxiliary tasks in Models settings

`PluginContext.register_auxiliary_task` already lists a plugin task in the `hermes model`
picker and layers its defaults into `auxiliary.<key>`, but every REST/UI surface behind the
Models page enumerated only the built-in `_AUX_TASK_SLOTS`:

- `GET /api/model/auxiliary` never listed the task
- `POST /api/model/set` (scope=auxiliary) rejected it: `400 unknown auxiliary task`
- `__reset__` and the empty-task broadcast skipped it; `_stale_aux_pins` ignored it
- Desktop Settings → Models and the dashboard ModelsPage hardcoded the built-in list

So a plugin's side model could only be pinned by hand-editing config.yaml per profile.

Fix, at the existing seams:
- `web_server_config._aux_task_slots()` = built-ins + plugin keys from
  `get_plugin_auxiliary_tasks()`, fail-soft; every consumer in that module uses it.
- `GET /api/model/auxiliary` reads plugin tasks INSIDE `_profile_scope`, so a request for
  profile B enumerates B's plugins (the plugin manager keys on the same context-local home) —
  the profile-scope gap flagged on #40922. Built-in rows are byte-identical; plugin rows are
  appended with `label`/`hint`/`plugin` from the registration.
- Desktop `model-settings.tsx` and dashboard `ModelsPage.tsx` render served plugin rows after
  the built-ins using the server label/hint (pure `auxTaskRows`; no new i18n strings, no-op on
  older backends).

Tests: real plugin loaded from a temp HERMES_HOME through PluginManager discovery, two
profiles in one process (listing scoped per profile; foreign task still 400), assignment /
broadcast / reset / stale-pin over a plugin slot, discovery failure leaves built-ins intact —
all red on main. Vitest covers the pure row merge and the rendered plugin row.

Fixes #40880. Salvages #40922.

Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>

* fix(plugins): inherited aux slots follow their base until the user pins one

Salvage follow-up for #129189 on top of #129209:

- An unknown or self-referential `inherit_from` now logs a warning and
  registers the task without inheritance instead of raising, so one bad
  base no longer fails the whole plugin load (the #129191 approach).
- `provider: auto` on an inheriting task means "no preference", like the
  empty strings: "Reset all to auto" (CLI + dashboard) writes exactly that,
  and before this it pinned the slot to the main provider while the model
  was still inherited from the base (mismatched route).
- Once the user pins a route on the task (non-auto provider, model or
  base_url), provider/model/base_url/api_key/api_mode/key_env/effort all
  come from the task's own block. Mixing per key let a pinned provider pick
  up the base's base_url and api_key.
- The CLI picker resolves the inherited view only for inheriting tasks.

Tests collapse to behavior contracts: base followed and changes
propagate, picker placeholders keep following, a pin wins whole, bad
bases warn and are ignored, re-registration cycles are cut.

Co-authored-by: Yuan Li <dskwelmcy@163.com>

* feat(dashboard,desktop): show which slot a plugin aux task inherits

Plugin aux rows (#40880) now carry `inherit_from` and, for inheriting
tasks, an `effective` {provider, model, base_url} resolved inside the
request's profile scope. Desktop Settings → Models and the dashboard
Auxiliary Tasks modal render an unpinned inheriting row as
"inherits Compression · <provider · model>" and offer "Follow <base>"
on a pinned one (writes provider auto, which resumes inheritance).

The dashboard test now writes plugin manifests/config as JSON (no yaml
dependency in the test env) and covers profile A/B isolation of the
same plugin slot: listing, effective route, pin persistence, follow and
base-change propagation each stay in their own profile.

* refactor: move aux-slot growth into topical siblings (code-health caps)

No behavior change. Keeps every touched file and function at or below
its value on main:

- agent/auxiliary_task_config.py: read-time `auxiliary.<task>`
  resolution (plugin defaults, inherit_from, whole-route pin); still
  re-exported as agent.auxiliary_client._get_auxiliary_task_config.
- hermes_cli/plugins_aux_tasks.py: register_auxiliary_task validation
  and entry shape.
- web/src/lib/api-aux.ts: /api/model/auxiliary wire types.
- Desktop: aux row list, label resolver, deep-link highlight, route
  summary, Follow button and the set-to-main route in
  settings/aux-task-rows.tsx; one assignAuxiliary callback replaces
  setAuxiliaryToMain. Built-in aux slot labels move from each locale
  into <locale>_aux_tasks.ts (types_aux_tasks.ts holds the shape).

* test(install): cover treeless checkout migration

* fix(update): convert a treeless checkout before the dependency work, and on installer re-runs

A Desktop built before the blobless fix keeps walking history during
`hermes update`. On a treeless (tree:0) checkout every walk downloads trees
again, and #129514 filled a 434 GB disk during the minutes of dependency work.
The one-time conversion (#133016) ran at the very end of the update, after that
window. Both update hand-offs (update_completion._prepare and an older
updater's _update_takeover.prepare) now run it first, in the bootstrap
interpreter, and the late call is gone.

Re-running install.sh / install.ps1 over a treeless checkout now converts it
too. The filter is recorded only after the refetch succeeds, so a failure is
retried by the next update instead of leaving a blob:none filter over a
history that has no trees.

The pack cleanup also sweeps tmp_* transfer files that killed fetches left
behind. They are more than an hour old, a storm leaves gigabytes of them,
and git itself only prunes them after two weeks. The docs gain the recovery
steps for a disk that the storm already filled.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Yuan Li <dskwelmcy@163.com>

* test(install): start the treeless re-run fixture from a real treeless clone

The bare origin had no main HEAD, so the clone had no commits and stage_repository
re-cloned it as an interrupted install; a plain-path clone also ignores --filter.
The test passed on the unfixed installer. Use -b main and a file:// URL, and assert
the checkout was converted in place.

* test(install): run the treeless re-run case through install.ps1 too

* refactor(update): move the no-prompt git kwargs helper where the bootstrap can reach it

The convert-first step runs in the bootstrap interpreter, which cannot import
update_cmd (ruamel), so it carried its own raw os.environ copy (the spawn-env
guard flagged it) and lacked the Windows console-hide flags (#117781).
Move the body unchanged to _subprocess_compat.no_prompt_git_kwargs: prompts
off, credential helpers kept, consoles hidden. update_cmd keeps the
_no_prompt_git_kwargs name as an import because old updaters load it after
the checkout swap (tests/compat/old_updater_surface.json); existing callers
are untouched.

* feat(plugins): let plugins register Automation Blueprints via ctx

Plugins can now call ctx.register_automation_blueprint(key, *, title,
description, schedule_template, prompt_template, category, slots,
deliver_default, skills, tags). The fields mirror the built-in
AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call
also crosses the plugin-host boundary.

- One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(),
  merges the built-ins with the active profile's plugin blueprints at call
  time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the
  instantiate route all use it. Each dashboard route resolves inside the
  ?profile= scope, off the event loop.
- Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in.
  A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type
  or a schedule that does not parse is warned and ignored at registration.
- Catalog entries carry source/plugin. Desktop fetches the catalog for the
  profile it creates jobs in, keyed per profile, and shows the plugin name
  on the blueprint row and in the "Start from" select. The dashboard shows
  a plugin badge.
- A created job is an ordinary cron job with the prompt and schedule already
  filled in, so it keeps running if the plugin is later disabled.
- The ctx content registrars (register_skill, now joined by
  register_automation_blueprint) move to hermes_cli/plugins_content.py.
  The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts.

Closes #132373. Requested by @enckequity.

* fix(discord): a role-authorized member's speech in a voice channel reaches the agent

With DISCORD_ALLOWED_ROLES and no user allowlist, the voice listen loop
admits a role member through the adapter's guild-scoped role check, but
_handle_voice_channel_input rebuilds the turn's source from the
`/voice join` copy. SessionSource.to_dict() deliberately drops the
per-event role_authorized grant, so the gateway's own check saw a
principal with no grant and dropped every spoken turn from a role-only
member, while the same member's typed messages were answered.

The grant is now recomputed for the current speaker against the guild's
current member, with the meaning the message path gives it (the adapter
has a role allowlist and this user passes its gate). It is never
serialized and never inherited from whoever ran `/voice join`: a speaker
without the role, or no longer in the guild, is still refused.

Reported by andrexibiza (F4, voice half) on #128788.

(cherry picked from commit 6985ea6300671466b8cfa8e7948ee4c402bcb9e2)

* docs(voice): voice-channel access follows DISCORD_ALLOWED_ROLES too

(cherry picked from commit 7976b4aae1826a4e234f3f2a7cf796cb5b7ae98e)

* fix(discord): carry the role verdict into slash command events

Fixes #118958

(cherry picked from commit f5f538ef4b86a1d179e76292894372cfece8d2f3)

* docs(discord): state what the slash role flag means

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)

* fix(discord): a native slash or /thread starter stamps the role grant for its own actor only

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)

* refactor(discord): move the slash gate verdict to a sibling mixin; adapt the thread-starter test

_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.

* fix(gateway): voice turns drop the bound /voice join message id

A typed `/voice join` binds that message's source, message_id included, and
`_voice_input_source` rebuilt every voice turn from it. The join message is
not the voice turn's trigger: run.py exports source.message_id as
HERMES_SESSION_MESSAGE_ID (reply anchoring), and on main it also set the
presence bit in the prompt-pin key, so the note-gating cache miss this PR
fixes only surfaced for native slash-command joins. Clear it on the way out.

Review-fix for #127944.

[adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, so the
clear lands in main's block; the test moved to test_discord_turn_prompt_parity.py, a typed
`/voice join` id on the bound source]
(cherry picked from commit fe70c11bdabdb1ea0b9494880146f4cf16b19ad0)

* fix(discord): a voice speaker outside the member cache still gets a name

guild.get_member() is cache-only, so a speaker who had never typed in the
guild (no member intent, or joined voice straight away) rendered as the bare
user id while their text turns render a display name: the session-context
prompt differed again on every switch. Fall back to the cached global user
(global name / username) before giving up; a guild nick still wins when the
member is cached. Suggested in the first review, reproduced independently
(F3 on the thread).

[adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, not
through DiscordAdapter.voice_member_display_name, so the fallback is a small helper there. It
runs after the /voice join invoker's bound name, which is the nickname their typed turns
carry; a cached global name would differ from it. Tests moved to
test_discord_turn_prompt_parity.py]
(cherry picked from commit 295a01f3ab88f07f3bac22f8939c42bca928c398)

* fix(auxiliary): support MiniMax OAuth side tasks

_resolve_registry_branch now has an oauth_minimax arm that resolves a
callable bearer via resolve_minimax_oauth_runtime_credentials and wraps
the resulting Anthropic SDK client in AnthropicAuxiliaryClient(..., is_oauth=True).
Without it, minimax-oauth falls through to the oauth_device_code/oauth_external
branch and silently returns (None, None), re-routing every aux task pinned
to that provider to the Step-2 fallback chain.

The callable bearer mints a fresh access token per outbound request because
MiniMax's tokens live ~15 minutes and a static string would 401 mid-session.

* fix(auxiliary): MiniMax OAuth route keeps third-party wire contract

Review fix for the oauth_minimax arm (ehz0ah, #109316): is_oauth on
AnthropicAuxiliaryClient is not 'credential came from OAuth' — it enables
Claude Code-native request behavior (mcp__ tool-name wire prefixing,
identity/system rewrites, response prefix stripping) that only
api.anthropic.com routes qualify for. api.minimax.io is a third-party
Anthropic-protocol endpoint and anthropic_route_is_oauth(base_url,
token_provider) is False for it by contract
(tests/agent/test_anthropic_route_oauth_identity.py), so the hardcoded
True sent mcp__-prefixed tool names to a host that never round-trips
them.

Derive the flag via anthropic_route_is_oauth(base_url, token_provider)
instead — same rule the custom anthropic_messages arms already use —
keeping the callable bearer (per-request token refresh) intact.

The regression test asserted the wrong invariant; it now pins
_is_oauth False, and a new witness test proves tool names reach the
wire unprefixed (read_file stays read_file, not mcp__read_file;
session_search is not aliased).

* refactor(auxiliary): registry auth-type table; MiniMax OAuth arm in a topical sibling

Clears the code health ratchet on the branch:

- FILE_LINES: agent/auxiliary_client.py 8469 > 8434 (main's cap, may only go
  down). The PR's new MiniMax OAuth arm moved out of the facade into
  agent/auxiliary_client_minimax.py, following the <stem>_<topic>.py sibling
  layout; the arm late-imports the facade so the seams tests patch
  (hermes_cli.auth.resolve_minimax_oauth_runtime_credentials,
  agent.anthropic_adapter.build_anthropic_client) are exactly where
  production reads. Facade now 8432 lines.
- HX011: the auth_type if/elif ladder in _resolve_registry_branch became a
  dict -> handler table (REGISTRY_AUTHTYPE_ARMS in
  agent/auxiliary_client_registry.py, the registry twin of
  _EXPLICIT_PROVIDER_BRANCHES). Behavior identical: same arms, same
  log-once fall-throughs on the facade.
- BLE001: the arm's blind except Exception around the runtime credential
  resolution is a deliberate absent-credentials boundary; it now logs with
  exc_info=True so the traceback surfaces (verified clean under the pinned
  ruff with --ignore-noqa; no waiver comments).

No behavior change: the 3 PR tests, test_auxiliary_client.py (240),
test_anthropic_route_oauth_identity.py, test_auxiliary_client_resolve_dedup.py,
test_missing_provider_credentials_hint.py, bedrock/vertex/minimax suites all
green; scripts/check 11 checks ok.

* fix(auth): serialize MiniMax OAuth refresh and keep it a credential-only write

teknium1's request-changes review of #133534, all four findings plus nits:

1. Concurrent refresh logged the user out (major): _minimax_fresh_state now
   holds _auth_store_lock across read -> refresh -> save and re-reads state
   inside the lock, so a second concurrent consumer (main agent + aux task)
   sees the first caller's new expiry instead of replaying the single-use
   refresh token into refresh_token_reused and quarantining the winner's
   fresh tokens. Lock timeout outlives the refresh POST (codex pattern).
2. Aux refresh overwrote active_provider (medium): _minimax_save_auth_state
   gains set_active; the refresh and quarantine paths persist with
   set_active=False (the _save_provider_state_to_source rule), only
   _minimax_oauth_login sets active.
3. Warning spam when logged out (medium, #21521's complaint): the arm's
   expected absent/not-logged-in/AuthError fall-throughs are debug-once
   (contract test_auxiliary_client_resolve_dedup.py); only a genuinely
   unexpected exception keeps exc_info, logged once.
4. Probe mode made a network call (minor): under _aux_probe_active() the arm
   reads get_provider_auth_state("minimax-oauth") raw and stubs when
   access_token + inference_base_url are present, skipping the refresh
   (mirrors credential_pool.py::_seed_minimax_singleton).

Nits: the arm body lives directly in auxiliary_client_registry.py (one new
module, agent/auxiliary_client_minimax.py deleted), the in-tree
try/except ImportError wrappers are gone (only the build_anthropic_client
SDK-absent boundary remains, matching _try_anthropic), and the misnamed
..._with_oauth_semantics test is now ..._third_party_wire.

Tests: concurrent loopback-portal refresh race (1 POST, 0 errors, tokens
intact), aux refresh preserves active_provider, login still sets it,
logged-out resolutions are debug-once without traceback, probe mode with an
expired token makes 0 HTTP calls.

Co-authored-by: Ubxty <ubxty@users.noreply.github.com>

* fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped (#133648)

* fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped

Follow-up to #133434, which fixed these races for local processes only.

Non-local backends (spawn_via_env) settle through _env_poller_loop, which
still wrote the session's final state without the session lock:

- It set exit_code before _finish_exited, so a kill that landed first ended
  as killed with the sandbox's exit code. The exit now goes through
  _finish_exited alone, and a committed kill keeps its code.
- Its backend-lost path set exited/-1/lost directly, replacing a recorded
  kill. It now goes through _finish_exited(reason="lost"), so the kill stands
  and the completion is still published by its owner.
- A log delta (or a log-rotation reset) fetched before a kill was applied
  after it, so the session's output no longer matched the kill's snapshot.
  Both are now dropped once the session has exited, decided under the lock.

A failed systemd scope stop could still count as a successful stop:

- A recovered PID that died between get() and _signal_kill ignored the stop
  result and returned a plain already_exited. It now reports
  scope_stop_failed.
- kill_all counted already_exited as stopped even when the scope stop
  failed, and only looked at _running, so a session that exited after being
  picked kept a live scope that no later sweep retried. Every kill path now
  stops the scope through _stop_scope, which marks a failed stop pending;
  kill_all does not count a result with scope_stop_failed and also retries
  finished sessions whose stop is pending.

_log_delta_command moves unchanged into tools/process_registry_env_log.py
(ProcessRegistry._log_delta_command stays as an alias) to keep
process_registry.py under its size cap.

Refs #132358. Both gaps were found by @andrexibiza in the review of #133434.

* fix(process): pruning keeps a session whose scope stop is pending

kill_all retries a failed scope stop through the finished session, but
_prune_if_needed still evicted that session by TTL (measured from process
start, so a long-running process was prunable the moment it finished) or
by capacity, leaving a possibly live scope with no retry target. Sessions
with _scope_stop_pending are now exempt from both until the stop succeeds;
capacity pruning drops the oldest prunable session instead.

Found by the independent review of #133648.

* fix(discord): buttons stop accepting a user revoked after connect

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)

* fix(discord): confirm a snapshot wildcard with the live check too

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.

* test(discord): trim component live-auth tests to two invariants

Keep the parametrized helper test (now also covering a '*' snapshot) and
the end-to-end revoked-user deny via send_exec_approval. Drop the
role-grant case: live_auth only gates the user-set branch, and the role
OR path is already covered by the existing *_accepts_role_allowlist tests.

* refactor(discord): move _component_check_auth into adapter_component_auth to keep adapter.py under its size cap

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.

* fix(discord): a programmatic voice join binds the requested text channel on every path

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.

* fix(update): drop the duplicate tmp-pack sweep from pack cleanup

#133567 taught git_pack_tidy to delete tmp_* transfer files, but
gitlock.clear_stale_tmp_packs already does that a few lines earlier in the
same update step (since Sep 6), with a stricter guard: it skips while any
git process runs. Remove the copy, keep the shared _unlink helper, and
correct the docs line about when the files go away.

* feat(telemetry): memory and compression rows say why they failed or were skipped

Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.

- hermes.memory.op.count and hermes.compression.count gain one closed-enum
  dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
  tools/memory_tool_store.py names its class (no_match, ambiguous,
  over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
  missing_content, missing_old_text, invalid_args, stale_entry, disabled,
  staged, gate_refused) through a context variable; the result the model
  sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
  compressor's closed names (exception:<Type> / rollback:<Type> keep only the
  prefix, anything else reads other, a classless failure unknown). Skipped
  rows carry the skip reason, and the skip set is now defined once in the
  contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
  valid so pending rows drain. Doc table rows and the consent copy (CLI
  setup and every Desktop locale's collectedActivity) name the new reason.

* fix(telemetry): compression verdict reads the stored class; consent offer names the reason

Review follow-ups for the failure_class split: finish_compression_attempt now
decides skipped/failed from the normalized class it records (a padded or
mixed-case skip name no longer reads failed with a skip reason, and a
non-string class no longer raises); the pre-chat consent offer names the new
reason like the setup copy; the setup copy keeps 80 columns; the doc says a
refused batch's op rows carry the class of the op that stopped it.

* fix(build): refuse a completed-install receipt whose packages lost their manifests

The reuse receipt check in node-deps.mjs accepted a tree where every locked
directory existed but package contents had been stripped (partial cleanup or
snapshot restore). npm trusts the hidden lockfile and never repairs such
entries, so every 'hermes update' skipped npm ci, the TUI build died on the
missing esbuild manifest, and the stale receipt kept the loop closed (#128935).
Probe each locked entry's package.json — the same file product builders
resolve tools through — so a gutted tree falls back to a real npm ci.

* fix(gateway): return a failed reconnect candidate's held inbound to its predecessor

7ef5aca075 (#133228) handed held Telegram inbound over only when a
replacement was published, on the premise that a candidate that fails to
connect never owns the hold queue. That is false: TelegramAdapter.connect
failure sets a retryable fatal error, so updates PTB already acked are held
on the candidate, and disconnect() salvages pending text batches into the
same queue. The runner then disposed the candidate and those events were
lost for good.

After every candidate dispose path (primary retryable and exception
branches, secondary connect-raised teardown, secondary not-installed
teardown and the outer secondary except), hand the candidate's held events
back to the retained predecessor with the existing hand_over_held_inbound.
The predecessor's queue then moves to the eventual replacement at publish,
and the candidate's successor weakref forwards any late hold.

Fixes #133399
Reported-by: j2h4u (Maxim Brashenko)

* test(gateway): cover held inbound on failed Telegram reconnect candidates

Parametrize the existing hand-over test over a candidate whose connect
returns False or raises, holding one event in connect and salvaging one in
disconnect; the published replacement must deliver all three once. Add one
secondary-path case: a not-installed candidate returns its holds to the
retained predecessor (#133399).

* fix(gateway): do not hand an installed reconnect adapter's hold queue back to its predecessor

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.

* refactor(gateway): name hand_over_held_inbound's params source/target

Gate finding C01 quality [Low]: since #133399 the helper also runs in the
reverse direction (failed candidate -> retained predecessor), so the
hand-back calls read hand_over_held_inbound(adapter, predecessor) against a
(predecessor, adapter) signature. Neutral source/target names match both
directions. Every caller passes positionally, so no call site changes.

* test(gateway): cover the hand-back when a secondary attempt's connect raises

Gate finding C01 quality [Low]: the secondary hand-back test stubbed
_secondary_reconnect_attempt, so only the not-installed branch ran and the
hand-back in the attempt's own configure/connect teardown could be deleted
with every test green. Add a connect-raises row that runs the real attempt
(profile/config seams patched) with a candidate whose connect raises after
holding; it is red without that hand-back. Same test function, no new one.

* test(gateway): pin where an installed secondary's held inbound lands on cancel

The cancel-after-install row asserted only that the salvaged event sat in
predecessor + failed combined, which passes wherever it lands, so it pinned
neither half of the r1 High fix (fold2 finding: mutating the secondary
installed-guard to `if True:` stayed green). Assert the installed adapter
keeps its queue, the predecessor stays empty, and the forward link points
predecessor -> installed adapter with no reverse link.

* feat(providers): add Solstice as a secret-launch OAuth inference provider

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.

* feat(providers): pre-release profiles stay off discovery until signed in

ProviderProfile gains `hidden` and `listed()`. A hidden profile is left
out of every surface that offers providers (the hermes model / setup
lists, /model and the TUI inventory, the provider catalog behind the
dashboard accounts tab, the hermes auth add picker) until the user signs
in by name and its credential pool holds a row. Resolution by name is
never gated, so --provider, model.provider and `hermes auth add <name>`
keep working.

This replaces the PR's global _HIDDEN_GATES registry plus a per-provider
enable function, which returned True unconditionally and showed the
provider to every user.

* feat(auth): brokered PKCE, expiry-driven rotation and aux on plugin OAuth rows

Generic plugin-OAuth support that the Solstice provider needs. None of it
names a provider:

- OAuthPKCEConfig.token_request: a confidential client whose secret lives
  behind a broker hands the grant to the plugin in place of a token_url
  POST. The old refresh_token is kept when the response omits one, which
  Google does on refresh.
- Plugin OAuth rows rotate 2 minutes ahead of their stored expires_at_ms
  (shared with the anthropic rule). Opaque bearers such as Google's ya29.*
  carry no JWT exp, so this stored expiry is the only clock they have.
- A row whose refresh grant just went DEAD stays DEAD on the inference 401
  that follows; before this it was re-benched EXHAUSTED and the dead token
  was replayed every cooldown.
- The auxiliary client serves an OAuth plugin that ships create_client from
  its pooled row on the main runtime's endpoint (compression, titles). It
  used to return "not directly supported".
- A 401 on a plugin OAuth row prints a re-sign-in hint, not the API-key
  advice.
- The Gemini native adapter exposes its method names, auth headers and
  error builder so a Bearer-auth subclass can reuse it without the API-key
  remedies.

* refactor(solstice): rebuild the provider as a standard model-provider plugin

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.

* fix(providers): listed filter reads the live provider table; parity covers the hidden case

listed_canonical_providers() read the CANONICAL_PROVIDERS binding
captured in models_catalog_static at import. Every list consumer used to
read hermes_cli.models.CANONICAL_PROVIDERS, so a provider appended there
at runtime vanished from list_available_providers(). It now reads the
models binding again.

The desktop parity contract now covers the listed universe in both
directions: every provider `hermes model` offers is configurable, and an
unlisted pre-release provider appears on neither Providers tab.

* chore: map pberard@nvidia.com to nv-pberard

Co-author of the #129431 salvage (Co-authored-by trailer on the
contributor fix commit).

* fix(compaction): bench a failing summary model instead of blocking compaction

Two fallback summaries in a row tripped the anti-thrash breaker even when
each fallback compaction was effective, so automatic compaction was blocked
and the session rode into the provider's hard context limit. Only
ineffective compactions now trip the breaker; a fallback streak >= 2 instead
benches the summary model (deterministic drop, one probe per recovery
window), which still stops the paid fallback loop from #63008. force=True
(/compress) always calls the model.

Salvaged from #129431.

Co-authored-by: Pascal Bérard <pberard@nvidia.com>

* fix(compaction): persist the summary-model bench probe deadline

The bench probe clock was in-memory monotonic time, so every rebuilt
compressor (the gateway rebuilds the AIAgent on cache eviction) came up
with a durable fallback streak but no clock, armed a fresh 300s window and
benched again. If rebuilds arrive faster than the window, the summary model
is never re-probed and the session stays on deterministic summaries until
a manual /compress.

Store the deadline as wall-clock time on the session row (model_config
key, the same durable path the proactive-prune runway uses) and load it in
bind_session_state, mirroring the anti-thrash recovery deadline (#100185).
A deadline more than one window in the future (clock jumped back) is
re-armed from now.

* test(compaction): trim bench tests to the two invariants

Keep one test per invariant: effective fallback compactions no longer block
auto-compaction (test_fallback_boundaries_wait_for_real_effectiveness_verdict)
and the bench probe deadline survives a compressor rebuild (which also pins
that a streak >= 2 benches the summary model). Drop the window-arithmetic
tests that pinned the old in-memory clock and the unrelated session-reset
counter test.

* refactor(compaction): move pre-LLM summary skips into context_compressor_prellm

Code-health ratchet: agent/context_compressor.py grew past its cap on main.
Move _feasibility_skip, _fallback_streak_skip and _set_fallback_probe_at
(plus their constants) byte-identical into a PreLlmSkipMixin in the new
agent/context_compressor_prellm.py sibling, matching the existing
SummaryDispatchMixin layout, to keep context_compressor.py under its size
cap. No behaviour change; no test patches the moved names.

* test(compaction): move the bench probe rebuild test into its own file

Code-health ratchet: tests/agent/test_context_compressor.py grew to
3847 > 3829. New tests go in a topical test_<stem>_<topic>.py.

* fix(compaction): carry the bench probe deadline across rotating compaction

Rotating compaction (compression.in_place: false) calls on_session_start
with boundary_reason="compression"; bind_session_state zeroes
_fallback_probe_at and reloads it from the fresh child row, which has no
model-config key. The fallback streak was carried but the deadline was
not, so the benched summary model was re-benched with a new window on
every rotation and never probed again. Capture the deadline before the
rebind and persist it onto the child row alongside the streak.

* refactor(compaction): own the bench probe deadline load in PreLlmSkipMixin

The mixin already owns _set_fallback_probe_at and the model-config key;
the matching load lived inline in bind_session_state, which forced
context_compressor.py to import the key. Move it to
_load_fallback_probe_at() and make the key private to the mixin module.

* refactor(compaction): refresh stale skip/anti-thrash wording

record_completed_compaction carried two overlapping comments and logged
"via pre-LLM feasibility skip" although the path now also covers the
benched summary model; the anti-thrash comment still said it drops
"counters" when only the ineffective count is reset. Split the long
streak/skip tuple assignment in the stall-retry path and drop the
`is True` hedge (the flag is always a bool on the built-in compressor).

* test(compaction): trim the redundant fallback-streak gate tests

The rotation-state gate test now pins only that a durable fallback
streak never blocks should_compress (renamed to say so); its
clear-and-reload tail re-tested the loader, not the gate.
test_fallback_streak_alone_does_not_block_compaction duplicated
test_fallback_boundaries_wait_for_real_effectiveness_verdict, which
already asserts should_compress stays open with a streak of 2.

* refactor(compaction): name the pre-LLM summary skip in the fallback log

_fallback_summary_for_window logged "Feasibility skip" and the compress()
comment said "feasibility skip", but the flag is now also set when the
summary model is benched. Say "Pre-LLM summary skip" so the log matches
both causes. Wording only; no test asserts the old text.

* fix(desktop): a resume keeps the owner hint of a session with no row

#132017 started dropping a remembered owner hint unless a connection-tagged
cached row agreed with it. Hidden sessions (Bot Chat, plugin opens) never
get a sidebar row, and for them the hint is the only owner record, so a
remote bot's pathname-driven resume lost its route and went to the local
primary: Desktop core E2E remote-secondary has been red on main since.
Only an untagged or disagreeing row now makes the hint stale; no row
keeps it.

* ci: Desktop session open/resume changes run the core E2E

Every core spec drives open, resume and switch, yet changes under
apps/desktop/src/app/session/ skipped the suite on the PR; #132017
changed resume that way and main went red on remote-secondary.

* fix(pm): cold updates on Python < 3.13 trust a stored intermediate CA

An install still on its legacy 3.11 venv runs its first `hermes update`
from that interpreter, which downloads uv and Python 3.14 with plain
urllib before PM's worker (and truststore) exists. Windows can cache Let's
Encrypt "Root YR" in its intermediate store without ISRG Root X1; GitHub's
asset CDN now chains through Root YR, so 3.11 rejected every download with
"unable to get issuer certificate" while PowerShell and 3.14 accepted it.

Python 3.13+ sets VERIFY_X509_PARTIAL_CHAIN by default. Set it on the PM
downloader's opener so the legacy interpreter applies the same rule;
hostname, expiry and pinned sha256 checks are unchanged.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>

* chore: map LuwenOosten for salvage of #133340

* fix(telegram): reuse the shared platform TLS context for every Telegram client

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.

* refactor(telegram): import platform_ssl_context at module top in the adapter

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>

* fix(ssl): honour SSL_CERT_FILE in the shared Telegram TLS context

Gate finding C02-1 (High regression): before this stack Telegram passed
verify=True, and httpx built create_default_context(cafile=$SSL_CERT_FILE),
so a private CA supplied only through SSL_CERT_FILE was trusted.
platform_ssl_context() always returned the truststore platform context,
so on macOS that CA was rejected (probed on a real local TLS server).

When SSL_CERT_FILE names an existing file, return the cached bundle
context from _shared_context(path) — still one load per process, never
per reconnect. A missing or stale path keeps the platform context.
SSL_CERT_DIR (BASE used capath) stays uncovered.

* refactor(telegram): import platform_ssl_context at module top in telegram_network

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.

* test(telegram): cover the DoH discovery client in the shared TLS context test

Gate finding C02-3 (Low): the kept test only checked the PTB clients, so
dropping verify= from the discover_fallback_ips AsyncClient still passed
(mutation probe). Record the AsyncClient kwargs inside the same test.

* test(telegram): drop raising=False from the platform_ssl_context patch

Gate quality Low (C02 r1): platform_ssl_context is a real module-level
import in the adapter, so raising=False only hides a rename by planting
an unused attribute instead of failing at setup.

* fix(ssl): trust SSL_CERT_FILE on top of the platform store, not instead of it

Gate finding (C02 fold2 #1, High regression): platform_ssl_context()
built the SSL_CERT_FILE context from the un-injected stdlib class with
only that bundle loaded. Before this stack the Telegram clients used
httpx verify=True, i.e. ssl.create_default_context(cafile=SSL_CERT_FILE)
on the truststore-injected class, which trusts the bundle PLUS the OS
store. An operator exporting a private-CA-only SSL_CERT_FILE therefore
lost api.telegram.org (SSLCertVerificationError, live-probed).

Give the env bundle union semantics under its own cache key: the context
is the injected ssl.SSLContext (PROTOCOL_TLS_CLIENT: CERT_REQUIRED +
hostname checking) with the bundle loaded on top. A provider ssl_ca_cert
keeps its replace semantics through resolve_httpx_verify unchanged.

* test(ssl): assert the platform_ssl_context contract in the ssl_verify tests

Gate finding (C02 fold2 #2, quality): the SSL_CERT_FILE contract of
platform_ssl_context (bundle honoured on top of the platform store,
cached once, distinct from the plain platform context, CAs loaded) is an
agent/ssl_verify.py behaviour, but it was asserted inside the Telegram
close-wait test. Move it into the existing SSL_CERT_FILE test in
tests/agent/test_ssl_verify.py (no new test function); the Telegram test
keeps only its every-client-shares-the-context checks.

* refactor(ssl): collapse the duplicated union arm in _shared_context

The SSL_CERT_FILE (union) arm repeated the platform arm line for line
plus a load_verify_locations call. Build the context from one of two
classes (stdlib for a replacing provider bundle, injected for everything
else) and load the bundle once when there is one.

Equivalence probed on 44a807c2c7 vs this head for (path, False),
(path, True), (None, False): identical type, verify_mode, check_hostname
and cert_store_stats().

Fold3 finding 1 (reuse/quality Low).

* test(ssl): pin the SSL_CERT_FILE context to its union cache slot

The getattr(bundle_ctx, '_ctx', bundle_ctx).cert_store_stats() hedge
asserted nothing a non-empty certifi bundle would not satisfy either way.
Assert instead that platform_ssl_context() returned the (bundle, union=True)
cached context, which goes red if the platform path ever builds a
replacing (union=False) bundle context.

Fold3 finding 2 (quality Low).

* docs(ssl): note SSL_CERT_FILE sits on top of the platform store

The module docstring still said only explicit per-provider config sits
above the platform store; platform_ssl_context() now honours an exported
SSL_CERT_FILE bundle in addition to it.

Fold3 finding 3 (quality Low).

* docs(user-stories): add 45 business and professional stories (#133708)

Prepends 45 verified first-person stories of Hermes Agent used at work
(companies, agencies, practices, shops and solo operators) to
userStories.json. Every quote is verbatim from the linked public source.
No existing entries are changed or removed.

Co-authored-by: witcheer <208820874+notwitcheer@users.noreply.github.com>

* feat(plugin-catalog): add fxmacrodata

* chore: map contributor email for @roberttidball

* chore(plugin-catalog): fxmacrodata disclosure at merge

Normalizes 'Disclosure:' to 'Disclosure —' and appends the reviewed note on the system-prompt section, tool count and remote MCP behaviour (salvage of #133564).

* catalog: bump honcho (pin 3dd3b7a)

Adopts plastic-labs/honcho#1203: the plugin sends X-Honcho-Host and
X-Honcho-Plugin on its Honcho client. No capability changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(plugin-catalog): honcho disclosure at merge

* chore: map contributor email for honcho (#133486)

* fix(update): verify a parked branch from the commit graph before git cherry

git cherry's patch-id walk needs upstream-side trees, which a tree:0
partial clone lazy-fetches in many promisor batches; one failed batch
made the guard call a clean checkout unverifiable and skip the update
(#124767). Count parked-only commits with rev-list (commit graph only)
first, keep cherry as a refinement, and degrade a cherry failure to the
conservative commit count instead of unverifiable.

* fix(update): the parked-branch cherry refinement never lazy-fetches

`_assess_parked_branch_switch` still ran `git cherry origin/<target>` whenever the
commit-graph count found parked-only commits. On a tree:0 partial clone that
patch-id walk lazy-fetches a tree batch per upstream commit, and nothing bounded
it: the #131444 trace identifies exactly this child (`git cherry origin/main`,
6 h 45 m, 332 packs / 180 GiB) as the writer behind the silent disk runaway.

Run the refinement with GIT_NO_LAZY_FETCH=1 (the NO_LAZY_FETCH_ENV every other
read-only updater probe already uses). A missing object now fails `git cherry`
fast and the conservative rev-list count stands in, so the guard can never turn
into a fetch loop; full clones keep the patch-equivalence refinement.

Test: a reachable promisor remote, a parked branch one commit ahead — the guard
must not add a single pack. Red on origin/main and on #124777 alone (4 packs
after vs 2 before), green here.

Refs #131444. Supersedes the bound/offline variants in #125529, #126693, #128335.

* chore(tests): explicit encodings in the parked-branch guard tests

The Windows footgun ratchet flags every bare read_text()/write_text() in a
touched file; this file predates the rule.

* test(update): the parked-branch fetch guard is proven by a Trace2 fetch count

Review on #132378 (@andrexibiza) showed the regression oracle was the net pack
count: with foreground auto-GC (gc.auto=1, autoPackLimit=1, autoDetach=false)
a guard with the override removed still made two lazy fetches and left the
count at 2 -> 2. The test now captures Trace2 around the assessment and
requires zero fetch child starts, pins global/system git config, and runs
plain and foreground-GC variants; removing the override fails both.

The cherry child's environment now comes from noninteractive_git_env() like
the other no-lazy-fetch callers, instead of a raw os.environ copy that the
spawn-env guard rejects.

* plugin-catalog: add session-token-counter

* chore: map vsemagush@gmail.com to @Vsemagush for attribution (catalog PR #133144)

* feat(plugin-catalog): add hermes-teammates plugin

* chore(catalog): pin pass-secrets @ 485f105 — publish-identity scrub (replaces 55189d3, same tree)

* feat(plugin-catalog): add error-ledger

* feat(plugin-catalog): add the error-ledger catalog card

* feat(plugin-catalog): add stalkchain

* chore(plugin-catalog): stalkchain disclosure at merge

Applies the reviewed Disclosure line from the catalog sweep review (catalog PR #133356).

* feat(plugin-catalog): add bento plugin

* chore(plugin-catalog): bento disclosure at merge

Applies the reviewed Disclosure line from the catalog sweep review (catalog PR #133524).

* fix(catalog): refresh Parallel Search listing status

* feat(catalog): add Tabro local browser automation plugin

* fix(catalog): pin Tabro browser compatibility and host gate update

* fix(catalog): pin Tabro relay and installation review fixes

* chore(plugin-catalog): tabro disclosure at merge

* chore: map contributor email(s) for PR #132619

* Re-pin agora to v2.0.7 (fix #3: dashboard 500)

* Re-pin agora to v2.0.7 (7e7e270 — includes test fixture fix)

* Re-pin agora to v2.0.7 (auto-patch finder + test fixes)

* Re-pin agora to v2.0.8 (security-clean shim per review)

* Re-pin agora to v2.0.8 (b78b42a — cold-load fix per review)

* Re-pin agora to v2.0.8 (5e90c30 — security-clean, no core override)

* Re-pin agora to v2.0.9 (fix #5: completion gate missed todo/triage/review)

* fix(agent): gate reasoning-only answer promotion by route

A clean-stop response with empty content and a reasoning payload was
promoted to the visible answer on every route. A field name is not
provenance: OpenRouter, DeepSeek and other adapters put private
chain-of-thought in `reasoning`, so users saw the model's private
thoughts as the reply (#111761, #114082). Exhausted recovery also echoed
a 500-char reasoning preview.

Promotion now requires a trusted route: an explicit
`answer_in_reasoning` runtime capability, a custom_providers per-model
opt-in, or the local Nemotron-3.5-Lightning parser route from #109205.
The decision lives in runtime_capabilities and is re-derived at startup,
fallback activation, primary restore and model switch. Exhausted
recovery returns the empty sentinel without a reasoning preview.

Rebuilt by hand on current main (keeps the Anthropic signed-thinking
skip and the _refund_api_call import added since the PR's base).

Salvaged from #112833 (a339569d77, 15a19b2fc0, 1d240e7b24).

* refactor(agent): drop defensive capability try/except and dead reasoning_only copy

get_custom_provider_model_capability already returns None when the
config cannot be read, so the try/except around it in
answer_in_reasoning_capability only hid real bugs.

_terminal_empty no longer returns site_copy("reasoning_only") with a
reasoning preview, and nothing else references that entry, so remove it
instead of leaving dead copy behind.

* test(agent): trim reasoning-promotion tests to two invariants

Keep two behavioural invariants for the route gate: an untrusted
OpenRouter/DeepSeek route retries past private reasoning to the visible
answer while the local Nemotron parser route still promotes in one call
(one parametrized test), and exhausted recovery never echoes private
reasoning. The capability-helper, constructor-plumbing and fallback-
restore unit tests duplicate what those end-to-end paths already cover.

* refactor(agent): derive reasoning-promotion trust live in agent/reasoning_promotion

The code-health ratchet blocks growth of agent_runtime_helpers.py
(3909), agent_init.py (2527), chat_completion_helpers.py (4122) and
finish_text_response (309 lines); the gate added 60+ lines to those
facades.

Move answer_in_reasoning_capability into a topical sibling and evaluate
it from the agent's live route at the promotion site instead of caching
the decision in runtime_capabilities. That removes the plumbing at
startup, primary snapshot/restore, switch_model and fallback activation:
a live check…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P0 Critical — data loss, security, crash loop platform/discord Discord bot adapter sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants