@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.
@@ -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
  }
@@ -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
 
@@ -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[]` (user-authored)
42
- * 2. `managedRoutes` (daemon-synthesized per-agent)
43
- * 3. `config.defaultRoute`
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)) return route;
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
  }
@@ -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;
@@ -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
+ }
@@ -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
- return assemble([ownerScene, memory, digest]);
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
- return assemble([ownerScene, memory, roomBlock, digest]);
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;