Skip to content

fix(discord): retire opening-title aliases on moderator renames - #132758

Closed
andrexibiza wants to merge 9 commits into
NousResearch:mainfrom
andrexibiza:fix/discord-rename-provenance-131614
Closed

andrexibiza wants to merge 9 commits into
NousResearch:mainfrom
andrexibiza:fix/discord-rename-provenance-131614

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Oct 4, 2026 •

Copy link
Copy Markdown

What does this PR do?

Keep a Discord auto-thread's opening label pinned while Hermes owns its uninterrupted rename attempt. An observed moderator rename retires that alias, including a rename away and back, batched gateway updates, and edits received before the HTTP request completes. Typed, slash and voice turns share the same prompt prefix even when Discord message tools are enabled.

Original discovery and published rename-state repair: Axl Ibiza. Sources: Axl's original report, published repair and regressions, and raw-event/in-flight ordering explanation. #131614 records that reporting credit.

This integrates John Paul Soliva's auto-thread/title/topic and slash/voice metadata work. His existing authored commits and Matt McFarland's co-authorship remain in the published ancestry.

Related Issue and PR scope

Fixes #131614. Related: #131242.

This PR supersedes the implementations in #128788, #131120 and #131243: it includes their contributor changes, then adds raw rename-attempt ownership and the tools-enabled triggering-message instruction repair. Those original PRs have not been closed by this update. #132288 and #132295 are overlapping label-masking drafts.

The linear-history request is now published. This PR head is a nine-commit, single-parent series rebased onto main at af90026aa09949579bd423d24def3d38f743cde0: seven contributor commits retain their authors, author dates, full messages and co-author trailer, followed by two repair/test commits. The old published head is preserved at andrexibiza/hermes-agent:backup/pr132758-before-linear-2bc73582; the rebased candidate is also preserved at backup/pr132758-linear-current-af90026. The code delta composes cleanly with that main pin. Current-head upstream CI still requires approval and has not executed.

Type of Change

  • Bug fix

Changes Made

  • Publish each rename record before awaiting HTTP; raw THREAD_UPDATE snapshots advance Hermes's opening-to-generated transition or retire an observed external change. A late HTTP success cannot resurrect a retired record.
  • Failure and cancellation clean up by identity, preserving a newer attempt.
  • Keep the triggering-message instruction static and remove message-ID presence from the prompt-cache key. Actual Discord message IDs remain per-turn; slash/voice input does not invent an ID.
  • Move the rename ownership invariants into SDK-free gateway tests. Keep one real discord.py ordering case in the messaging-enabled platform E2E lane; it exercises production connect() registration with two moderator frames parsed before callbacks run and no platform-event subscriber.
  • Reduce the added rename/composed/parity coverage from 42 to 17 cases: six rename invariants, one composed disabled-tools case, nine slash/voice parity cases and one native SDK witness. Distinct contributor routing cases are retained without the tools-on/off product grid.

The formatter now explains the accepted reconnect limitation: re-IDENTIFY without RESUME can repopulate a title without replaying its raw rename event. An unseen restoration to the opening name can remain indistinguishable from cache lag; the raw path does not establish provenance for missed events.

How to Test

Use the canonical runner in an isolated PM test environment, with retries disabled. The gateway checks run without the Discord SDK:

HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh \
  tests/gateway/test_discord_auto_thread_rename_pin.py \
  tests/gateway/test_discord_composed_pin_contract.py \
  tests/gateway/test_discord_turn_prompt_parity.py

Run the native witness in the messaging-enabled environment:

HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh \
  tests/e2e/core/platforms/test_discord_thread_rename_ordering.py
Local validation Result
Rebasing to af90026 — three gateway files, without Discord installed, Python 3.11.11 16 passed, 0 failed, 0 skipped (independent local rerun)
Earlier tested code tree — final three gateway files without Discord, Python 3.14.7 16 passed, 0 failed, 0 skipped
Native connected-Bot ordering witness, discord.py 2.7.1 1 passed, 0 failed, 0 skipped
Rebased current head, related 15-file suite without SDK (Windows/Python 3.11.11) 224 passed, 0 failed, 4 skipped; 8 runtime warnings
Rebased current head, related 16-file suite with isolated discord.py 2.7.1 (Windows/Python 3.11.11) 225 passed, 0 failed, 4 skipped; 9 warnings
Earlier base/tree, related 15-file suite without SDK (Python 3.14.7) 223 passed, 0 failed, 4 skipped
Earlier base/tree, related 16-file suite with SDK before final disabled-voice row (Python 3.14.7) 228 passed, 0 failed, 0 skipped
Final nine-row parity file, rerun both with and without SDK 9 passed in each environment
Independent validation 33 gateway tests passed; 76 related tests passed with 4 existing SDK-dependent skips; contributor lineage checked
Ruff and whitespace validation Passed

The earlier broad receipts used Python 3.14.7 and the pre-rebase main. The current rebased head was rerun independently on Windows/Python 3.11.11 with retries disabled: 15 files without the SDK and 16 with an isolated discord.py 2.7.1 install. Four cases remain skipped on this host even with the SDK; the warnings include unawaited AsyncMock coroutines in existing voice tests. Focused Ruff and git diff --check pass on the current tree. Final tree: cc097281315f186f23744cdb970d000e410c3ac9, published head: a8368ceef373966b8caa6024166ca7ea8b99f77c.

The revised gateway tests produce 10 failures on the previous composed implementation without installing Discord. The native SDK witness also fails on that implementation at the restored-generated-title assertion and passes with this repair.

Remote CI is not green yet. On the rebased current head, CI, Nix, and Docker report action_required; upstream fork-workflow approval is required. Local results do not replace these checks.

Checklist

  • Read repository and area contribution instructions.
  • Retained contributor authorship and reporting credit.
  • Added failing-baseline and passing-candidate behavioral witnesses.
  • Unit invariants execute without the optional Discord SDK.
  • Native parsing/dispatch is exercised in the SDK-owning E2E lane.
  • Superseded implementation PRs are disclosed.
  • Publish the prepared linear history on current main (nine single-parent commits).
  • Current-head GitHub CI, Nix and Docker checks pass.

Coverage and runtime relationship

This Mermaid source was rendered with Mermaid CLI using the dark theme and the PNG was visually inspected.

%%{init: {"theme":"dark"}}%%
flowchart TB
 subgraph FLOW["Coverage and runtime relationship"]
 direction TB
    U["SDK-free gateway invariants"]:::context
    E["Messaging E2E: real Bot and batched frames"]:::context
    R["Raw THREAD_UPDATE snapshots"]:::entry
    O["Adapter owns the rename attempt"]:::owner
    F["Formatter uses the active alias or actual title"]:::transition
    P["Typed, slash and voice prompt parity"]:::context
    G["Missed events after re-IDENTIFY remain ambiguous"]:::context
    U --> R
    E --> R
    R --> O --> F --> P
    G -.-> F
 end
 LEGEND["Legend<br/>Blue: entry / incoming<br/>Purple: ownership / policy<br/>Amber: transition / pending<br/>Gray: context / transient<br/>Coverage evidence: see test results"]
 FLOW ~~~ LEGEND




 classDef entry fill:#172554,stroke:#60a5fa,color:#eff6ff,stroke-width:2px
 classDef transition fill:#451a03,stroke:#fbbf24,color:#fffbeb,stroke-width:2px
 classDef owner fill:#3b0764,stroke:#c084fc,color:#faf5ff,stroke-width:2px
 classDef context fill:#1e293b,stroke:#94a3b8,color:#f8fafc,stroke-width:2px
 style FLOW fill:#0f172a,stroke:#475569,color:#f8fafc
 style LEGEND fill:#1e293b,stroke:#94a3b8,color:#f8fafc,font-size:11px
 click U "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/tests/gateway/test_discord_auto_thread_rename_pin.py#L100" "Pinned source from the existing node-source table"
 click E "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/tests/e2e/core/platforms/test_discord_thread_rename_ordering.py#L20" "Pinned source from the existing node-source table"
 click R "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/plugins/platforms/discord/adapter.py#L1728" "Pinned source from the existing node-source table"
 click O "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/plugins/platforms/discord/adapter.py#L5451" "Pinned source from the existing node-source table"
 click F "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/plugins/platforms/discord/adapter.py#L5848" "Pinned source from the existing node-source table"
 click G "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/plugins/platforms/discord/adapter.py#L5848" "Pinned source from the existing node-source table"
 click P "https://github.com/NousResearch/hermes-agent/blob/a8368ceef373966b8caa6024166ca7ea8b99f77c/tests/gateway/test_discord_turn_prompt_parity.py#L99" "Pinned source from the existing node-source table"
Loading
Diagram node Source at this PR head
SDK-free gateway invariants Raw payload and pending HTTP tests
Messaging E2E Connected Bot and batched parser witness
Raw snapshots Raw thread-update handler
Rename attempt owner Record publication and identity cleanup
Formatter and missed-event limitation Effective thread label and reconnect comment
Typed, slash and voice prompt parity Enabled-tools routing and disabled-voice witness, disabled-tools composition

@alt-glitch alt-glitch added type/bug Something isn't working P0 Critical — data loss, security, crash loop comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Oct 4, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review — #132758 (head 6f27ef0b)

Verdict: Request changes — the raw-event state machine is the right mechanism and the message-ID gap is a real find, but the PR's shape (octopus merge commit carrying the change, three open PRs stacked underneath) and its CI-blind headline tests need fixing before this can be considered for merge. Merge decision stays with the maintainer.

Premise, verified on origin/main (1298c8e): main has no _semantic_thread_renames at all, so #131614 itself only exists on #131243's head — Teknium's triage on the issue already rated it a P2 inside an unmerged fix; the P0 label here is inherited from #131242. What this PR adds over the composed base (#128788 + #131120 + #131243, octopus-merged onto main in a scratch worktree) is a net delta of ~76 LOC in adapter.py, ~20 LOC in gateway/session.py + run_agent_cache.py, and ~360 test lines.

Tests (hermetic, fake HOME): PR head 119 passed / 0 failed / 20 skipped across the 9 Discord/voice files. The PR's own test files copied onto the composed base: test_discord_turn_prompt_parity.py 16/16 red, test_discord_composed_pin_contract.py 4/4 red — so the delta does move behaviour. But test_discord_auto_thread_rename_pin.py was 16 skipped on BOTH trees — see Critical 1.

Critical

  1. The 16 native-dispatch rename regressions never run in CI. discord_sdk (tests/gateway/test_discord_auto_thread_rename_pin.py:84-91) does pytest.importorskip("discord"), and discord.py is deliberately not in the [all] extra (pyproject.toml:328-332); the unit tests job syncs all,anthropic,bedrock,… without messaging (.github/workflows/tests.yml:63). Only the e2e job installs messaging (tests.yml:145) and it runs tests/e2e/** only. So the cases the PR body leads with ("prior fix fails 12 of the 22 native rename cases") are green locally in your PM env with the extra and silently s everywhere else — a later regression of _on_platform_raw_thread_update would pass CI. Fix direction: the state machine does not need the SDK — drive _on_platform_raw_thread_update(SimpleNamespace(thread_id=800, data={"name": …})) directly for the ordering/ownership invariants in tests/gateway/, and keep ONE real-SDK case (two THREAD_UPDATE frames parsed before callbacks run) in tests/e2e/core/platforms/ next to test_discord_contract.py / _drv_discord.py, which is the lane that has the extra.

  2. Your change lives inside an octopus merge commit (6f27ef0b, parents 1298c8e + 01435400 + f86610fa). A rebase-merge drops merge commits, so landing this PR as the repo does (rebase, never squash) would either be refused or lose your delta; a --merge would land three still-open P0 PRs (#128788, #131120, #131243) under this PR's title. Please rebuild the branch linearly: the stacked contributor commits as-is (authorship preserved), then your change as a normal fix(discord): … commit on top — or, since #131243 allows maintainer edits, offer the ~100 LOC production delta to its author as a follow-up commit there and keep this PR to the message-ID gap. Either way the PR body should say which of the three originals it supersedes; right now it states "no existing PR is closed or rewritten" while re-landing all of their commits.

Warnings

  1. Test volume. 22 parametrized native cases + 4 composed + 16 parity variants for a ~100 LOC change. The invariants are three: (a) a moderator rename retires the record even when the second THREAD_UPDATE is parsed before the first callback runs; (b) a return to Hermes's title after a moderator edit renders as itself; (c) typed / slash / voice turns pin the identical string with Discord tools enabled and disabled. One case per invariant (plus the failed/cancelled attempt ownership) is the bar here; the product grids mostly re-prove the same branch.

  2. Residual, worth a one-line comment in _format_thread_chat_name: after a lost-event gap (re-IDENTIFY without RESUME repopulates thread names from GUILD_CREATE, no THREAD_UPDATE), a moderator who restored the opening name during the gap still reads as cache lag (thread_name == renamed.replaced, set_seen False), so their later pick of Hermes's title shows the opening name — the original #131614 shape, narrowed to that gap. Not a blocker; just don't let the docstring claim the raw path makes the record exact.

Looks good

  • _on_platform_raw_thread_update is the correct seam: discord.py 2.7.1 parse_thread_update dispatches raw_thread_update before mutating the cached Thread and in both the cached and uncached branches (verified in the installed package), so the payload name is an immutable snapshot and the before/after handler's two failure modes (mutated after, thread_join for uncached threads) go away.
  • Publishing the _SemanticThreadRename record before await edit(...) and cleaning up by identity in finally (covers cancellation, protects a newer attempt) is right; the (event_name, new_name) == (replaced, set_name) transition check correctly admits only Hermes's own opening→title edge.
  • The message-ID change is a real gap in #128788, reproduced: with _discord_tools_loaded() true, a typed turn (has message_id) renders the "Triggering message" line and a slash/voice turn (message_id=None) does not, so the pinned prompt differs between alternating turns — exactly the #128787 flip, surviving #128788 for every user with the discord toolset enabled. Making the line static and dropping the presence bit from the run_agent_cache.py key (gateway/run_agent_cache.py:730-737) is the correct fix and keeps the key covering everything rendered. I'll flag it on #128788 too.
  • Nix and Docker lanes already green on this head; CI unit lane approved and running.

Comment thread tests/gateway/test_discord_auto_thread_rename_pin.py Outdated
Comment thread plugins/platforms/discord/adapter.py
jonpol01 and others added 9 commits October 4, 2026 10:30
…rompt inputs

Native slash commands (/skill, /plan, /queue), the /thread starter and
voice-channel input are human turns, so each re-pins the session-context
prompt and the channel prompt. They built their event without the fields
on_message sets:

- a DM slash turn had no chat name, so the "origin" line rendered the
  channel id instead of the user's name;
- a thread slash turn and the /thread starter dropped the parent channel
  from the thread label, and the starter took the parent channel's topic;
- a spoken turn named the speaker by numeric id, not display name;
- none carried the channel's bound skill, so a session opened by one of
  them never loaded it.

Each switch between one of these turns and a typed message re-rendered
the pinned context prompt and back again, a prompt-cache miss each way.
They now use the same formatters (_format_thread_chat_name,
_get_effective_topic on the thread, the member display name) and
_resolve_channel_skills as the message path.
…e channel's current labels

A spoken turn rebuilds its source from the copy `/voice join` stored, and
three of its prompt inputs still differed from a typed message in the same
conversation:

- voice joined from a thread looked up the channel prompt and bound skills
  for the thread alone, while a typed message there falls back to the
  parent channel's entry, so a parent-only binding never reached speech;
- the chat name and topic stayed at their join-time values, so after a
  rename or topic edit every typed/spoken switch re-rendered the pinned
  context prompt and back;
- `guild.get_member()` is cache-only, and on a miss the speaker was named
  by numeric id even when they were the member who ran `/voice join`.

The resolvers now get the stored parent id, the name and topic come from
the bound channel through the same formatters a message uses (the stored
values stay as the fallback when the channel is not cached), and an
uncached joiner keeps the name bound at join; any other uncached speaker
still gets their id, never the joiner's name.

Reported by andrexibiza (F1, F2) and teknium1 (get_member) on NousResearch#128788.

Co-authored-by: Matt McFarland <mattttmcfarland@gmail.com>
The first turn of an auto-threaded @mention already points its source at the new thread, but it read chat_topic from the text channel the mention was posted in. The thread's next message reads the thread's own topic (none, outside forums), so the pinned session-context prompt was re-rendered on turn 2 of every auto-threaded conversation in a channel with a topic: a prompt-cache miss, and a Channel Topic line the model saw once and then lost.
…efault mention gate

The regression test disabled require_mention and sent an untagged parent message, so it never covered the default entrypoint the issue describes. The first message is now a real bot mention under the default gate, an untagged parent message is kept as a rejection control (no thread, no dispatch), and the module docstring no longer implies the channel prompt / skill lookups were part of the bug.
…prompt

chat_name keys the pinned session-context prompt, and the title lane
renames a new auto-thread once the LLM title arrives, normally before the
user's second message. Turn 2 read the new name and re-rendered the
already-sent prompt, a prompt-cache miss on every auto-threaded
conversation on the default config.

rename_thread now records the name it replaced and the name it set for a
guarded semantic rename, and _format_thread_chat_name keeps the replaced
name while the thread still carries Hermes's title. A rename by anyone
else no longer matches and re-renders as before.
…names the thread

The mask from Hermes's own title rename matched on name text only and was never retired, so a moderator who renamed the thread away and later restored Hermes's title saw the prompt show the thread's opening name instead. Once a name other than Hermes's title has been observed the record is dropped; before Hermes's title is first seen, the replaced name is still tolerated because a cache that has not caught up with the edit reports it.

Fixes NousResearch#131614
Track rename-attempt ownership before the HTTP edit and retire semantic
opening-title aliases when an immutable raw update observes an external
change. Failed or cancelled older attempts cannot remove newer records.
Keep triggering message IDs and their presence out of pinned system bytes.

Run ownership regressions without the optional Discord SDK; exercise one
real Bot/parser ordering contract in the messaging E2E lane. Remove the
redundant test cross-products and explain the missed-event reconnect limit.

Original discovery and published repair: Axl Ibiza, MBA
NousResearch#131243 (comment)
NousResearch#131243 (review)

Built on John Paul Soliva's seven contributor commits, replayed linearly
with original authorship and Matt McFarland's co-author trailer preserved.
jonpol01 added a commit to jonpol01/hermes-agent that referenced this pull request Oct 4, 2026
…e_id presence

With the discord toolset loaded, a typed turn rendered the per-turn
triggering-message line and keyed the agent cache on message_id presence,
while slash, /thread and voice turns (no triggering message) did not, so
the pinned session-context prompt re-rendered on every switch between
them. The line is now static and the presence bit is out of the key; the
real id still rides the user message when there is one.

Reported by teknium1 in the NousResearch#132758 review; fix shape from andrexibiza's NousResearch#132758.

Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
@andrexibiza
andrexibiza force-pushed the fix/discord-rename-provenance-131614 branch from 2bc7358 to a8368ce Compare October 4, 2026 15:52
@andrexibiza

Copy link
Copy Markdown
Author

@teknium1 The changes requested in your review are now on the current #132758 head a8368cee:

Both inline threads are resolved. On this rebased head, the related local suites returned 224 passed/4 skipped without the SDK and 225 passed/4 skipped with discord.py 2.7.1; focused Ruff and diff checks passed. Hosted CI, Nix and Docker are still action_required with no jobs, so this is not a hosted-green claim.

@whyyagswhy

Copy link
Copy Markdown

Verified at a8368ce: discord + voice suites pass 82/82 with 4 skipped.

unsupportedpastels added a commit that referenced this pull request Oct 5, 2026
The mask record was installed only after the REST edit returned and was
retired from the cached on_thread_update callback. Gateway updates can land
while the edit is in flight, and discord.py mutates the cached thread before
scheduling callbacks, so batched updates all report the final name. Either
way a moderator's restore of Hermes's title could render the opening name.

Record the guarded attempt before awaiting the edit, advance or retire it
from on_raw_thread_update, and on failure or cancellation drop only the
record that attempt still owns. The record store and _format_thread_chat_name
move into adapter_thread_titles.py so adapter.py does not grow.

Rename-only part of #132758's f67ef41; its message-id, slash/voice and
composed-contract changes are left to their own PRs.

Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
@unsupportedpastels

Copy link
Copy Markdown
Collaborator

Thanks @andrexibiza for this work and for the review findings that shaped the final fix. The rename part of f67ef417e0 is on main through #133393 as ea32af9331, with you as co-author: recording the attempt before the edit, retiring the record from raw rename events, per-attempt cleanup on failure or cancellation, and the real-SDK ordering test.

I'm closing this as superseded for the rename fix. The other changes here are not in #133393. The message-ID presence fix and the slash/voice label belong with #128788, as Teknium suggested, and the topic re-key belongs with #131120. Please carry them over there if they aren't already.

unsupportedpastels pushed a commit that referenced this pull request Oct 5, 2026
…e_id presence

With the discord toolset loaded, a typed turn rendered the per-turn
triggering-message line and keyed the agent cache on message_id presence,
while slash, /thread and voice turns (no triggering message) did not, so
the pinned session-context prompt re-rendered on every switch between
them. The line is now static and the presence bit is out of the key; the
real id still rides the user message when there is one.

Reported by teknium1 in the #132758 review; fix shape from andrexibiza's #132758.

Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
(cherry picked from commit 2a3f04a)
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 comp/plugins Plugin system and bundled plugins P0 Critical — data loss, security, crash loop platform/discord Discord bot adapter sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord auto-thread rename mask shows the opening name after a moderator restores Hermes's title

6 participants