Repository navigation
fix(discord): an utterance transcribed across a /voice join rebind is not posted to the new channel - #133471
Conversation
There was a problem hiding this comment.
Review — #133471 at 99b701a897aaead7e1d712b9b20a41bf76cc8934
P1 remains: audio already buffered under conversation A can still be assigned to B before the new guard samples its identity. The salvage repairs rebinding during transcription and preserves the two-utterance batch repair, but it also carries forward the receiver-buffer finding already posted on #130312. It does not yet establish #130311's end-to-end conversation-ownership invariant.
The original contributions are preserved in 408dc714 and 0164e467, both authored by jonpol01. This review distinguishes that effective repair from the unresolved boundary; it does not attribute the inherited failure to this salvage.
SSA — producer, binding owner, consumers, and cleanup
| Boundary | Exact implementation | What owns the state |
|---|---|---|
| Decoded audio enters the system | PCM append | Receiver stores PCM by SSRC plus a timestamp, without a text-channel identity. |
| Audio waits or is drained | check_silence / flush_pending | Polling and leave-flush both emit (user_id, pcm_bytes), not capture-time ownership. |
| Conversation changes | same-VC join early return → gateway binding/source publication | Existing receiver survives; gateway replaces the guild's text-channel and source maps. |
| Identity is assigned | poll-batch capture / leave-batch capture | The caller reads the current binding after draining the receiver. |
| Transcription and dispatch | post-STT comparison → gateway source/auth selection → echo, prompt, MessageEvent | The comparison preserves a correctly captured identity, but cannot correct one assigned too late. |
%%{init: {'theme':'dark','themeVariables':{'background':'#0d1117','primaryTextColor':'#e6edf3','lineColor':'#94a3b8'}}}%%
flowchart TB
subgraph CORE[" "]
direction TB
subgraph CAPTURE["Receiver boundary: owns PCM, not a conversation"]
direction TB
P["Decoded PCM append<br/>_buffers + packet timestamp"]:::normal
R["check_silence<br/>1.5 s silence, 0.5 s speech"]:::transition
F["flush_pending<br/>leave before silence"]:::transition
P --> R
P --> F
end
subgraph OWNERSHIP["Binding boundary: independent mutable maps"]
direction TB
J["Same voice channel join<br/>keeps the existing receiver"]:::authority
M["Gateway publishes new<br/>text channel + source"]:::authority
J --> M
end
C["Poll or flush caller<br/>samples captured_for NOW"]:::failure
T["WAV / STT await"]:::transition
G{"Current channel equals<br/>captured_for?"}:::authority
D["No: discard stale batch"]:::success
V["Yes: gateway reads mapping<br/>selects source and prompt"]:::authority
E["Transcript echo + MessageEvent<br/>to selected text conversation"]:::transition
R --> C
F --> C
M -. "sample current value" .-> C
C --> T --> G
G -- "No" --> D
G -- "Yes" --> V --> E
end
CORE ~~~ LEGEND
LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Amber: transition / pending<br/>Green: success<br/>Red: failure"]
style CORE fill:#0d1117,stroke:#30363d,color:#e6edf3
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px
classDef normal fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px
classDef authority fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px
classDef failure fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px
classDef success fill:#052e16,stroke:#4ade80,color:#f0fdf4,stroke-width:2px
click P "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L889-L895" "Decoded PCM append" _blank
click R "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L925-L951" "Silence extraction" _blank
click F "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L953-L970" "Leave flush extraction" _blank
click J "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3609-L3631" "Same-voice-channel early return" _blank
click M "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/gateway/run_voice.py#L168-L184" "Gateway publishes channel and source" _blank
click C "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3844-L3855" "Identity sampled after extraction" _blank
click T "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3861-L3875" "Transcription awaits" _blank
click G "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3876-L3883" "Channel equality guard" _blank
click D "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3876-L3878" "Guard drops mismatched channel" _blank
click V "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/gateway/run_voice.py#L255-L276" "Snapshot and authorization" _blank
click E "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/gateway/run_voice.py#L277-L296" "Echo, prompt, and event consumer" _blank
classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px
style CAPTURE fill:#0d1117,stroke:#30363d,color:#e6edf3
style OWNERSHIP fill:#0d1117,stroke:#30363d,color:#e6edf3
F1 · P1 — receiver-owned PCM is relabeled at extraction, not invalidated at rebind
Location: plugins/platforms/discord/adapter.py:3846, _voice_listen_loop; the sibling is plugins/platforms/discord/adapter.py:3660, leave_voice_channel. Classification: pre-existing, previously reported, still present at this head.
The decisive distinction is capture time versus completion time. VoiceReceiver accumulates PCM before the 1.5-second silence decision. A successful same-voice-channel join does not replace or clear that receiver. When the gateway subsequently publishes B, an A-owned buffer can therefore remain pending. The next poll drains that old buffer and records captured_for = B; after transcription, B == B passes. The gateway callback then selects B's source, echoes into B, resolves B's channel prompt, and constructs a B-targeted message event.
The normal authorization checks do not repair this: an allowed speaker is still an allowed speaker, but their earlier utterance has acquired a different conversation destination. With different channel audiences this can disclose the transcript; independently of audience differences, it contaminates B's conversation and prompt context. The executed evidence below demonstrates routing into synthetic sinks, not an observed disclosure on a live server.
This includes the cleanup side of the same defect. leave_voice_channel flushes before stopping the receiver, reads the current binding, transcribes that pending batch, and only later removes the mapping. Leaving shortly after a rebind can therefore deliver the stale PCM without waiting for silence. Treating this as only a listen-loop change would leave the second drain consumer open.
Executed evidence and negative controls
I ran six offline tests in Python 3.13.5 using method bodies extracted from the pinned receiver, adapter join/leave/listen/STT methods, and gateway join/input/source methods. The composed tests invoke the same-VC join and gateway binding-publication methods; they do not merely assign the new map in place of those methods. Mapped synthetic PCM is seeded at the decoded-buffer boundary. Codec/STT, Discord transport, authorization outcomes, source serialization, and final agent execution are stand-ins.
| Interleaving / assertion | Head 99b701a897 |
Extracted base 20ae6087ff |
|---|---|---|
| 96,000 bytes buffered under A; rebind while still pending; later silence extraction must not deliver to B | Fails: one B echo, B event, prompt-800 |
Same failure |
| 57,600 bytes under A + 57,600 after rebind; old samples must not enter B | Fails: combined 115,200-byte input becomes one B echo/event | Same failure |
| 96,000 bytes under A; rebind, then leave at 0.2 s, below the silence threshold | Fails: one B echo/event before cleanup | Same failure |
| Two valid utterances, binding unchanged | Pass: two A echoes/events | Pass |
| Rebind during first STT of an already-emitted two-item A batch | Pass: both discarded | Fails: both routed to B |
| Rebind during the echo await, after callback source selection | Pass: event and prompt remain A | Pass |
Commands: python -m pytest -q -s test_review_voice.py → 3 failed, 3 passed, exit 1; REVIEW_REVISION=base python -m pytest -q -s --tb=short test_review_voice.py → 4 failed, 2 passed, exit 1. These are deliberately failing reviewer regression assertions, not results from the repository's canonical suite. The extracted baseline undoes the inspected guard delta; it is not a full base checkout. The gateway file has the same blob SHA at both inspected revisions.
The last control also rules out an unsupported extra finding: there is no demonstrated reroute between the guard and this callback's initial synchronous source snapshot. The remaining failure is earlier, inside capture ownership.
Runnable offline regression for the receiver-to-poller boundary
Save as voice_pending_regression.py outside the checkout and run python /path/to/voice_pending_regression.py plugins/platforms/discord/adapter.py from the reviewed revision. This extracts only the relevant methods, uses harmless synthetic PCM, and never connects to Discord or a transcription service. It isolates the late-sampling boundary; the composed gateway/flush evidence is reported separately above.
The same script executed against the pinned source-extracted copy printed {'unchanged': [700], 'pending_pcm_rebound': [800]}, then failed the no-reroute assertion. It has not been represented as a full repository import.
from __future__ import annotations
import ast
import asyncio
import logging
import os
import sys
import tempfile
import threading
from collections import defaultdict
from pathlib import Path
from types import ModuleType, SimpleNamespace
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("plugins/platforms/discord/adapter.py")
tree = ast.parse(path.read_text())
selected = {
"VoiceReceiver": {"__init__", "check_silence"},
"DiscordAdapter": {"_voice_listen_loop", "_process_voice_input"},
}
classes = []
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name in selected:
node.bases, node.keywords, node.decorator_list = [], [], []
constants = {"SILENCE_THRESHOLD", "MIN_SPEECH_DURATION", "SAMPLE_RATE", "CHANNELS", "_KEEPALIVE_INTERVAL"}
node.body = [n for n in node.body
if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id in constants for t in n.targets)
or isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name in selected[node.name]]
classes.append(node)
assert len(classes) == 2, "expected receiver and adapter definitions in input source"
namespace = dict(globals(), logger=logging.getLogger("offline-review"),
time=SimpleNamespace(monotonic=lambda: 2.0))
module = ast.Module(body=[ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0), *classes], type_ignores=[])
exec(compile(ast.fix_missing_locations(module), str(path), "exec"), namespace)
Receiver, Adapter = namespace["VoiceReceiver"], namespace["DiscordAdapter"]
Receiver.pcm_to_wav = staticmethod(lambda pcm, path: None)
for name in ("tools", "tools.voice_mode_transcript", "tools.transcription_tools"):
sys.modules[name] = ModuleType(name)
sys.modules["tools.voice_mode_transcript"].is_whisper_hallucination = lambda text: False
sys.modules["tools.transcription_tools"].transcribe_audio = lambda path: {"success": True, "transcript": "synthetic sample"}
async def check(bound_after):
receiver = Receiver(SimpleNamespace())
receiver._running = True
receiver._ssrc_to_user[7] = 42
receiver._buffers[7] = bytearray(b"\x01\x00" * 48000) # 0.5 s, 48 kHz, stereo s16
receiver._last_packet_time[7] = 0.0
adapter = Adapter.__new__(Adapter)
adapter._voice_text_channels = {1: 700}
adapter._voice_receivers = {1: receiver}
adapter._client = None
adapter._is_allowed_user = lambda *a, **kw: True
adapter._reset_voice_timeout = lambda *a: None
observed = []
async def callback(**kw):
observed.append(adapter._voice_text_channels[1])
adapter._voice_input_callback = callback
drain = receiver.check_silence
def one_poll():
result = drain()
receiver._running = False
return result
receiver.check_silence = one_poll
# Isolate the late-sampling boundary: existing decoded PCM precedes rebind.
adapter._voice_text_channels[1] = bound_after
await asyncio.wait_for(adapter._voice_listen_loop(1), timeout=2)
return observed
async def main():
unchanged, rebound = await check(700), await check(800)
print({"unchanged": unchanged, "pending_pcm_rebound": rebound}, flush=True)
assert unchanged == [700], "positive control must deliver"
assert rebound == [], "old PCM acquired channel 800 after capture"
asyncio.run(main())RCA — why the guard works in one window and misses the other
%%{init: {'theme':'dark','themeVariables':{'background':'#0d1117','primaryTextColor':'#e6edf3','lineColor':'#94a3b8'}}}%%
flowchart TB
subgraph CORE[" "]
direction TB
subgraph OPEN["P1 still open: capture ownership is assigned after rebinding"]
direction TB
A["PCM enters receiver under A"]:::normal
B["Same-VC join publishes B<br/>A-owned PCM remains buffered"]:::authority
C["Silence poll OR leave flush<br/>returns the old PCM"]:::transition
D["captured_for = B<br/>guard later compares B == B"]:::failure
E["Old PCM becomes B echo,<br/>B event, and B prompt input"]:::failure
A --> B --> C --> D --> E
end
subgraph CLOSED["Window repaired by this PR: rebinding during STT"]
direction TB
H["Batch emitted under A<br/>captured_for = A"]:::normal
I["STT awaits; join publishes B"]:::authority
K["B != A<br/>both batch items discarded"]:::success
H --> I --> K
end
end
CORE ~~~ LEGEND
LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Amber: transition / pending<br/>Green: success<br/>Red: failure"]
style CORE fill:#0d1117,stroke:#30363d,color:#e6edf3
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px
classDef normal fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px
classDef authority fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px
classDef failure fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px
classDef success fill:#052e16,stroke:#4ade80,color:#f0fdf4,stroke-width:2px
click A "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L889-L895" "PCM buffer creation" _blank
click B "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/gateway/run_voice.py#L168-L184" "Current binding overwrite" _blank
click C "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L925-L970" "Both buffer-drain consumers" _blank
click D "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3844-L3883" "Late capture and equality guard" _blank
click E "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/gateway/run_voice.py#L255-L296" "New conversation source and event" _blank
click H "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3844-L3855" "Once-per-batch capture" _blank
click I "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3868-L3870" "Transcription awaits" _blank
click K "https://github.com/NousResearch/hermes-agent/blob/99b701a897aaead7e1d712b9b20a41bf76cc8934/plugins/platforms/discord/adapter.py#L3876-L3878" "Effective mismatch rejection" _blank
classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px
style OPEN fill:#0d1117,stroke:#30363d,color:#e6edf3
style CLOSED fill:#0d1117,stroke:#30363d,color:#e6edf3
Required repair: bind ownership when a receiver buffer begins, and preserve it through both drain paths. Either carry the originating conversation/binding generation with PCM, or invalidate pre-rebind receiver state atomically with binding publication under synchronization shared with packet append. Already-emitted batches must retain the existing post-STT protection. A map assignment followed by an unrelated buffer clear is not a complete concurrency contract. Update channel and source together; preserve the intended same-conversation rejoin behavior.
Regression contract: exercise actual pending receiver state through a successful same-VC rebind, active speech spanning the change, and leave-flush before silence. Assert that no pre-rebind samples reach B's transcript echo or message event; retain the unchanged-binding positive control and the two-item STT-rebind negative control. Also exercise append versus invalidation ordering so a packet cannot re-create an old buffer with new ownership. Do not stop at callback counts from an immediately completed fake batch.
Prior review and claim reconciliation
| Earlier finding / current claim | Reconciled status |
|---|---|
| Original mid-STT reroute and Enough1122's later-item batch gap | Repaired in the tested extraction: both already-emitted A items are discarded after rebinding to B. |
Earlier read-before-try missing-attribute concern in _process_voice_input |
That new entry read is gone; comparison uses getattr. No repeat finding. |
| My receiver-buffer P1 on #130312 | Still present, with pending, spanning-speech, and leave-flush evidence above. |
| New regression test establishes capture-time ownership | It establishes only the emitted-batch/STT window. Its immediate fake batch never traverses receiver buffering or silence detection. |
| New documentation says queued utterances are dropped | Too broad while receiver-queued PCM can acquire B's identity. Close that behavior, not just the test description. |
| Voice-info mixin extraction | Diff and moved implementation inspected; no additional demonstrated defect retained. This is not a claim that the full suite passed locally. |
Verification limits and current CI
The head remained 99b701a897aaead7e1d712b9b20a41bf76cc8934 at recheck. GitHub reports CI run 37361872843 as failed; its “All required checks pass” job is cancelled. Nix and Docker completed successfully. I have not attributed that CI failure to this patch without a failing-job diagnosis.
No full checkout/import, canonical test suite, real ffmpeg/STT/Discord run, native multi-OS run, or integration run with #133463 was executed in this review. The Mermaid sources were checked for SHA-pinned clickable nodes and legend-matched colors; local rendering could not be verified (mmdc unavailable and browser navigation prohibited).
Assessment: the STT/batch mitigation is valid, but F1 remains P1. Please close receiver-buffer ownership across poll and leave-flush before treating this salvage as completing #130311.
… not posted to the new channel _process_voice_input awaits WAV conversion and STT, then calls the gateway callback with only guild, user and transcript, and the callback routes by the guild's binding as it is at that moment. A `/voice join` from another text channel while the bot is already connected rewrites _voice_text_channels and _voice_sources, so an utterance captured for the old conversation became a turn in the new one, with its channel prompt, bound skills, transcript echo and replies. The bound text channel is captured when the utterance is accepted and compared after STT; if the binding moved, the stale work is dropped and logged. Rejoining from the same channel keeps the conversation and is dispatched as before. Nothing awaits between that check and the callback's own read of the binding. Reported by andrexibiza (F6) on NousResearch#128788. (cherry picked from commit 47d515e)
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 e4b020a)
…; 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.
…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 NousResearch#133471 and NousResearch#130312.
99b701a to
7e3ea12
Compare
unsupportedpastels
left a comment
There was a problem hiding this comment.
Thank you @andrexibiza for the careful review. F1 held: speech still buffered in the receiver was stamped with the new binding at the next poll or by the leave flush, and the post-STT check couldn't see it. 7e3ea1245b fixes both drain paths by discarding the receiver's unemitted audio when /voice join moves the binding to a different text channel, and keeps it on a first join or same-channel rejoin. Your three failing interleavings are now regression tests through the real join handler and receiver, alongside the same-channel controls. The PR description and the voice-mode docs line are updated to match. Thanks also to @jonpol01 for the original fix.
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.
…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.
* 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…
…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 NousResearch#133471 and NousResearch#130312. The frozen adapter binds successful cold, repeated and moved joins through _bind_voice_channel under the guild lock. Discard buffered PCM there before replacing a different text channel, after the existing disconnect notification. No duplicate gateway binding is added. The complete upstream poll, spanning-speech and leave-flush tests use the actual locked adapter binding and existing call keys.
What does this PR do?
_process_voice_inputawaits WAV conversion and STT, then the gateway callback reads the guild's text-channel binding as it is at that moment. A/voice joinfrom another text channel during transcription moved the binding, so the old utterance became a turn in the new conversation, with that conversation's prompt, skills and replies. The listen loop andleave_voice_channelnow capture the binding when a batch is collected, and_process_voice_inputdrops an utterance whose binding moved before dispatch. An unchanged binding, including leave and rejoin from the same channel, dispatches as before.Speech the receiver still buffers when the binding moves is dropped too. A same-voice-channel
/voice joinkeeps the receiver, and its buffers carry no conversation identity, so the next poll or the leave flush would stamp audio captured for the old channel with the new binding._handle_voice_channel_joinnow discards the receiver's unemitted audio, for every speaker, before moving the binding to a different text channel; a first join or a same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.Related Issue
Fixes #130311
Salvages #130312 (@jonpol01: both commits are cherry-picked with authorship kept). Thanks to @andrexibiza for the review, including F1 (speech still buffered in the receiver), which the follow-up commit fixes.
Type of Change
Changes Made
plugins/platforms/discord/adapter.py: the listen loop andleave_voice_channelpass the binding captured for the batch;_process_voice_inputcompares it before invoking the callback.plugins/platforms/discord/adapter_voice_info.py(new):get_voice_channel_infoandget_voice_channel_contextmove here unchanged asDiscordVoiceInfoMixin. Together with folding the new comments, this keepsadapter.pywithin its code-health file cap (7,333 lines, down from 7,368).tests/gateway/test_discord_voice_stale_binding.py(new): one batch of two utterances, with the binding unchanged (both dispatch) or moved during the first STT (neither dispatches); speech buffered across a rebind, speech spanning a rebind (only the part after it reaches the new channel), and a leave flush after a rebind, each with a same-channel control. All go through the real gateway join handler andVoiceReceiver.gateway/run_voice.py:_handle_voice_channel_joincallsdiscard_pending_voice_inputbefore moving the binding to a different text channel.plugins/platforms/discord/adapter.py:VoiceReceiver.discard_pending()clears unemitted PCM under the receiver lock;DiscordAdapter.discard_pending_voice_input(guild_id)calls it.tests/gateway/test_voice_command.py: callers of_process_voice_inputpass the captured binding.website/docs/user-guide/features/voice-mode.mdand its zh-Hans copy: one line stating that a rebind drops speech captured before the move, whether still being transcribed or not yet finished.How to Test
scripts/run_tests.sh tests/gateway/test_discord_voice_stale_binding.pygives 7 passed. Withadapter.pyandrun_voice.pyfrom before the receiver-buffer commit, the 3 rebind-while-buffered cases fail; withmain'sadapter.py, the moved-during-STT case fails too.Checklist
Code
pytest tests/ -qand all tests pass. Not run in full; the related suites pass.python scripts/checkpasses (11 checks)Documentation & Housekeeping
cli-config.yaml.example: N/ACONTRIBUTING.md/AGENTS.md: N/AInfographic