Skip to content

fix(voice): one Discord bot's transcript dedup no longer drops another bot's turn (#130307) - #130308

Closed
jonpol01 wants to merge 1 commit into
NousResearch:mainfrom
jonpol01:fix/voice-dedup-per-bot
Closed

jonpol01 wants to merge 1 commit into
NousResearch:mainfrom
jonpol01:fix/voice-dedup-per-bot

Conversation

@jonpol01

@jonpol01 jonpol01 commented Oct 1, 2026

Copy link
Copy Markdown

What does this PR do?

When one gateway serves several Discord bots (multiplexed profiles) listening in the same guild, each bot now answers its own copy of an utterance. Before, the voice-transcript dedup was one runner-wide store keyed by (guild_id, user_id): the first bot's callback recorded the utterance and the second bot's returned as a "duplicate" before building its event. A /voice join from another text channel also inherited the previous binding's 12-second history.

The key is now (receiving bot's profile, guild, bound text channel, speaker). A capture emitted twice inside one binding is still suppressed, which is what #19428 added the dedup for.

Reported by @andrexibiza (F5) in the review of #128788.

Related Issue

Fixes #130307

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/run_voice.py: _is_duplicate_voice_transcript takes a binding tuple instead of the bare guild id; _handle_voice_channel_input passes (adapter._owner_profile, guild_id, text_ch_id). Normalization, the 12 s window, the fuzzy match and the 5-entry cap are unchanged.
  • gateway/run.py: the store's comment and type follow the new key.
  • tests/gateway/test_voice_transcript_dedup_scope.py: one invariant test through the real callback: two bots each dispatch the same utterance once, a repeat on one bot is suppressed, and a rebind to another text channel dispatches again.

Docs: N/A. No page describes voice-transcript dedup.

How to Test

  1. scripts/run_tests.sh tests/gateway/test_voice_transcript_dedup_scope.py: 1 passed.
  2. Red on main (233434b414): assert (1, 0) == (1, 1) (bot B dropped).
  3. Sabotages, each red: key without the profile (bot B dropped); key without the text channel (rebind suppressed); key unique per call (same-bot repeat dispatched twice).
  4. scripts/run_tests.sh tests/gateway/test_voice_command.py tests/gateway/test_voice_mode_platform_isolation.py tests/gateway/test_discord_race_polish.py tests/gateway/test_discord_voice_mixer.py tests/gateway/test_platform_reconnect.py: all pass. Ruff clean. Python 3.14.

Checked while building:

  • Callers: _handle_voice_channel_input is the only one.
  • The profile, not the adapter object, names the bot, so a reconnect that rebuilds the adapter keeps suppressing a repeat inside the window.
  • Retention: the store gains one key per (bot, guild, text channel, speaker) actually heard, each capped at 5 entries, as before per (guild, speaker).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass. I ran only the files listed above via scripts/run_tests.sh, not the full suite.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 27, Python 3.14

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A (see "Docs: N/A" above)
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

…r bot's turn

_is_duplicate_voice_transcript keeps one runner-wide store keyed by
(guild_id, user_id). When one gateway serves several Discord bots
(multiplexed profiles) listening in the same guild, each bot transcribes
its own copy of an utterance; the first callback records it and the
second returns before building its event, so that bot never answers.
A `/voice join` from another text channel also inherited the previous
binding's 12-second history.

The key is now the receiving bot's profile, the guild, the bound text
channel and the speaker. A repeated capture inside one binding is still
suppressed, as NousResearch#19428 intended.

Reported by andrexibiza (F5) on NousResearch#128788.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Oct 1, 2026

@andrexibiza andrexibiza left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Batch review at 0b889d0.

Please fold this into #128788 with #129303 and #130303. This directly repairs that review's F5: dedup state belongs to the receiving bot and bound conversation. The supplied regression fails on the earlier Discord version with bot B dispatch count 0, and passes with this change; the broader selection passed 57 tests with 4 skips.

Keep the same-capture suppression, two-bot separation and text-channel rebind checks in the consolidated PR. The key proves separation across owners and different bound channels. It does not prove capture-generation ownership across leave/rejoin, reconnect, or delayed transcription; those are separate lifecycle acceptance conditions from the earlier F6 thread. I found no additional reproduced defect in this key change.

%%{init: {'theme':'dark','themeVariables':{'background':'#0d1117','primaryTextColor':'#e6edf3','lineColor':'#94a3b8'}}}%%
flowchart TD
subgraph CORE[" "]
direction TB
 A["Bot A transcript"] --> K["Key: owner, guild, bound chat, speaker"]
 B["Bot B transcript"] --> K
 K --> H{"Seen within this key?"}
 H -->|same capture| S["Suppress repeat"]
 H -->|new bot or chat| D["Dispatch its own turn"]
end
CORE ~~~ LEGEND
LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Green: success"]
style CORE fill:#0d1117,stroke:#30363d,color:#e6edf3
style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px
 class A,B input
 class K,H step
 class S,D good
classDef input fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px
classDef step fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px
classDef good fill:#052e16,stroke:#4ade80,color:#f0fdf4,stroke-width:2px
classDef bad fill:#450a0a,stroke:#f87171,color:#fef2f2,stroke-width:2px
click A "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L275-L279"
click B "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L275-L279"
click K "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L275-L279"
click H "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L275-L279"
click S "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L275-L279"
click D "https://github.com/NousResearch/hermes-agent/blob/0b889d02455584315913cf29076bb9952f8489e5/gateway/run_voice.py#L293-L299"
Loading

Diagram source: gateway/run_voice.py:275. Batch consolidation map: published set review.

I reviewed the 13 open PRs in this burst as one set, including existing review threads, changed producers, downstream consumers, and a local composition of all 13 full PR diffs. The composition applied without conflicts and passed 419 tests in 18 files, with 6 skips, using the repository's canonical per-file test runner. This is targeted regression evidence, not a full repository test run or a live Slack, Discord, Relay or provider test. Five additional local consumer/lifecycle mechanisms still fail (6 failing checks, including positive and negative peer caches): native Discord role handoff, Slack middle-cancellation ordering, Slack reply-context ownership, lazy provider-stream permit reentry, and Relay stale peer label caches. Their owning reviews contain runnable checks and precise boundaries. GitHub returned no check runs or commit status entries for the reviewed heads; local results do not establish CI success.

Individual pinned-head check: === Summary: 3 files, 57 tests passed, 0 failed, 4 skipped (100% complete) in 6.7s (2 workers) ===.

scripts/run_tests.sh -j 2 tests/gateway/test_voice_transcript_dedup_scope.py tests/gateway/test_voice_command.py tests/gateway/test_voice_mode_platform_isolation.py

Comment thread gateway/run_voice.py
logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
return
if self._is_duplicate_voice_transcript(guild_id, user_id, transcript):
binding = (getattr(adapter, "_owner_profile", None), guild_id, text_ch_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fold this into #128788 as its dedup-scope repair. The owner/guild/bound-chat key prevents another bot or a different text binding from inheriting the speaker's recent transcript history; the supplied regression fails on the earlier version and passes with this patch. Preserve the same-capture suppression and both separation controls in the consolidated tests. Do not infer a capture-generation guarantee from this tuple: a fresh join to the same chat retains the same key, and delayed transcription ownership needs a separately specified lifecycle contract. This is an acceptance-boundary distinction, not a new reproduced generation defect.

@jonpol01

jonpol01 commented Oct 2, 2026

Copy link
Copy Markdown
Author

Thanks. We're keeping this separate from #128788 on purpose.

@unsupportedpastels unsupportedpastels left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you @jonpol01: your fix landed in #133470, cherry-picked with your authorship kept. We shortened one comment so gateway/run.py stays within its code-health size cap and added type hints to the test. Thanks also to @andrexibiza for the review. Closing in favour of #133470.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/discord Discord bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord voice transcript dedup is runner-wide, so a second bot in the same guild drops its own copy of an utterance

4 participants