Skip to content

feat(discord): add opt-in staleness check for slow-reasoning agents#7431

Merged
lalalune merged 1 commit into
elizaOS:developfrom
0xSolace:feat/discord-staleness-check
May 6, 2026
Merged

feat(discord): add opt-in staleness check for slow-reasoning agents#7431
lalalune merged 1 commit into
elizaOS:developfrom
0xSolace:feat/discord-staleness-check

Conversation

@0xSolace
Copy link
Copy Markdown
Collaborator

@0xSolace 0xSolace commented May 6, 2026

Summary

Adds an opt-in post-completion staleness guard for Discord responses. The guard tracks inbound message sequence numbers per channel and compares the channel sequence when a response callback fires against the sequence captured when the agent started handling the turn.

This helps slow-reasoning agents avoid replying into stale context after a busy channel has moved on.

Configuration

All defaults are conservative and the feature is off unless explicitly enabled:

  • DISCORD_STALENESS_ENABLED=false
  • DISCORD_STALENESS_BEHAVIOR=tag
    • tag: send the response with a (catching up:) prefix when stale
    • skip: suppress stale responses
    • ignore: leave responses unchanged even if tracking is enabled
  • DISCORD_STALENESS_THRESHOLD=2

A response is considered stale only when more than the threshold number of newer channel messages arrived since reasoning started.

Notes / receipts

This ports the Nyx production boot patch from /tmp/nyx-patches-extract/patch-discord-staleness-check.py into source-level plugin-discord TypeScript, with plugin-scoped env names instead of Nyx-specific names.

Validation

  • bun test __tests__/staleness.test.ts
  • bunx biome check .
  • bun run build

Greptile Summary

Adds an opt-in post-completion staleness guard for the Discord plugin that tracks per-channel message sequence numbers and suppresses or tags agent responses when the channel has moved on significantly during slow reasoning. The feature is disabled by default and controlled entirely via three DISCORD_STALENESS_* env vars.

  • staleness.ts (new): WeakMap-based sequence tracker and applyDiscordStalenessGuard function with skip, tag, and ignore behaviours; config parsing helpers.
  • discord-events.ts: Records every inbound (non-self, non-ignored-bot) message into the sequence counter inside the messageCreate handler.
  • messages.ts: Captures stalenessStartSequence before callback setup and evaluates the guard on each HandlerCallback invocation, logging a warning and short-circuiting when stale with behavior=skip.

Confidence Score: 4/5

Safe to merge with awareness that multi-chunk agent responses can produce partial messages in busy channels when behavior=skip.

The staleness guard is evaluated per callback invocation but uses a single stalenessStartSequence captured at turn start. When a slow-reasoning agent fires its callback more than once and the channel crosses the staleness threshold between invocations, the first chunk is already sent and cannot be retracted while subsequent chunks are suppressed, leaving a truncated message visible to users. Everything else — config parsing, WeakMap scoping, tag idempotency, and finalize("") silent termination of draft streams — is correct.

plugins/plugin-discord/messages.ts — the callback-level staleness evaluation interacts with multi-chunk responses in a way that can produce truncated output.

Important Files Changed

Filename Overview
plugins/plugin-discord/staleness.ts New module implementing the staleness guard: module-level WeakMaps keyed by owner object, config parsing, sequence tracking, and the guard function. Core logic is correct.
plugins/plugin-discord/messages.ts Integrates staleness guard into the callback pipeline. The guard runs per-callback invocation using a shared stalenessStartSequence, which can produce partial responses for multi-chunk agents when behavior=skip and the channel becomes busy mid-response.
plugins/plugin-discord/discord-events.ts Adds recordDiscordChannelMessageSeen call in the messageCreate handler, correctly placed after the self/bot early-return guard. Messages during startup before the manager initialises are silently missed.
plugins/plugin-discord/tests/staleness.test.ts Unit tests cover config parsing, within-threshold pass-through, skip suppression, and tag idempotency. Each test uses an independent owner object so module-level WeakMap state does not leak between tests.

Sequence Diagram

sequenceDiagram
    participant Discord
    participant discord-events.ts
    participant staleness.ts
    participant messages.ts

    Discord->>discord-events.ts: messageCreate (user msg)
    discord-events.ts->>staleness.ts: recordDiscordChannelMessageSeen(owner, channelId, msgId)
    Note over staleness.ts: channelSequences[owner][channelId]++

    discord-events.ts->>messages.ts: handleMessage(message)
    messages.ts->>staleness.ts: getDiscordChannelMessageSequence(this, channelId)
    Note over messages.ts: stalenessStartSequence = N

    Discord->>discord-events.ts: messageCreate (more msgs arrive)
    discord-events.ts->>staleness.ts: recordDiscordChannelMessageSeen(...)
    Note over staleness.ts: sequence advances to N+k

    messages.ts->>messages.ts: LLM generates response
    messages.ts->>staleness.ts: applyDiscordStalenessGuard({startSequence: N, ...})
    Note over staleness.ts: delta = (N+k) - N = k
    alt k <= threshold
        staleness.ts-->>messages.ts: {shouldSend:true, stale:false}
        messages.ts->>Discord: send response
    else k > threshold AND behavior=tag
        staleness.ts-->>messages.ts: {shouldSend:true, stale:true, content prefixed}
        messages.ts->>Discord: send "(catching up:) response"
    else k > threshold AND behavior=skip
        staleness.ts-->>messages.ts: {shouldSend:false, stale:true}
        messages.ts->>messages.ts: suppress response
    end
Loading

Reviews (2): Last reviewed commit: "feat(discord): add opt-in staleness guar..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 89687cbd-bc3f-4c3e-aa3d-b3d1b9bd57bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@0xSolace 0xSolace marked this pull request as ready for review May 6, 2026 08:53
Comment on lines +13 to +14
const channelSequences = new WeakMap<object, Map<string, number>>();
const lastMessageIds = new WeakMap<object, Map<string, string | undefined>>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Dead code: lastMessageIds is written but never read

lastMessageIds is populated in recordDiscordChannelMessageSeen (lines 73-78) but no exported function reads from it — there is no getDiscordChannelLastMessageId or similar accessor. The map accumulates data for every channel indefinitely while providing zero observable value. If it was meant to support a future feature (e.g., dedup by message ID), that should be noted in a comment; otherwise it should be removed to avoid confusion and unnecessary memory retention.

Comment on lines 154 to +157
) as string | undefined;
this.draftStreamingEnabled =
draftStreamSetting === "true" || draftStreamSetting === "1";
this.stalenessConfig = getDiscordStalenessConfig((key) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Staleness config is snapshotted once at construction time

getDiscordStalenessConfig is called inside the MessageManager constructor, so this.stalenessConfig is frozen at startup. If DISCORD_STALENESS_ENABLED or related settings are changed at runtime (e.g., via a hot-reload mechanism or dynamic setSetting), the guard will continue to use the original values until the service restarts. For a feature whose primary guard is the enabled flag, this means there's no way to disable it without a full restart once it's turned on.

Comment on lines +108 to +115
if (!config.enabled || config.behavior === "ignore") {
return {
shouldSend: true,
stale: false,
messagesSinceTurnStart: 0,
behavior: config.behavior,
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 ignore behavior silently suppresses staleness warnings

When behavior === "ignore" the guard short-circuits and returns stale: false, which prevents the runtime.logger.warn call in messages.ts from ever firing. A user who enables tracking (DISCORD_STALENESS_ENABLED=true) but sets behavior=ignore expecting silent pass-through will also get no observability into how often staleness is actually occurring. Returning stale: true (with shouldSend: true) when the channel has moved past the threshold would let the warn path fire without changing send behaviour.

@0xSolace 0xSolace changed the base branch from main to develop May 6, 2026 09:49
Co-authored-by: wakesync <shadow@shad0w.xyz>
@0xSolace 0xSolace force-pushed the feat/discord-staleness-check branch from 378ec03 to 6069e2b Compare May 6, 2026 09:50
Comment on lines +768 to +795
const stalenessDecision = applyDiscordStalenessGuard({
config: this.stalenessConfig,
owner: this,
message,
startSequence: stalenessStartSequence,
content,
});
if (stalenessDecision.stale) {
this.runtime.logger.warn(
{
src: "plugin:discord",
agentId: this.runtime.agentId,
channelId: message.channel.id,
messageId: message.id,
messagesSinceTurnStart:
stalenessDecision.messagesSinceTurnStart,
threshold: this.stalenessConfig.threshold,
behavior: stalenessDecision.behavior,
},
"Discord response completed after newer channel messages arrived",
);
}
if (!stalenessDecision.shouldSend) {
typingController.stop();
statusReactions?.setDone();
await finalizePendingDraft();
return [];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Partial response sent when channel becomes busy mid-multi-chunk reply

stalenessStartSequence is captured once before the callback is defined, and applyDiscordStalenessGuard is evaluated on every individual callback invocation. For a slow-reasoning agent that fires the callback multiple times (multi-part response), if the channel moves from within-threshold to beyond-threshold between the first and second invocations, the first chunk is already sent to Discord and cannot be unsent, but subsequent chunks are suppressed. The user receives a visibly truncated message — exactly the worst-case outcome in a busy channel, which is precisely the scenario this feature targets.

Since Discord messages cannot be retracted after sending, the only robust mitigation for behavior=skip would be to buffer all callback output until reasoning completes and gate the send on a final staleness evaluation, rather than checking per-chunk.

@lalalune lalalune merged commit 20d00c5 into elizaOS:develop May 6, 2026
19 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants