@botcord/daemon 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Loop-risk guard — detects patterns that suggest two daemon-hosted agents
3
+ * are stuck echoing each other (or that a conversation has naturally
4
+ * wound down but both sides keep sending courtesy acks). When triggered,
5
+ * `buildLoopRiskPrompt()` returns an injected hint that encourages the
6
+ * agent to reply with `NO_REPLY` unless it has something substantive to
7
+ * add.
8
+ *
9
+ * Ported from `plugin/src/loop-risk.ts` with one structural change: plugin
10
+ * has OpenClaw's message transcript available (`messages: unknown[]`) so
11
+ * it can reconstruct historical user turns on demand. Daemon does not —
12
+ * Claude Code owns the transcript, not daemon. So daemon records inbound
13
+ * texts in a module-level map the same way plugin records outbound ones.
14
+ *
15
+ * Detectors:
16
+ * - high_turn_rate — many user↔assistant alternations in a short window
17
+ * - short_ack_tail — the last two inbound texts are acks / closure phrases
18
+ * - repeated_outbound — recent outbound replies are highly similar
19
+ */
20
+ // Module-level state. Keys are caller-supplied session identifiers
21
+ // (daemon uses `${accountId}:${conversationId}:${threadId ?? ""}`).
22
+ const inboundBySession = new Map();
23
+ const outboundBySession = new Map();
24
+ // --- Tunables ---------------------------------------------------------
25
+ const TURN_WINDOW_MS = 2 * 60_000;
26
+ const TURN_THRESHOLD = 8;
27
+ const ALTERNATION_THRESHOLD = 6;
28
+ const MIN_TURNS_PER_SIDE = 3;
29
+ const SAMPLE_MAX_AGE_MS = 10 * 60_000;
30
+ const MAX_TRACKED_PER_SIDE = 6;
31
+ const SHORT_ACK_MAX_CHARS = 48;
32
+ const MIN_REPEAT_TEXT_CHARS = 6;
33
+ const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
34
+ // --- Ack / closure phrase lists (mirrors plugin) ----------------------
35
+ const ENGLISH_ACK_OR_CLOSURE = new Set([
36
+ "ok", "okay", "got it", "thanks", "thank you", "noted", "understood",
37
+ "sounds good", "sgtm", "roger", "copy", "will do", "all good",
38
+ "no worries", "bye", "goodbye", "see you", "talk later",
39
+ ]);
40
+ const CHINESE_ACK_OR_CLOSURE = new Set([
41
+ "收到", "好的", "好", "行", "嗯", "嗯嗯", "明白", "明白了", "知道了",
42
+ "谢谢", "谢谢你", "感谢", "辛苦了", "先这样", "回头聊", "有需要再说",
43
+ "没问题", "了解", "好嘞",
44
+ ]);
45
+ // --- Text normalization -----------------------------------------------
46
+ /**
47
+ * Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
48
+ * wrappers the user-turn composer adds around the raw inbound text. Leaves
49
+ * the plain body so similarity / ack detection operates on actual content.
50
+ * Kept in sync with `turn-text.ts` output shape.
51
+ */
52
+ export function stripBotCordPromptScaffolding(text) {
53
+ const filtered = text
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .filter((line) => {
57
+ if (!line)
58
+ return false;
59
+ if (line.startsWith("[BotCord Message]"))
60
+ return false;
61
+ if (line.startsWith("[BotCord Notification]"))
62
+ return false;
63
+ if (line.startsWith("[Room Rule]"))
64
+ return false;
65
+ if (line.startsWith("[In group chats, do NOT reply"))
66
+ return false;
67
+ if (line.startsWith("[If the conversation has naturally concluded"))
68
+ return false;
69
+ if (line.startsWith("[You received a contact request"))
70
+ return false;
71
+ if (line.includes('reply with exactly "NO_REPLY"'))
72
+ return false;
73
+ if (line.startsWith("<agent-message"))
74
+ return false;
75
+ if (line === "</agent-message>")
76
+ return false;
77
+ if (line.startsWith("<human-message"))
78
+ return false;
79
+ if (line === "</human-message>")
80
+ return false;
81
+ return true;
82
+ });
83
+ return filtered.join("\n").trim();
84
+ }
85
+ export function normalizeLoopText(text) {
86
+ return stripBotCordPromptScaffolding(text)
87
+ .toLowerCase()
88
+ .replace(/https?:\/\/\S+/gu, " ")
89
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
90
+ .replace(/\s+/gu, " ")
91
+ .trim();
92
+ }
93
+ function isShortAckOrClosure(text) {
94
+ const n = normalizeLoopText(text);
95
+ if (!n || n.length > SHORT_ACK_MAX_CHARS)
96
+ return false;
97
+ return ENGLISH_ACK_OR_CLOSURE.has(n) || CHINESE_ACK_OR_CLOSURE.has(n);
98
+ }
99
+ // --- Similarity -------------------------------------------------------
100
+ function trigramSet(text) {
101
+ if (text.length <= 3)
102
+ return new Set([text]);
103
+ const grams = new Set();
104
+ for (let i = 0; i <= text.length - 3; i++)
105
+ grams.add(text.slice(i, i + 3));
106
+ return grams;
107
+ }
108
+ function jaccardSimilarity(a, b) {
109
+ if (!a || !b)
110
+ return 0;
111
+ if (a === b)
112
+ return 1;
113
+ const A = trigramSet(a);
114
+ const B = trigramSet(b);
115
+ let inter = 0;
116
+ for (const g of A)
117
+ if (B.has(g))
118
+ inter++;
119
+ const union = A.size + B.size - inter;
120
+ return union === 0 ? 0 : inter / union;
121
+ }
122
+ function areOutboundTextsHighlySimilar(a, b) {
123
+ if (!a || !b)
124
+ return false;
125
+ if (a === b)
126
+ return true;
127
+ if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS)
128
+ return false;
129
+ return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
130
+ }
131
+ // --- Sample store -----------------------------------------------------
132
+ function prune(map, sessionKey, now) {
133
+ const existing = map.get(sessionKey) ?? [];
134
+ const next = existing.filter((s) => now - s.timestamp <= SAMPLE_MAX_AGE_MS);
135
+ if (next.length === 0) {
136
+ map.delete(sessionKey);
137
+ return [];
138
+ }
139
+ map.set(sessionKey, next);
140
+ return next;
141
+ }
142
+ function record(map, sessionKey, sample) {
143
+ const kept = prune(map, sessionKey, sample.timestamp);
144
+ const next = [...kept, sample].slice(-MAX_TRACKED_PER_SIDE);
145
+ map.set(sessionKey, next);
146
+ }
147
+ export function recordInboundText(params) {
148
+ const sessionKey = params.sessionKey?.trim();
149
+ const raw = typeof params.text === "string" ? params.text.trim() : "";
150
+ if (!sessionKey || !raw)
151
+ return;
152
+ const normalized = normalizeLoopText(raw);
153
+ if (!normalized)
154
+ return;
155
+ record(inboundBySession, sessionKey, {
156
+ text: raw,
157
+ normalized,
158
+ timestamp: params.timestamp ?? Date.now(),
159
+ });
160
+ }
161
+ export function recordOutboundText(params) {
162
+ const sessionKey = params.sessionKey?.trim();
163
+ const raw = typeof params.text === "string" ? params.text.trim() : "";
164
+ if (!sessionKey || !raw)
165
+ return;
166
+ const normalized = normalizeLoopText(raw);
167
+ if (!normalized)
168
+ return;
169
+ record(outboundBySession, sessionKey, {
170
+ text: raw,
171
+ normalized,
172
+ timestamp: params.timestamp ?? Date.now(),
173
+ });
174
+ }
175
+ export function clearLoopRiskSession(sessionKey) {
176
+ if (!sessionKey)
177
+ return;
178
+ inboundBySession.delete(sessionKey);
179
+ outboundBySession.delete(sessionKey);
180
+ }
181
+ export function resetLoopRiskStateForTests() {
182
+ inboundBySession.clear();
183
+ outboundBySession.clear();
184
+ }
185
+ // --- Detectors --------------------------------------------------------
186
+ function detectHighTurnRate(inbound, outbound, now) {
187
+ const timeline = [];
188
+ for (const s of inbound) {
189
+ if (now - s.timestamp <= TURN_WINDOW_MS)
190
+ timeline.push({ role: "user", timestamp: s.timestamp });
191
+ }
192
+ for (const s of outbound) {
193
+ if (now - s.timestamp <= TURN_WINDOW_MS)
194
+ timeline.push({ role: "assistant", timestamp: s.timestamp });
195
+ }
196
+ if (timeline.length < TURN_THRESHOLD)
197
+ return undefined;
198
+ timeline.sort((a, b) => a.timestamp - b.timestamp);
199
+ let userTurns = 0;
200
+ let assistantTurns = 0;
201
+ let alternations = 0;
202
+ for (let i = 0; i < timeline.length; i++) {
203
+ if (timeline[i].role === "user")
204
+ userTurns++;
205
+ else
206
+ assistantTurns++;
207
+ if (i > 0 && timeline[i].role !== timeline[i - 1].role)
208
+ alternations++;
209
+ }
210
+ if (userTurns >= MIN_TURNS_PER_SIDE &&
211
+ assistantTurns >= MIN_TURNS_PER_SIDE &&
212
+ alternations >= ALTERNATION_THRESHOLD) {
213
+ return {
214
+ id: "high_turn_rate",
215
+ summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
216
+ };
217
+ }
218
+ return undefined;
219
+ }
220
+ function detectShortAckTail(inbound) {
221
+ const tail = inbound.slice(-2);
222
+ if (tail.length < 2)
223
+ return undefined;
224
+ if (tail.every((s) => isShortAckOrClosure(s.text))) {
225
+ return {
226
+ id: "short_ack_tail",
227
+ summary: "the last two inbound user messages are short acknowledgements or closure phrases",
228
+ };
229
+ }
230
+ return undefined;
231
+ }
232
+ function detectRepeatedOutbound(outbound) {
233
+ const recent = outbound.slice(-3);
234
+ if (recent.length < 2)
235
+ return undefined;
236
+ const last = recent[recent.length - 1];
237
+ if (!last)
238
+ return undefined;
239
+ const previous = recent.slice(0, -1);
240
+ const exact = previous.filter((s) => s.normalized === last.normalized).length;
241
+ const similar = previous.filter((s) => areOutboundTextsHighlySimilar(s.normalized, last.normalized)).length;
242
+ if (exact >= 1 || (recent.length >= 3 && similar >= 2)) {
243
+ return {
244
+ id: "repeated_outbound",
245
+ summary: "recent outbound texts in this session are highly similar",
246
+ };
247
+ }
248
+ return undefined;
249
+ }
250
+ export function evaluateLoopRisk(params) {
251
+ const now = params.now ?? Date.now();
252
+ if (!params.sessionKey)
253
+ return { reasons: [] };
254
+ const inbound = prune(inboundBySession, params.sessionKey, now);
255
+ const outbound = prune(outboundBySession, params.sessionKey, now);
256
+ const reasons = [
257
+ detectHighTurnRate(inbound, outbound, now),
258
+ detectShortAckTail(inbound),
259
+ detectRepeatedOutbound(outbound),
260
+ ].filter((r) => Boolean(r));
261
+ return { reasons };
262
+ }
263
+ /** Build the injected system-context hint, or `null` if no risk detected. */
264
+ export function buildLoopRiskPrompt(params) {
265
+ const evaluation = evaluateLoopRisk(params);
266
+ if (evaluation.reasons.length === 0)
267
+ return null;
268
+ return [
269
+ "[BotCord loop-risk check]",
270
+ "Observed signals:",
271
+ ...evaluation.reasons.map((r) => `- ${r.summary}`),
272
+ "",
273
+ "Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
274
+ 'If it does not, reply with exactly "NO_REPLY" and nothing else.',
275
+ "Do not send courtesy-only acknowledgements or mirrored sign-offs.",
276
+ ].join("\n");
277
+ }
278
+ /**
279
+ * Derive a loop-risk session key from a gateway inbound message or
280
+ * outbound reply. Keyed on (accountId, conversationId, threadId) so a
281
+ * DM and a group thread under the same agent don't share state.
282
+ */
283
+ export function loopRiskSessionKey(params) {
284
+ const thread = params.threadId ?? "";
285
+ return `${params.accountId}:${params.conversationId}:${thread}`;
286
+ }
@@ -46,6 +46,12 @@ export interface SystemContextDeps {
46
46
  * is skipped.
47
47
  */
48
48
  roomContextBuilder?: RoomStaticContextBuilder;
49
+ /**
50
+ * Optional per-turn loop-risk check. Returns a warning block when the
51
+ * session shows signs of agent-to-agent echo or courtesy loops. Sync
52
+ * + cheap — consulted every turn even when roomContextBuilder is absent.
53
+ */
54
+ loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
49
55
  }
50
56
  /**
51
57
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
@@ -53,10 +53,28 @@ export function createDaemonSystemContextBuilder(deps) {
53
53
  const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
54
54
  return filtered.length > 0 ? filtered.join("\n\n") : undefined;
55
55
  };
56
+ const runLoopRisk = (message) => {
57
+ if (!deps.loopRiskBuilder)
58
+ return null;
59
+ try {
60
+ return deps.loopRiskBuilder(message);
61
+ }
62
+ catch (err) {
63
+ log.warn("system-context: loopRiskBuilder threw — skipping loop-risk block", {
64
+ agentId: deps.agentId,
65
+ roomId: message.conversation.id,
66
+ err: err instanceof Error ? err.message : String(err),
67
+ });
68
+ return null;
69
+ }
70
+ };
56
71
  if (!deps.roomContextBuilder) {
57
72
  const syncBuilder = (message) => {
58
73
  const { ownerScene, memory, digest } = gatherSyncBlocks(message);
59
- return assemble([ownerScene, memory, digest]);
74
+ // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
75
+ // is the last thing the model sees before the user turn body.
76
+ const loopRisk = runLoopRisk(message);
77
+ return assemble([ownerScene, memory, digest, loopRisk]);
60
78
  };
61
79
  // Compile-time witness that the narrower sync signature still satisfies
62
80
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -83,7 +101,8 @@ export function createDaemonSystemContextBuilder(deps) {
83
101
  err: err instanceof Error ? err.message : String(err),
84
102
  });
85
103
  }
86
- return assemble([ownerScene, memory, roomBlock, digest]);
104
+ const loopRisk = runLoopRisk(message);
105
+ return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
87
106
  };
88
107
  const _typecheck = asyncBuilder;
89
108
  void _typecheck;
package/dist/turn-text.js CHANGED
@@ -1,9 +1,56 @@
1
- import { sanitizeSenderName } from "./gateway/index.js";
1
+ import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
2
2
  import { classifyActivitySender } from "./sender-classify.js";
3
3
  const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
4
4
  'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
5
5
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
6
6
  'reply with exactly "NO_REPLY" and nothing else.]';
7
+ /**
8
+ * Read the BotCord envelope type from a raw inbound message. Returns
9
+ * `undefined` when the message didn't come from the BotCord channel or the
10
+ * raw shape is unexpected — callers treat that the same as "message".
11
+ */
12
+ function readEnvelopeType(raw) {
13
+ if (!raw || typeof raw !== "object")
14
+ return undefined;
15
+ const env = raw.envelope;
16
+ if (!env || typeof env !== "object")
17
+ return undefined;
18
+ const t = env.type;
19
+ return typeof t === "string" ? t : undefined;
20
+ }
21
+ /**
22
+ * Read the `raw.batch` array emitted by the BotCord channel when inbox
23
+ * drain groups multiple messages for the same `(room, topic)`. Returns the
24
+ * list when present and well-shaped, else null. Single-message envelopes
25
+ * have no `batch` field and fall through to the single-message path.
26
+ */
27
+ function readBatch(raw) {
28
+ if (!raw || typeof raw !== "object")
29
+ return null;
30
+ const b = raw.batch;
31
+ if (!Array.isArray(b) || b.length < 2)
32
+ return null;
33
+ return b;
34
+ }
35
+ function entryFromLabel(e) {
36
+ const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
37
+ const isHuman = e.source_type === "dashboard_human_room" ||
38
+ (typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
39
+ const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
40
+ const label = isHuman
41
+ ? typeof e.source_user_name === "string" && e.source_user_name
42
+ ? e.source_user_name
43
+ : "User"
44
+ : fromId;
45
+ return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
46
+ }
47
+ function entryText(e) {
48
+ if (typeof e.text === "string")
49
+ return e.text;
50
+ if (typeof e.envelope?.payload?.text === "string")
51
+ return e.envelope.payload.text;
52
+ return "";
53
+ }
7
54
  /**
8
55
  * Compose the user-turn text for a BotCord inbound message.
9
56
  *
@@ -23,6 +70,10 @@ export function composeBotCordUserTurn(msg) {
23
70
  // system-context handles context; wrapping here would just add noise.
24
71
  if (sender.kind === "owner")
25
72
  return trimmed;
73
+ const batch = readBatch(msg.raw);
74
+ if (batch) {
75
+ return composeBatchedTurn(msg, batch);
76
+ }
26
77
  const conversation = msg.conversation;
27
78
  const isGroup = conversation.kind === "group";
28
79
  const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
@@ -46,12 +97,78 @@ export function composeBotCordUserTurn(msg) {
46
97
  const tag = sender.kind === "human" ? "human-message" : "agent-message";
47
98
  const senderKindAttr = sender.kind === "human" ? "human" : "agent";
48
99
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
49
- return [
100
+ // Contact-request envelopes travel through the same "inbound message"
101
+ // path as regular messages, but carry an additional expectation: the
102
+ // agent should surface the request to its owner rather than auto-accept
103
+ // or auto-reject. Mirrors `plugin/src/inbound.ts` §handleA2ASingle.
104
+ const isContactRequest = readEnvelopeType(msg.raw) === "contact_request";
105
+ const contactRequestHint = isContactRequest
106
+ ? "[You received a contact request from " +
107
+ sanitizedSenderLabel +
108
+ ". Use the botcord_notify tool to inform your owner about this request so " +
109
+ "they can decide whether to accept or reject it. Include the sender's " +
110
+ "agent ID and any message they attached.]"
111
+ : null;
112
+ const lines = [
50
113
  headerFields.join(" | "),
51
114
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
52
115
  trimmed,
53
116
  `</${tag}>`,
54
117
  "",
55
118
  hint,
56
- ].join("\n");
119
+ ];
120
+ if (contactRequestHint) {
121
+ lines.push("", contactRequestHint);
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ /**
126
+ * Render a batched turn (≥2 messages from the same room/topic folded into
127
+ * one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
128
+ * `handleA2AGroup` output shape so Claude Code sees the same prompt
129
+ * whether driven by OpenClaw or by daemon.
130
+ */
131
+ function composeBatchedTurn(msg, batch) {
132
+ const conversation = msg.conversation;
133
+ const isGroup = conversation.kind === "group";
134
+ const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
135
+ const header = [
136
+ `[BotCord Messages (${batch.length} new)]`,
137
+ `to: ${msg.accountId}`,
138
+ ];
139
+ if (isGroup && roomTitle) {
140
+ const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
141
+ header.push(`room: ${safeRoom}`);
142
+ }
143
+ if (msg.mentioned) {
144
+ header.push("mentioned: true");
145
+ }
146
+ const blocks = [];
147
+ const contactRequestSenders = [];
148
+ for (const entry of batch) {
149
+ const { label, kind, envelopeType } = entryFromLabel(entry);
150
+ const safeLabel = sanitizeSenderName(label);
151
+ const raw = entryText(entry);
152
+ // Owner-trust bypass is handled at the outer level — by the time we
153
+ // reach a batched turn the sender classifier has already returned
154
+ // non-owner. Still sanitize defensively.
155
+ const safeBody = sanitizeUntrustedContent(raw);
156
+ const tag = kind === "human" ? "human-message" : "agent-message";
157
+ blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
158
+ if (envelopeType === "contact_request") {
159
+ contactRequestSenders.push(safeLabel);
160
+ }
161
+ }
162
+ const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
163
+ const lines = [header.join(" | "), blocks.join("\n"), "", hint];
164
+ if (contactRequestSenders.length > 0) {
165
+ // Dedup + list — multiple distinct senders show as "A, B".
166
+ const unique = Array.from(new Set(contactRequestSenders));
167
+ lines.push("", "[You received a contact request from " +
168
+ unique.join(", ") +
169
+ ". Use the botcord_notify tool to inform your owner about this request so " +
170
+ "they can decide whether to accept or reject it. Include the sender's " +
171
+ "agent ID and any message they attached.]");
172
+ }
173
+ return lines.join("\n");
57
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@botcord/protocol-core": "^0.1.1",
30
+ "@botcord/protocol-core": "^0.2.0",
31
31
  "ws": "^8.18.0"
32
32
  },
33
33
  "devDependencies": {
@@ -0,0 +1,172 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ buildLoopRiskPrompt,
4
+ clearLoopRiskSession,
5
+ evaluateLoopRisk,
6
+ loopRiskSessionKey,
7
+ recordInboundText,
8
+ recordOutboundText,
9
+ resetLoopRiskStateForTests,
10
+ stripBotCordPromptScaffolding,
11
+ } from "../loop-risk.js";
12
+
13
+ afterEach(() => {
14
+ resetLoopRiskStateForTests();
15
+ });
16
+
17
+ describe("stripBotCordPromptScaffolding", () => {
18
+ it("removes headers, hints, and wrapper tags but keeps the actual body", () => {
19
+ const wrapped = [
20
+ "[BotCord Message] | from: ag_alice | to: ag_me | room: Team",
21
+ '<agent-message sender="ag_alice" sender_kind="agent">',
22
+ "hello world",
23
+ "</agent-message>",
24
+ "",
25
+ '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]',
26
+ ].join("\n");
27
+ expect(stripBotCordPromptScaffolding(wrapped)).toBe("hello world");
28
+ });
29
+
30
+ it("leaves plain text untouched", () => {
31
+ expect(stripBotCordPromptScaffolding("just a line")).toBe("just a line");
32
+ });
33
+ });
34
+
35
+ describe("evaluateLoopRisk", () => {
36
+ const key = "ag_me:rm_team:";
37
+
38
+ it("returns no reasons for an unknown session", () => {
39
+ expect(evaluateLoopRisk({ sessionKey: "unknown" })).toEqual({ reasons: [] });
40
+ });
41
+
42
+ it("flags short_ack_tail when the last two inbound messages are both acks", () => {
43
+ const now = 1_700_000_000_000;
44
+ recordInboundText({ sessionKey: key, text: "Let's ship it", timestamp: now - 3000 });
45
+ recordInboundText({ sessionKey: key, text: "Thanks!", timestamp: now - 2000 });
46
+ recordInboundText({ sessionKey: key, text: "好的", timestamp: now - 1000 });
47
+ const out = evaluateLoopRisk({ sessionKey: key, now });
48
+ expect(out.reasons.some((r) => r.id === "short_ack_tail")).toBe(true);
49
+ });
50
+
51
+ it("does NOT flag short_ack_tail when only one message is an ack", () => {
52
+ const now = 1_700_000_000_000;
53
+ recordInboundText({ sessionKey: key, text: "Can you help with X?", timestamp: now - 2000 });
54
+ recordInboundText({ sessionKey: key, text: "OK", timestamp: now - 1000 });
55
+ const out = evaluateLoopRisk({ sessionKey: key, now });
56
+ expect(out.reasons.some((r) => r.id === "short_ack_tail")).toBe(false);
57
+ });
58
+
59
+ it("flags repeated_outbound when the last outbound reply matches the previous one exactly", () => {
60
+ const now = 1_700_000_000_000;
61
+ recordOutboundText({ sessionKey: key, text: "Got it, thanks!", timestamp: now - 2000 });
62
+ recordOutboundText({ sessionKey: key, text: "Got it, thanks!", timestamp: now - 1000 });
63
+ const out = evaluateLoopRisk({ sessionKey: key, now });
64
+ expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(true);
65
+ });
66
+
67
+ it("flags repeated_outbound on high trigram similarity (>= 0.88)", () => {
68
+ const now = 1_700_000_000_000;
69
+ const a = "Sounds good, I'll take care of that shortly.";
70
+ const b = "Sounds good, I'll take care of that shortly!";
71
+ const c = "Sounds good, I'll take care of that shortly :)";
72
+ recordOutboundText({ sessionKey: key, text: a, timestamp: now - 3000 });
73
+ recordOutboundText({ sessionKey: key, text: b, timestamp: now - 2000 });
74
+ recordOutboundText({ sessionKey: key, text: c, timestamp: now - 1000 });
75
+ const out = evaluateLoopRisk({ sessionKey: key, now });
76
+ expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(true);
77
+ });
78
+
79
+ it("flags high_turn_rate on rapid user↔assistant alternation", () => {
80
+ const now = 1_700_000_000_000;
81
+ // 8 turns over the last 60s, tightly alternating.
82
+ const base = now - 60_000;
83
+ for (let i = 0; i < 4; i++) {
84
+ recordInboundText({
85
+ sessionKey: key,
86
+ text: `inbound ${i}`,
87
+ timestamp: base + i * 14_000,
88
+ });
89
+ recordOutboundText({
90
+ sessionKey: key,
91
+ text: `outbound ${i}`,
92
+ timestamp: base + i * 14_000 + 7_000,
93
+ });
94
+ }
95
+ const out = evaluateLoopRisk({ sessionKey: key, now });
96
+ expect(out.reasons.some((r) => r.id === "high_turn_rate")).toBe(true);
97
+ });
98
+
99
+ it("does NOT flag high_turn_rate when the turns are spread out beyond the 2-minute window", () => {
100
+ const now = 1_700_000_000_000;
101
+ const base = now - 5 * 60_000;
102
+ for (let i = 0; i < 4; i++) {
103
+ recordInboundText({
104
+ sessionKey: key,
105
+ text: `in ${i}`,
106
+ timestamp: base + i * 30_000,
107
+ });
108
+ recordOutboundText({
109
+ sessionKey: key,
110
+ text: `out ${i}`,
111
+ timestamp: base + i * 30_000 + 1000,
112
+ });
113
+ }
114
+ const out = evaluateLoopRisk({ sessionKey: key, now });
115
+ expect(out.reasons.some((r) => r.id === "high_turn_rate")).toBe(false);
116
+ });
117
+
118
+ it("prunes samples older than the 10-minute max age", () => {
119
+ const now = 1_700_000_000_000;
120
+ recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now - 20 * 60_000 });
121
+ recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now - 15 * 60_000 });
122
+ // Same text immediately before now would trigger repeated_outbound if the
123
+ // old samples survived; they should be pruned, so no flag fires.
124
+ recordOutboundText({ sessionKey: key, text: "ancient", timestamp: now });
125
+ const out = evaluateLoopRisk({ sessionKey: key, now });
126
+ expect(out.reasons.some((r) => r.id === "repeated_outbound")).toBe(false);
127
+ });
128
+
129
+ it("clearLoopRiskSession drops all state for the given key", () => {
130
+ const now = 1_700_000_000_000;
131
+ recordOutboundText({ sessionKey: key, text: "same", timestamp: now - 1000 });
132
+ recordOutboundText({ sessionKey: key, text: "same", timestamp: now });
133
+ clearLoopRiskSession(key);
134
+ expect(evaluateLoopRisk({ sessionKey: key, now }).reasons).toEqual([]);
135
+ });
136
+ });
137
+
138
+ describe("buildLoopRiskPrompt", () => {
139
+ const key = "ag_me:rm_x:";
140
+
141
+ it("returns null when no risk is detected", () => {
142
+ expect(buildLoopRiskPrompt({ sessionKey: key })).toBeNull();
143
+ });
144
+
145
+ it("renders the full prompt block when a risk fires", () => {
146
+ const now = 1_700_000_000_000;
147
+ recordOutboundText({ sessionKey: key, text: "same outbound", timestamp: now - 1000 });
148
+ recordOutboundText({ sessionKey: key, text: "same outbound", timestamp: now });
149
+ const out = buildLoopRiskPrompt({ sessionKey: key, now });
150
+ expect(out).toContain("[BotCord loop-risk check]");
151
+ expect(out).toContain("Observed signals:");
152
+ expect(out).toContain("recent outbound texts in this session are highly similar");
153
+ expect(out).toContain('reply with exactly "NO_REPLY"');
154
+ });
155
+ });
156
+
157
+ describe("loopRiskSessionKey", () => {
158
+ it("includes threadId when present", () => {
159
+ expect(
160
+ loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1", threadId: "tp_a" }),
161
+ ).toBe("ag_me:rm_1:tp_a");
162
+ });
163
+
164
+ it("uses empty string for threadId when null/undefined", () => {
165
+ expect(loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1" })).toBe(
166
+ "ag_me:rm_1:",
167
+ );
168
+ expect(
169
+ loopRiskSessionKey({ accountId: "ag_me", conversationId: "rm_1", threadId: null }),
170
+ ).toBe("ag_me:rm_1:");
171
+ });
172
+ });