@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.
- package/dist/daemon-config-map.js +4 -3
- package/dist/daemon.js +35 -1
- package/dist/gateway/channels/botcord.d.ts +11 -0
- package/dist/gateway/channels/botcord.js +86 -6
- package/dist/gateway/dispatcher.d.ts +8 -1
- package/dist/gateway/dispatcher.js +14 -0
- package/dist/gateway/gateway.d.ts +6 -1
- package/dist/gateway/gateway.js +1 -0
- package/dist/gateway/router.d.ts +8 -3
- package/dist/gateway/router.js +14 -4
- package/dist/gateway/types.d.ts +6 -0
- package/dist/loop-risk.d.ts +65 -0
- package/dist/loop-risk.js +286 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +21 -2
- package/dist/turn-text.js +120 -3
- package/package.json +2 -2
- package/src/__tests__/loop-risk.test.ts +172 -0
- package/src/__tests__/system-context.test.ts +35 -0
- package/src/__tests__/turn-text.test.ts +143 -0
- package/src/daemon-config-map.ts +4 -3
- package/src/daemon.ts +42 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +89 -0
- package/src/gateway/__tests__/dispatcher.test.ts +40 -0
- package/src/gateway/__tests__/router.test.ts +27 -1
- package/src/gateway/channels/botcord.ts +102 -6
- package/src/gateway/dispatcher.ts +20 -0
- package/src/gateway/gateway.ts +7 -0
- package/src/gateway/router.ts +18 -4
- package/src/gateway/types.ts +9 -0
- package/src/loop-risk.ts +322 -0
- package/src/system-context.ts +26 -2
- package/src/turn-text.ts +151 -3
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
GatewayRoute,
|
|
10
10
|
GatewaySessionEntry,
|
|
11
11
|
InboundObserver,
|
|
12
|
+
OutboundObserver,
|
|
12
13
|
QueueMode,
|
|
13
14
|
RuntimeAdapter,
|
|
14
15
|
StreamBlock,
|
|
@@ -58,6 +59,12 @@ export interface DispatcherOptions {
|
|
|
58
59
|
* a fallback so a buggy composer cannot drop turns.
|
|
59
60
|
*/
|
|
60
61
|
composeUserTurn?: UserTurnBuilder;
|
|
62
|
+
/**
|
|
63
|
+
* Optional observer fired after each reply is dispatched. Intended for
|
|
64
|
+
* outbound bookkeeping such as loop-risk tracking. Errors are logged
|
|
65
|
+
* and suppressed so observer failures never break the turn.
|
|
66
|
+
*/
|
|
67
|
+
onOutbound?: OutboundObserver;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
interface TurnSlot {
|
|
@@ -102,6 +109,7 @@ export class Dispatcher {
|
|
|
102
109
|
private readonly turnTimeoutMs: number;
|
|
103
110
|
private readonly buildSystemContext?: SystemContextBuilder;
|
|
104
111
|
private readonly onInbound?: InboundObserver;
|
|
112
|
+
private readonly onOutbound?: OutboundObserver;
|
|
105
113
|
private readonly composeUserTurn?: UserTurnBuilder;
|
|
106
114
|
private readonly managedRoutes?: Map<string, GatewayRoute>;
|
|
107
115
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
@@ -115,6 +123,7 @@ export class Dispatcher {
|
|
|
115
123
|
this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
116
124
|
this.buildSystemContext = opts.buildSystemContext;
|
|
117
125
|
this.onInbound = opts.onInbound;
|
|
126
|
+
this.onOutbound = opts.onOutbound;
|
|
118
127
|
this.composeUserTurn = opts.composeUserTurn;
|
|
119
128
|
this.managedRoutes = opts.managedRoutes;
|
|
120
129
|
}
|
|
@@ -532,6 +541,17 @@ export class Dispatcher {
|
|
|
532
541
|
conversationId: outbound.conversationId,
|
|
533
542
|
error: err instanceof Error ? err.message : String(err),
|
|
534
543
|
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (this.onOutbound) {
|
|
547
|
+
try {
|
|
548
|
+
await this.onOutbound(outbound);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
this.log.warn("dispatcher: onOutbound threw — continuing", {
|
|
551
|
+
conversationId: outbound.conversationId,
|
|
552
|
+
error: err instanceof Error ? err.message : String(err),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
535
555
|
}
|
|
536
556
|
}
|
|
537
557
|
}
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
GatewayRoute,
|
|
11
11
|
GatewayRuntimeSnapshot,
|
|
12
12
|
InboundObserver,
|
|
13
|
+
OutboundObserver,
|
|
13
14
|
SystemContextBuilder,
|
|
14
15
|
UserTurnBuilder,
|
|
15
16
|
} from "./types.js";
|
|
@@ -42,6 +43,11 @@ export interface GatewayBootOptions {
|
|
|
42
43
|
* to the runtime. Forwarded to the dispatcher; see {@link UserTurnBuilder}.
|
|
43
44
|
*/
|
|
44
45
|
composeUserTurn?: UserTurnBuilder;
|
|
46
|
+
/**
|
|
47
|
+
* Optional observer fired after each reply is sent. Intended for outbound
|
|
48
|
+
* bookkeeping like loop-risk tracking.
|
|
49
|
+
*/
|
|
50
|
+
onOutbound?: OutboundObserver;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** Default runtime factory: delegates to the built-in registry; ignores extraArgs at construction. */
|
|
@@ -110,6 +116,7 @@ export class Gateway {
|
|
|
110
116
|
buildSystemContext: opts.buildSystemContext,
|
|
111
117
|
onInbound: opts.onInbound,
|
|
112
118
|
composeUserTurn: opts.composeUserTurn,
|
|
119
|
+
onOutbound: opts.onOutbound,
|
|
113
120
|
managedRoutes: this.managedRoutes,
|
|
114
121
|
});
|
|
115
122
|
|
package/src/gateway/router.ts
CHANGED
|
@@ -38,9 +38,14 @@ export function matchesRoute(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Picks the first matching route in priority order:
|
|
41
|
-
* 1. `config.routes[]`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
42
|
+
* accountId — explicit operator override for a specific agent.
|
|
43
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
44
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
45
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
46
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
47
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
48
|
+
* 4. `config.defaultRoute`.
|
|
44
49
|
*/
|
|
45
50
|
export function resolveRoute(
|
|
46
51
|
message: GatewayInboundMessage,
|
|
@@ -48,13 +53,22 @@ export function resolveRoute(
|
|
|
48
53
|
managedRoutes?: readonly GatewayRoute[],
|
|
49
54
|
): GatewayRoute {
|
|
50
55
|
const routes = config.routes ?? [];
|
|
56
|
+
|
|
51
57
|
for (const route of routes) {
|
|
52
|
-
if (matchesRoute(message, route.match))
|
|
58
|
+
if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
|
|
59
|
+
return route;
|
|
60
|
+
}
|
|
53
61
|
}
|
|
62
|
+
|
|
54
63
|
if (managedRoutes) {
|
|
55
64
|
for (const route of managedRoutes) {
|
|
56
65
|
if (matchesRoute(message, route.match)) return route;
|
|
57
66
|
}
|
|
58
67
|
}
|
|
68
|
+
|
|
69
|
+
for (const route of routes) {
|
|
70
|
+
if (matchesRoute(message, route.match)) return route;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
return config.defaultRoute;
|
|
60
74
|
}
|
package/src/gateway/types.ts
CHANGED
|
@@ -138,6 +138,15 @@ export type InboundObserver = (
|
|
|
138
138
|
*/
|
|
139
139
|
export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
143
|
+
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
144
|
+
* are caught and logged so observer failures never break the turn.
|
|
145
|
+
*/
|
|
146
|
+
export type OutboundObserver = (
|
|
147
|
+
message: GatewayOutboundMessage,
|
|
148
|
+
) => Promise<void> | void;
|
|
149
|
+
|
|
141
150
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
142
151
|
export interface GatewayOutboundMessage {
|
|
143
152
|
channel: string;
|
package/src/loop-risk.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
|
|
21
|
+
interface Sample {
|
|
22
|
+
text: string;
|
|
23
|
+
normalized: string;
|
|
24
|
+
timestamp: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LoopRiskReason = {
|
|
28
|
+
id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
|
|
29
|
+
summary: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface LoopRiskEvaluation {
|
|
33
|
+
reasons: LoopRiskReason[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Module-level state. Keys are caller-supplied session identifiers
|
|
37
|
+
// (daemon uses `${accountId}:${conversationId}:${threadId ?? ""}`).
|
|
38
|
+
const inboundBySession = new Map<string, Sample[]>();
|
|
39
|
+
const outboundBySession = new Map<string, Sample[]>();
|
|
40
|
+
|
|
41
|
+
// --- Tunables ---------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const TURN_WINDOW_MS = 2 * 60_000;
|
|
44
|
+
const TURN_THRESHOLD = 8;
|
|
45
|
+
const ALTERNATION_THRESHOLD = 6;
|
|
46
|
+
const MIN_TURNS_PER_SIDE = 3;
|
|
47
|
+
|
|
48
|
+
const SAMPLE_MAX_AGE_MS = 10 * 60_000;
|
|
49
|
+
const MAX_TRACKED_PER_SIDE = 6;
|
|
50
|
+
|
|
51
|
+
const SHORT_ACK_MAX_CHARS = 48;
|
|
52
|
+
const MIN_REPEAT_TEXT_CHARS = 6;
|
|
53
|
+
const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
|
|
54
|
+
|
|
55
|
+
// --- Ack / closure phrase lists (mirrors plugin) ----------------------
|
|
56
|
+
|
|
57
|
+
const ENGLISH_ACK_OR_CLOSURE = new Set([
|
|
58
|
+
"ok", "okay", "got it", "thanks", "thank you", "noted", "understood",
|
|
59
|
+
"sounds good", "sgtm", "roger", "copy", "will do", "all good",
|
|
60
|
+
"no worries", "bye", "goodbye", "see you", "talk later",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const CHINESE_ACK_OR_CLOSURE = new Set([
|
|
64
|
+
"收到", "好的", "好", "行", "嗯", "嗯嗯", "明白", "明白了", "知道了",
|
|
65
|
+
"谢谢", "谢谢你", "感谢", "辛苦了", "先这样", "回头聊", "有需要再说",
|
|
66
|
+
"没问题", "了解", "好嘞",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// --- Text normalization -----------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
|
|
73
|
+
* wrappers the user-turn composer adds around the raw inbound text. Leaves
|
|
74
|
+
* the plain body so similarity / ack detection operates on actual content.
|
|
75
|
+
* Kept in sync with `turn-text.ts` output shape.
|
|
76
|
+
*/
|
|
77
|
+
export function stripBotCordPromptScaffolding(text: string): string {
|
|
78
|
+
const filtered = text
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.map((line) => line.trim())
|
|
81
|
+
.filter((line) => {
|
|
82
|
+
if (!line) return false;
|
|
83
|
+
if (line.startsWith("[BotCord Message]")) return false;
|
|
84
|
+
if (line.startsWith("[BotCord Notification]")) return false;
|
|
85
|
+
if (line.startsWith("[Room Rule]")) return false;
|
|
86
|
+
if (line.startsWith("[In group chats, do NOT reply")) return false;
|
|
87
|
+
if (line.startsWith("[If the conversation has naturally concluded")) return false;
|
|
88
|
+
if (line.startsWith("[You received a contact request")) return false;
|
|
89
|
+
if (line.includes('reply with exactly "NO_REPLY"')) return false;
|
|
90
|
+
if (line.startsWith("<agent-message")) return false;
|
|
91
|
+
if (line === "</agent-message>") return false;
|
|
92
|
+
if (line.startsWith("<human-message")) return false;
|
|
93
|
+
if (line === "</human-message>") return false;
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
return filtered.join("\n").trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeLoopText(text: string): string {
|
|
100
|
+
return stripBotCordPromptScaffolding(text)
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/https?:\/\/\S+/gu, " ")
|
|
103
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
104
|
+
.replace(/\s+/gu, " ")
|
|
105
|
+
.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isShortAckOrClosure(text: string): boolean {
|
|
109
|
+
const n = normalizeLoopText(text);
|
|
110
|
+
if (!n || n.length > SHORT_ACK_MAX_CHARS) return false;
|
|
111
|
+
return ENGLISH_ACK_OR_CLOSURE.has(n) || CHINESE_ACK_OR_CLOSURE.has(n);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Similarity -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function trigramSet(text: string): Set<string> {
|
|
117
|
+
if (text.length <= 3) return new Set([text]);
|
|
118
|
+
const grams = new Set<string>();
|
|
119
|
+
for (let i = 0; i <= text.length - 3; i++) grams.add(text.slice(i, i + 3));
|
|
120
|
+
return grams;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function jaccardSimilarity(a: string, b: string): number {
|
|
124
|
+
if (!a || !b) return 0;
|
|
125
|
+
if (a === b) return 1;
|
|
126
|
+
const A = trigramSet(a);
|
|
127
|
+
const B = trigramSet(b);
|
|
128
|
+
let inter = 0;
|
|
129
|
+
for (const g of A) if (B.has(g)) inter++;
|
|
130
|
+
const union = A.size + B.size - inter;
|
|
131
|
+
return union === 0 ? 0 : inter / union;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function areOutboundTextsHighlySimilar(a: string, b: string): boolean {
|
|
135
|
+
if (!a || !b) return false;
|
|
136
|
+
if (a === b) return true;
|
|
137
|
+
if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS) return false;
|
|
138
|
+
return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Sample store -----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function prune(map: Map<string, Sample[]>, sessionKey: string, now: number): Sample[] {
|
|
144
|
+
const existing = map.get(sessionKey) ?? [];
|
|
145
|
+
const next = existing.filter((s) => now - s.timestamp <= SAMPLE_MAX_AGE_MS);
|
|
146
|
+
if (next.length === 0) {
|
|
147
|
+
map.delete(sessionKey);
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
map.set(sessionKey, next);
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function record(
|
|
155
|
+
map: Map<string, Sample[]>,
|
|
156
|
+
sessionKey: string,
|
|
157
|
+
sample: Sample,
|
|
158
|
+
): void {
|
|
159
|
+
const kept = prune(map, sessionKey, sample.timestamp);
|
|
160
|
+
const next = [...kept, sample].slice(-MAX_TRACKED_PER_SIDE);
|
|
161
|
+
map.set(sessionKey, next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function recordInboundText(params: {
|
|
165
|
+
sessionKey?: string;
|
|
166
|
+
text?: unknown;
|
|
167
|
+
timestamp?: number;
|
|
168
|
+
}): void {
|
|
169
|
+
const sessionKey = params.sessionKey?.trim();
|
|
170
|
+
const raw = typeof params.text === "string" ? params.text.trim() : "";
|
|
171
|
+
if (!sessionKey || !raw) return;
|
|
172
|
+
const normalized = normalizeLoopText(raw);
|
|
173
|
+
if (!normalized) return;
|
|
174
|
+
record(inboundBySession, sessionKey, {
|
|
175
|
+
text: raw,
|
|
176
|
+
normalized,
|
|
177
|
+
timestamp: params.timestamp ?? Date.now(),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function recordOutboundText(params: {
|
|
182
|
+
sessionKey?: string;
|
|
183
|
+
text?: unknown;
|
|
184
|
+
timestamp?: number;
|
|
185
|
+
}): void {
|
|
186
|
+
const sessionKey = params.sessionKey?.trim();
|
|
187
|
+
const raw = typeof params.text === "string" ? params.text.trim() : "";
|
|
188
|
+
if (!sessionKey || !raw) return;
|
|
189
|
+
const normalized = normalizeLoopText(raw);
|
|
190
|
+
if (!normalized) return;
|
|
191
|
+
record(outboundBySession, sessionKey, {
|
|
192
|
+
text: raw,
|
|
193
|
+
normalized,
|
|
194
|
+
timestamp: params.timestamp ?? Date.now(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function clearLoopRiskSession(sessionKey?: string): void {
|
|
199
|
+
if (!sessionKey) return;
|
|
200
|
+
inboundBySession.delete(sessionKey);
|
|
201
|
+
outboundBySession.delete(sessionKey);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resetLoopRiskStateForTests(): void {
|
|
205
|
+
inboundBySession.clear();
|
|
206
|
+
outboundBySession.clear();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Detectors --------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function detectHighTurnRate(inbound: Sample[], outbound: Sample[], now: number):
|
|
212
|
+
LoopRiskReason | undefined {
|
|
213
|
+
const timeline: Array<{ role: "user" | "assistant"; timestamp: number }> = [];
|
|
214
|
+
for (const s of inbound) {
|
|
215
|
+
if (now - s.timestamp <= TURN_WINDOW_MS) timeline.push({ role: "user", timestamp: s.timestamp });
|
|
216
|
+
}
|
|
217
|
+
for (const s of outbound) {
|
|
218
|
+
if (now - s.timestamp <= TURN_WINDOW_MS) timeline.push({ role: "assistant", timestamp: s.timestamp });
|
|
219
|
+
}
|
|
220
|
+
if (timeline.length < TURN_THRESHOLD) return undefined;
|
|
221
|
+
timeline.sort((a, b) => a.timestamp - b.timestamp);
|
|
222
|
+
|
|
223
|
+
let userTurns = 0;
|
|
224
|
+
let assistantTurns = 0;
|
|
225
|
+
let alternations = 0;
|
|
226
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
227
|
+
if (timeline[i]!.role === "user") userTurns++;
|
|
228
|
+
else assistantTurns++;
|
|
229
|
+
if (i > 0 && timeline[i]!.role !== timeline[i - 1]!.role) alternations++;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
userTurns >= MIN_TURNS_PER_SIDE &&
|
|
234
|
+
assistantTurns >= MIN_TURNS_PER_SIDE &&
|
|
235
|
+
alternations >= ALTERNATION_THRESHOLD
|
|
236
|
+
) {
|
|
237
|
+
return {
|
|
238
|
+
id: "high_turn_rate",
|
|
239
|
+
summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function detectShortAckTail(inbound: Sample[]): LoopRiskReason | undefined {
|
|
246
|
+
const tail = inbound.slice(-2);
|
|
247
|
+
if (tail.length < 2) return undefined;
|
|
248
|
+
if (tail.every((s) => isShortAckOrClosure(s.text))) {
|
|
249
|
+
return {
|
|
250
|
+
id: "short_ack_tail",
|
|
251
|
+
summary: "the last two inbound user messages are short acknowledgements or closure phrases",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function detectRepeatedOutbound(outbound: Sample[]): LoopRiskReason | undefined {
|
|
258
|
+
const recent = outbound.slice(-3);
|
|
259
|
+
if (recent.length < 2) return undefined;
|
|
260
|
+
const last = recent[recent.length - 1];
|
|
261
|
+
if (!last) return undefined;
|
|
262
|
+
const previous = recent.slice(0, -1);
|
|
263
|
+
const exact = previous.filter((s) => s.normalized === last.normalized).length;
|
|
264
|
+
const similar = previous.filter((s) =>
|
|
265
|
+
areOutboundTextsHighlySimilar(s.normalized, last.normalized),
|
|
266
|
+
).length;
|
|
267
|
+
if (exact >= 1 || (recent.length >= 3 && similar >= 2)) {
|
|
268
|
+
return {
|
|
269
|
+
id: "repeated_outbound",
|
|
270
|
+
summary: "recent outbound texts in this session are highly similar",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function evaluateLoopRisk(params: {
|
|
277
|
+
sessionKey?: string;
|
|
278
|
+
now?: number;
|
|
279
|
+
}): LoopRiskEvaluation {
|
|
280
|
+
const now = params.now ?? Date.now();
|
|
281
|
+
if (!params.sessionKey) return { reasons: [] };
|
|
282
|
+
const inbound = prune(inboundBySession, params.sessionKey, now);
|
|
283
|
+
const outbound = prune(outboundBySession, params.sessionKey, now);
|
|
284
|
+
const reasons = [
|
|
285
|
+
detectHighTurnRate(inbound, outbound, now),
|
|
286
|
+
detectShortAckTail(inbound),
|
|
287
|
+
detectRepeatedOutbound(outbound),
|
|
288
|
+
].filter((r): r is LoopRiskReason => Boolean(r));
|
|
289
|
+
return { reasons };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Build the injected system-context hint, or `null` if no risk detected. */
|
|
293
|
+
export function buildLoopRiskPrompt(params: {
|
|
294
|
+
sessionKey?: string;
|
|
295
|
+
now?: number;
|
|
296
|
+
}): string | null {
|
|
297
|
+
const evaluation = evaluateLoopRisk(params);
|
|
298
|
+
if (evaluation.reasons.length === 0) return null;
|
|
299
|
+
return [
|
|
300
|
+
"[BotCord loop-risk check]",
|
|
301
|
+
"Observed signals:",
|
|
302
|
+
...evaluation.reasons.map((r) => `- ${r.summary}`),
|
|
303
|
+
"",
|
|
304
|
+
"Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
|
|
305
|
+
'If it does not, reply with exactly "NO_REPLY" and nothing else.',
|
|
306
|
+
"Do not send courtesy-only acknowledgements or mirrored sign-offs.",
|
|
307
|
+
].join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Derive a loop-risk session key from a gateway inbound message or
|
|
312
|
+
* outbound reply. Keyed on (accountId, conversationId, threadId) so a
|
|
313
|
+
* DM and a group thread under the same agent don't share state.
|
|
314
|
+
*/
|
|
315
|
+
export function loopRiskSessionKey(params: {
|
|
316
|
+
accountId: string;
|
|
317
|
+
conversationId: string;
|
|
318
|
+
threadId?: string | null;
|
|
319
|
+
}): string {
|
|
320
|
+
const thread = params.threadId ?? "";
|
|
321
|
+
return `${params.accountId}:${params.conversationId}:${thread}`;
|
|
322
|
+
}
|
package/src/system-context.ts
CHANGED
|
@@ -69,6 +69,12 @@ export interface SystemContextDeps {
|
|
|
69
69
|
* is skipped.
|
|
70
70
|
*/
|
|
71
71
|
roomContextBuilder?: RoomStaticContextBuilder;
|
|
72
|
+
/**
|
|
73
|
+
* Optional per-turn loop-risk check. Returns a warning block when the
|
|
74
|
+
* session shows signs of agent-to-agent echo or courtesy loops. Sync
|
|
75
|
+
* + cheap — consulted every turn even when roomContextBuilder is absent.
|
|
76
|
+
*/
|
|
77
|
+
loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
function safeReadWorkingMemory(agentId: string) {
|
|
@@ -122,10 +128,27 @@ export function createDaemonSystemContextBuilder(
|
|
|
122
128
|
return filtered.length > 0 ? filtered.join("\n\n") : undefined;
|
|
123
129
|
};
|
|
124
130
|
|
|
131
|
+
const runLoopRisk = (message: GatewayInboundMessage): string | null => {
|
|
132
|
+
if (!deps.loopRiskBuilder) return null;
|
|
133
|
+
try {
|
|
134
|
+
return deps.loopRiskBuilder(message);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.warn("system-context: loopRiskBuilder threw — skipping loop-risk block", {
|
|
137
|
+
agentId: deps.agentId,
|
|
138
|
+
roomId: message.conversation.id,
|
|
139
|
+
err: err instanceof Error ? err.message : String(err),
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
125
145
|
if (!deps.roomContextBuilder) {
|
|
126
146
|
const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
|
|
127
147
|
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
128
|
-
|
|
148
|
+
// Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
|
|
149
|
+
// is the last thing the model sees before the user turn body.
|
|
150
|
+
const loopRisk = runLoopRisk(message);
|
|
151
|
+
return assemble([ownerScene, memory, digest, loopRisk]);
|
|
129
152
|
};
|
|
130
153
|
// Compile-time witness that the narrower sync signature still satisfies
|
|
131
154
|
// `SystemContextBuilder` (which allows async). Prevents the two contracts
|
|
@@ -154,7 +177,8 @@ export function createDaemonSystemContextBuilder(
|
|
|
154
177
|
err: err instanceof Error ? err.message : String(err),
|
|
155
178
|
});
|
|
156
179
|
}
|
|
157
|
-
|
|
180
|
+
const loopRisk = runLoopRisk(message);
|
|
181
|
+
return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
|
|
158
182
|
};
|
|
159
183
|
const _typecheck: SystemContextBuilder = asyncBuilder;
|
|
160
184
|
void _typecheck;
|