feat(discord): add opt-in staleness check for slow-reasoning agents#7431
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
| const channelSequences = new WeakMap<object, Map<string, number>>(); | ||
| const lastMessageIds = new WeakMap<object, Map<string, string | undefined>>(); |
There was a problem hiding this comment.
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.
| ) as string | undefined; | ||
| this.draftStreamingEnabled = | ||
| draftStreamSetting === "true" || draftStreamSetting === "1"; | ||
| this.stalenessConfig = getDiscordStalenessConfig((key) => |
There was a problem hiding this comment.
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.
| if (!config.enabled || config.behavior === "ignore") { | ||
| return { | ||
| shouldSend: true, | ||
| stale: false, | ||
| messagesSinceTurnStart: 0, | ||
| behavior: config.behavior, | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
Co-authored-by: wakesync <shadow@shad0w.xyz>
378ec03 to
6069e2b
Compare
| 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 []; | ||
| } |
There was a problem hiding this comment.
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.
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=falseDISCORD_STALENESS_BEHAVIOR=tagtag: send the response with a(catching up:)prefix when staleskip: suppress stale responsesignore: leave responses unchanged even if tracking is enabledDISCORD_STALENESS_THRESHOLD=2A 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.pyinto source-level plugin-discord TypeScript, with plugin-scoped env names instead of Nyx-specific names.Validation
bun test __tests__/staleness.test.tsbunx biome check .bun run buildGreptile 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 andapplyDiscordStalenessGuardfunction withskip,tag, andignorebehaviours; config parsing helpers.discord-events.ts: Records every inbound (non-self, non-ignored-bot) message into the sequence counter inside themessageCreatehandler.messages.ts: CapturesstalenessStartSequencebefore callback setup and evaluates the guard on eachHandlerCallbackinvocation, logging a warning and short-circuiting when stale withbehavior=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
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 endReviews (2): Last reviewed commit: "feat(discord): add opt-in staleness guar..." | Re-trigger Greptile