Skip to content

Commit 20d00c5

Browse files
author
Shaw
committed
Merge branch 'pr/7431' into develop
2 parents 65897d7 + 6069e2b commit 20d00c5

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { Content } from "@elizaos/core";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
applyDiscordStalenessGuard,
5+
getDiscordStalenessConfig,
6+
recordDiscordChannelMessageSeen,
7+
} from "../staleness";
8+
9+
function mockMessage(channelId = "channel-1") {
10+
return {
11+
id: "message-1",
12+
channel: { id: channelId },
13+
} as never;
14+
}
15+
16+
describe("Discord staleness guard", () => {
17+
it("is disabled by default and parses scoped settings", () => {
18+
const settings = new Map<string, unknown>([
19+
["DISCORD_STALENESS_BEHAVIOR", "skip"],
20+
["DISCORD_STALENESS_THRESHOLD", "4"],
21+
]);
22+
23+
expect(getDiscordStalenessConfig((key) => settings.get(key))).toEqual({
24+
enabled: false,
25+
behavior: "skip",
26+
threshold: 4,
27+
});
28+
29+
settings.set("DISCORD_STALENESS_ENABLED", "true");
30+
expect(getDiscordStalenessConfig((key) => settings.get(key))).toEqual({
31+
enabled: true,
32+
behavior: "skip",
33+
threshold: 4,
34+
});
35+
});
36+
37+
it("allows responses when the newer-message delta is within threshold", () => {
38+
const owner = {};
39+
const start = recordDiscordChannelMessageSeen(owner, "channel-1", "a");
40+
recordDiscordChannelMessageSeen(owner, "channel-1", "b");
41+
const content: Content = { text: "hello" };
42+
43+
expect(
44+
applyDiscordStalenessGuard({
45+
config: { enabled: true, behavior: "skip", threshold: 1 },
46+
owner,
47+
message: mockMessage(),
48+
startSequence: start,
49+
content,
50+
}),
51+
).toMatchObject({ shouldSend: true, stale: false });
52+
expect(content.text).toBe("hello");
53+
});
54+
55+
it("skips stale responses when configured to skip", () => {
56+
const owner = {};
57+
const start = recordDiscordChannelMessageSeen(owner, "channel-1", "a");
58+
recordDiscordChannelMessageSeen(owner, "channel-1", "b");
59+
recordDiscordChannelMessageSeen(owner, "channel-1", "c");
60+
const content: Content = { text: "hello" };
61+
62+
expect(
63+
applyDiscordStalenessGuard({
64+
config: { enabled: true, behavior: "skip", threshold: 1 },
65+
owner,
66+
message: mockMessage(),
67+
startSequence: start,
68+
content,
69+
}),
70+
).toMatchObject({
71+
shouldSend: false,
72+
stale: true,
73+
messagesSinceTurnStart: 2,
74+
});
75+
});
76+
77+
it("tags stale responses once when configured to tag", () => {
78+
const owner = {};
79+
const start = recordDiscordChannelMessageSeen(owner, "channel-1", "a");
80+
recordDiscordChannelMessageSeen(owner, "channel-1", "b");
81+
recordDiscordChannelMessageSeen(owner, "channel-1", "c");
82+
const content: Content = { text: "hello" };
83+
84+
const first = applyDiscordStalenessGuard({
85+
config: { enabled: true, behavior: "tag", threshold: 1 },
86+
owner,
87+
message: mockMessage(),
88+
startSequence: start,
89+
content,
90+
});
91+
const second = applyDiscordStalenessGuard({
92+
config: { enabled: true, behavior: "tag", threshold: 1 },
93+
owner,
94+
message: mockMessage(),
95+
startSequence: start,
96+
content,
97+
});
98+
99+
expect(first).toMatchObject({ shouldSend: true, stale: true });
100+
expect(second).toMatchObject({ shouldSend: true, stale: true });
101+
expect(content.text).toBe("(catching up:) hello");
102+
});
103+
});

plugins/plugin-discord/discord-events.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
handleAutocomplete as handleBuiltinAutocomplete,
4444
handleSlashCommand as handleBuiltinSlashCommand,
4545
} from "./slash-commands";
46+
import { recordDiscordChannelMessageSeen } from "./staleness";
4647
import {
4748
DiscordEventTypes,
4849
type DiscordListenChannelPayload,
@@ -333,6 +334,14 @@ export function setupDiscordEventListeners(service: DiscordServiceInternals): {
333334
return;
334335
}
335336

337+
if (service.messageManager) {
338+
recordDiscordChannelMessageSeen(
339+
service.messageManager,
340+
message.channel.id,
341+
message.id,
342+
);
343+
}
344+
336345
if (listenCids.includes(message.channel.id) && message) {
337346
const newMessage = await service.buildMemoryFromMessage(message);
338347

plugins/plugin-discord/messages.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ import { buildDiscordWorldMetadata } from "./identity";
3838
import { formatInboundEnvelope } from "./inbound-envelope";
3939
import { appendCoalescedDiscordMetadata } from "./message-coalesce";
4040
import { stripReasoningTags } from "./reasoning-tags";
41+
import {
42+
applyDiscordStalenessGuard,
43+
type DiscordStalenessConfig,
44+
getDiscordChannelMessageSequence,
45+
getDiscordStalenessConfig,
46+
} from "./staleness";
4147
import {
4248
createStatusReactionController,
4349
type StatusReactionScope,
@@ -124,6 +130,7 @@ export class MessageManager {
124130
private statusReactionScope: StatusReactionScope;
125131
private envelopeEnabled: boolean;
126132
private draftStreamingEnabled: boolean;
133+
private stalenessConfig: DiscordStalenessConfig;
127134
private recentlyProcessedMessageIds = new Map<string, number>();
128135
private static readonly PROCESSED_MESSAGE_TTL_MS = 2 * 60 * 1000;
129136
/**
@@ -171,6 +178,9 @@ export class MessageManager {
171178
) as string | undefined;
172179
this.draftStreamingEnabled =
173180
draftStreamSetting === "true" || draftStreamSetting === "1";
181+
this.stalenessConfig = getDiscordStalenessConfig((key) =>
182+
this.runtime.getSetting(key),
183+
);
174184
}
175185

176186
/**
@@ -644,6 +654,10 @@ export class MessageManager {
644654
}
645655

646656
const messageId = newMessage.id;
657+
const stalenessStartSequence = getDiscordChannelMessageSequence(
658+
this,
659+
message.channel.id,
660+
);
647661
const channel = message.channel as TextChannel;
648662
const typingController = createTypingController(channel);
649663
const clientUserId = this.client.user?.id;
@@ -752,6 +766,35 @@ export class MessageManager {
752766
return [];
753767
}
754768

769+
const stalenessDecision = applyDiscordStalenessGuard({
770+
config: this.stalenessConfig,
771+
owner: this,
772+
message,
773+
startSequence: stalenessStartSequence,
774+
content,
775+
});
776+
if (stalenessDecision.stale) {
777+
this.runtime.logger.warn(
778+
{
779+
src: "plugin:discord",
780+
agentId: this.runtime.agentId,
781+
channelId: message.channel.id,
782+
messageId: message.id,
783+
messagesSinceTurnStart:
784+
stalenessDecision.messagesSinceTurnStart,
785+
threshold: this.stalenessConfig.threshold,
786+
behavior: stalenessDecision.behavior,
787+
},
788+
"Discord response completed after newer channel messages arrived",
789+
);
790+
}
791+
if (!stalenessDecision.shouldSend) {
792+
typingController.stop();
793+
statusReactions?.setDone();
794+
await finalizePendingDraft();
795+
return [];
796+
}
797+
755798
if (message.id && !content.inReplyTo) {
756799
content.inReplyTo = createUniqueUuid(this.runtime, message.id);
757800
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { Content } from "@elizaos/core";
2+
import type { Message as DiscordMessage } from "discord.js";
3+
4+
export type DiscordStalenessBehavior = "tag" | "skip" | "ignore";
5+
6+
export interface DiscordStalenessConfig {
7+
enabled: boolean;
8+
behavior: DiscordStalenessBehavior;
9+
threshold: number;
10+
}
11+
12+
const DEFAULT_THRESHOLD = 2;
13+
const channelSequences = new WeakMap<object, Map<string, number>>();
14+
const lastMessageIds = new WeakMap<object, Map<string, string | undefined>>();
15+
16+
function parseBoolean(value: unknown, fallback: boolean): boolean {
17+
if (value === undefined || value === null) {
18+
return fallback;
19+
}
20+
return String(value).trim().toLowerCase() === "true";
21+
}
22+
23+
function parseNonNegativeInteger(value: unknown, fallback: number): number {
24+
const parsed = Number.parseInt(String(value ?? ""), 10);
25+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
26+
}
27+
28+
function parseBehavior(value: unknown): DiscordStalenessBehavior {
29+
const normalized = String(value ?? "tag")
30+
.trim()
31+
.toLowerCase();
32+
return normalized === "skip" ||
33+
normalized === "ignore" ||
34+
normalized === "tag"
35+
? normalized
36+
: "tag";
37+
}
38+
39+
function ensureSequenceMap(owner: object): Map<string, number> {
40+
let map = channelSequences.get(owner);
41+
if (!map) {
42+
map = new Map<string, number>();
43+
channelSequences.set(owner, map);
44+
}
45+
return map;
46+
}
47+
48+
export function getDiscordStalenessConfig(
49+
getSetting: (key: string) => unknown,
50+
): DiscordStalenessConfig {
51+
return {
52+
enabled: parseBoolean(getSetting("DISCORD_STALENESS_ENABLED"), false),
53+
behavior: parseBehavior(getSetting("DISCORD_STALENESS_BEHAVIOR")),
54+
threshold: parseNonNegativeInteger(
55+
getSetting("DISCORD_STALENESS_THRESHOLD"),
56+
DEFAULT_THRESHOLD,
57+
),
58+
};
59+
}
60+
61+
export function recordDiscordChannelMessageSeen(
62+
owner: object | undefined,
63+
channelId: string | undefined,
64+
messageId?: string,
65+
): number {
66+
if (!owner || !channelId) {
67+
return 0;
68+
}
69+
const sequences = ensureSequenceMap(owner);
70+
const next = (sequences.get(channelId) ?? 0) + 1;
71+
sequences.set(channelId, next);
72+
73+
let lastIds = lastMessageIds.get(owner);
74+
if (!lastIds) {
75+
lastIds = new Map<string, string | undefined>();
76+
lastMessageIds.set(owner, lastIds);
77+
}
78+
lastIds.set(channelId, messageId);
79+
80+
return next;
81+
}
82+
83+
export function getDiscordChannelMessageSequence(
84+
owner: object | undefined,
85+
channelId: string | undefined,
86+
): number {
87+
if (!owner || !channelId) {
88+
return 0;
89+
}
90+
return ensureSequenceMap(owner).get(channelId) ?? 0;
91+
}
92+
93+
export interface DiscordStalenessDecision {
94+
shouldSend: boolean;
95+
stale: boolean;
96+
messagesSinceTurnStart: number;
97+
behavior: DiscordStalenessBehavior;
98+
}
99+
100+
export function applyDiscordStalenessGuard(options: {
101+
config: DiscordStalenessConfig;
102+
owner: object | undefined;
103+
message: DiscordMessage;
104+
startSequence: number;
105+
content: Content;
106+
}): DiscordStalenessDecision {
107+
const { config, owner, message, startSequence, content } = options;
108+
if (!config.enabled || config.behavior === "ignore") {
109+
return {
110+
shouldSend: true,
111+
stale: false,
112+
messagesSinceTurnStart: 0,
113+
behavior: config.behavior,
114+
};
115+
}
116+
117+
const currentSequence = getDiscordChannelMessageSequence(
118+
owner,
119+
message.channel?.id,
120+
);
121+
const messagesSinceTurnStart = Math.max(0, currentSequence - startSequence);
122+
const stale = messagesSinceTurnStart > config.threshold;
123+
if (!stale) {
124+
return {
125+
shouldSend: true,
126+
stale: false,
127+
messagesSinceTurnStart,
128+
behavior: config.behavior,
129+
};
130+
}
131+
132+
if (config.behavior === "skip") {
133+
return {
134+
shouldSend: false,
135+
stale: true,
136+
messagesSinceTurnStart,
137+
behavior: config.behavior,
138+
};
139+
}
140+
141+
if (
142+
config.behavior === "tag" &&
143+
typeof content.text === "string" &&
144+
content.text.trim().length > 0 &&
145+
!/^(\s*\(catching up:\))/i.test(content.text)
146+
) {
147+
content.text = `(catching up:) ${content.text}`;
148+
}
149+
150+
return {
151+
shouldSend: true,
152+
stale: true,
153+
messagesSinceTurnStart,
154+
behavior: config.behavior,
155+
};
156+
}

0 commit comments

Comments
 (0)