Repository navigation
fix(gateway): voice-channel turns render the same session prompt as text turns - #127944
matttmcfarland wants to merge 5 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
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 toself._client.get_user(int(user_id))before giving up —User.display_nameis 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.
| 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 |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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) makestest_voice_turn_renders_the_same_prompt_as_a_text_turnfail — the review-fix line is load-bearing, verified by removal rather than by trusting the suite. _ephemeral_change_keyno longer keys on message-id presence while thetest_prompt_tail_freeze.pyinvariant 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
#126167collision 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 ongateway/session.py:336. - F2 (P2) —
channel_promptis 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 achannel_promptsentry. See inline ongateway/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.pyremains open and is load-bearing. See inline ongateway/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.
| # 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 " |
There was a problem hiding this comment.
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.
| 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).""" |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
|
The advertised text/voice parity is still incomplete at 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
Checked September 30, 2026. The current REST PR metadata reports 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
F2 matters even when 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
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 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 Metadata lifetime and capture lifetime must be repaired separately.
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
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
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
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
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 Executable receipts and their limits
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
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:cacheproviderFor the original base comparison, use an isolated copy at the reviewed head, restore 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 controlsSave the following exact file as 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:cacheproviderFile SHA-256: """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" |
…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).
dce6efe to
7eef630
Compare
teknium1
left a comment
There was a problem hiding this comment.
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: everyci.yamldispatch on it was a zero-jobstartup_failure, which is what a fork merge-base that predates a reusable workflowci.yamlnow 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. 295a01f3fix(discord): a voice speaker outside the member cache still gets a name— theget_userfallback 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; aMagicMock-shaped client no longer leaks a non-string "name" (theisinstance(str)guard). The existing adapter test gained the member-miss/global-hit case and the global-only case; its oldis Noneassertions were only true because nothing else was consulted.dce6efe's empty retrigger commit is carried as7eef630f(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_promptresolved without the thread's parent), F4 (native-slash thread label), F5 (voiceauto_skill), F6 (join-time channel name/topic snapshot) — all real, all pre-existing onmain, 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: keepsource.message_id = None(load-bearing, verified by deletion) and thevoice_member_display_namelookup 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.
|
@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 |
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).
|
Pushed |
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)
|
Thank you @matttmcfarland. Your diagnosis and the vLLM timings made this problem easy to understand and fix. Everything in this PR is now on
Thanks also to @andrexibiza for independently reproducing the uncached-speaker case. |
* 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…
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:
GatewayVoiceMixin._voice_input_sourcesetuser_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 newDiscordAdapter.voice_member_display_name. If the member isn't cached, it falls back to the name bound at/voice joinfor that same user, else the ID.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_idpresence 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
Changes Made
gateway/run_voice.py:_voice_input_sourceuses 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 onmessage_id.gateway/run_agent_cache.py:_ephemeral_change_keydrops message-id presence.tests/gateway/test_voice_session_prompt.py(new):How to Test
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_voice_input_sourcepasses (106), with no new failures againstmain(99).Checklist
Code
pytest tests/ -qand all tests pass: I ran the prompt-pin, session-context and voice test files listed above, not the full suiteDocumentation & Housekeeping
cli-config.yaml.example,CONTRIBUTING.md/AGENTS.md, tool schemas: N/A🤖 Generated with Claude Code