@botcord/daemon 0.1.1 → 0.2.1

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,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
+ });
@@ -288,6 +288,41 @@ describe("createDaemonSystemContextBuilder", () => {
288
288
  expect(out).toBeUndefined();
289
289
  });
290
290
 
291
+ it("appends loopRiskBuilder output at the end of the system context", async () => {
292
+ const builder = createDaemonSystemContextBuilder({
293
+ agentId: "ag_me",
294
+ roomContextBuilder: async () => null,
295
+ loopRiskBuilder: () => "[BotCord loop-risk check]\nObserved signals:\n- x",
296
+ });
297
+ const out = await builder(
298
+ makeMessage({ conversation: { id: "rm_team", kind: "group" } }),
299
+ );
300
+ expect(typeof out).toBe("string");
301
+ expect(out).toContain("[BotCord loop-risk check]");
302
+ });
303
+
304
+ it("also injects loopRiskBuilder in the sync (no roomContextBuilder) branch", () => {
305
+ const builder = createDaemonSystemContextBuilder({
306
+ agentId: "ag_me",
307
+ loopRiskBuilder: () => "[BotCord loop-risk check]\nObserved signals:\n- y",
308
+ });
309
+ const out = builder(makeMessage()) as string | undefined;
310
+ expect(typeof out).toBe("string");
311
+ expect(out).toContain("[BotCord loop-risk check]");
312
+ });
313
+
314
+ it("skips loopRiskBuilder gracefully when it throws", async () => {
315
+ const builder = createDaemonSystemContextBuilder({
316
+ agentId: "ag_me",
317
+ roomContextBuilder: async () => null,
318
+ loopRiskBuilder: () => {
319
+ throw new Error("oops");
320
+ },
321
+ });
322
+ const out = await builder(makeMessage());
323
+ expect(out).toBeUndefined();
324
+ });
325
+
291
326
  it("translates GatewayInboundMessage.conversation.id → old `room_id` for the digest exclude key", () => {
292
327
  const tracker = new ActivityTracker({
293
328
  filePath: path.join(tmpDir, "activity.json"),
@@ -99,6 +99,38 @@ describe("composeBotCordUserTurn", () => {
99
99
  expect(out).toBe("");
100
100
  });
101
101
 
102
+ it("appends the contact-request notify-owner hint when envelope.type is contact_request", () => {
103
+ const out = composeBotCordUserTurn(
104
+ makeMessage({
105
+ text: "Hi, please add me",
106
+ sender: { id: "ag_stranger", kind: "agent" },
107
+ conversation: { id: "rm_dm_x", kind: "direct" },
108
+ raw: { envelope: { type: "contact_request" } },
109
+ }),
110
+ );
111
+ expect(out).toContain("contact request from ag_stranger");
112
+ expect(out).toContain("botcord_notify tool");
113
+ // Base direct-chat hint should still appear above the contact-request hint.
114
+ expect(out).toContain("naturally concluded");
115
+ const baseIdx = out.indexOf("naturally concluded");
116
+ const crIdx = out.indexOf("contact request from");
117
+ expect(crIdx).toBeGreaterThan(baseIdx);
118
+ });
119
+
120
+ it("does NOT append the contact-request hint for a plain message envelope", () => {
121
+ const out = composeBotCordUserTurn(
122
+ makeMessage({
123
+ raw: { envelope: { type: "message" } },
124
+ }),
125
+ );
126
+ expect(out).not.toContain("contact request from");
127
+ });
128
+
129
+ it("does NOT append the contact-request hint when msg.raw has no envelope", () => {
130
+ const out = composeBotCordUserTurn(makeMessage({ raw: {} }));
131
+ expect(out).not.toContain("contact request from");
132
+ });
133
+
102
134
  it("sanitizes room names so newline-based injection can't reshape the header", () => {
103
135
  const out = composeBotCordUserTurn(
104
136
  makeMessage({
package/src/daemon.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ChannelAdapter,
7
7
  type GatewayChannelConfig,
8
8
  type GatewayInboundMessage,
9
+ type GatewayOutboundMessage,
9
10
  type GatewayLogger,
10
11
  type GatewayRuntimeSnapshot,
11
12
  } from "./gateway/index.js";
@@ -22,6 +23,12 @@ import { SnapshotWriter } from "./snapshot-writer.js";
22
23
  import { createDaemonSystemContextBuilder } from "./system-context.js";
23
24
  import { createRoomStaticContextBuilder } from "./room-context.js";
24
25
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
26
+ import {
27
+ buildLoopRiskPrompt,
28
+ loopRiskSessionKey,
29
+ recordInboundText as recordLoopRiskInbound,
30
+ recordOutboundText as recordLoopRiskOutbound,
31
+ } from "./loop-risk.js";
25
32
  import { composeBotCordUserTurn } from "./turn-text.js";
26
33
  import { UserAuthManager } from "./user-auth.js";
27
34
 
@@ -245,6 +252,14 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
245
252
  msg: GatewayInboundMessage,
246
253
  ) => Promise<string | undefined> | string | undefined;
247
254
  const scBuilders = new Map<string, PerAgentBuilder>();
255
+ const loopRiskBuilder = (msg: GatewayInboundMessage): string | null =>
256
+ buildLoopRiskPrompt({
257
+ sessionKey: loopRiskSessionKey({
258
+ accountId: msg.accountId,
259
+ conversationId: msg.conversation.id,
260
+ threadId: msg.conversation.threadId ?? null,
261
+ }),
262
+ });
248
263
  for (const aid of agentIds) {
249
264
  scBuilders.set(
250
265
  aid,
@@ -252,6 +267,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
252
267
  agentId: aid,
253
268
  activityTracker,
254
269
  roomContextBuilder,
270
+ loopRiskBuilder,
255
271
  }),
256
272
  );
257
273
  }
@@ -274,10 +290,34 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
274
290
  // outside the system-context builder (option A) means the builder stays
275
291
  // pure — a cleaner contract the gateway can also expose to non-daemon
276
292
  // callers in the future.
277
- const onInbound = createActivityRecorder({
293
+ const recordActivity = createActivityRecorder({
278
294
  activityTracker,
279
295
  ...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
280
296
  });
297
+ const onInbound = (msg: GatewayInboundMessage): void => {
298
+ recordActivity(msg);
299
+ // Feed the loop-risk tracker with the sanitized inbound text so
300
+ // detectShortAckTail + detectHighTurnRate have a timeline.
301
+ recordLoopRiskInbound({
302
+ sessionKey: loopRiskSessionKey({
303
+ accountId: msg.accountId,
304
+ conversationId: msg.conversation.id,
305
+ threadId: msg.conversation.threadId ?? null,
306
+ }),
307
+ text: msg.text,
308
+ timestamp: msg.receivedAt,
309
+ });
310
+ };
311
+ const onOutbound = (out: GatewayOutboundMessage): void => {
312
+ recordLoopRiskOutbound({
313
+ sessionKey: loopRiskSessionKey({
314
+ accountId: out.accountId,
315
+ conversationId: out.conversationId,
316
+ threadId: out.threadId ?? null,
317
+ }),
318
+ text: out.text,
319
+ });
320
+ };
281
321
 
282
322
  const gateway = new Gateway({
283
323
  config: gwConfig,
@@ -298,6 +338,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
298
338
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
299
339
  buildSystemContext,
300
340
  onInbound,
341
+ onOutbound,
301
342
  composeUserTurn: composeBotCordUserTurn,
302
343
  });
303
344
 
@@ -255,6 +255,32 @@ describe("createBotCordChannel — inbox normalization", () => {
255
255
  }
256
256
  });
257
257
 
258
+ it("lets contact_request envelopes through so the composer can add the notify-owner hint", async () => {
259
+ const { emits, server } = await startWithInbox([
260
+ makeInbox({
261
+ hub_msg_id: "m_cr",
262
+ room_id: "rm_dm_peer",
263
+ text: "Hi, please add me",
264
+ envelope: {
265
+ type: "contact_request",
266
+ from: "ag_stranger",
267
+ payload: { text: "Hi, please add me" },
268
+ } as unknown as InboxMessage["envelope"],
269
+ }),
270
+ ]);
271
+ try {
272
+ expect(emits).toHaveLength(1);
273
+ const env = emits[0].message;
274
+ expect(env.sender.id).toBe("ag_stranger");
275
+ expect(env.text).toBe("Hi, please add me");
276
+ // Raw preserves envelope so turn-text can detect the type.
277
+ const raw = env.raw as { envelope?: { type?: string } };
278
+ expect(raw?.envelope?.type).toBe("contact_request");
279
+ } finally {
280
+ await server.close();
281
+ }
282
+ });
283
+
258
284
  it("sanitizes prompt-injection markers in untrusted text but not in owner-chat", async () => {
259
285
  const { emits, server } = await startWithInbox([
260
286
  makeInbox({
@@ -350,6 +350,46 @@ describe("Dispatcher", () => {
350
350
  expect(runtime.calls[0].text).toBe("hello");
351
351
  });
352
352
 
353
+ it("fires onOutbound after a reply is dispatched", async () => {
354
+ const runtime = new FakeRuntime({ reply: "hello back", newSessionId: "sid-1" });
355
+ const { store, dir } = await makeStore();
356
+ tempDirs.push(dir);
357
+ const channel = new FakeChannel();
358
+ const outbound: string[] = [];
359
+ const dispatcher = new Dispatcher({
360
+ config: baseConfig(),
361
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
362
+ runtime: () => runtime,
363
+ sessionStore: store,
364
+ log: silentLogger(),
365
+ onOutbound: (msg) => {
366
+ outbound.push(msg.text);
367
+ },
368
+ });
369
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hi" }));
370
+ expect(outbound).toEqual(["hello back"]);
371
+ });
372
+
373
+ it("does not crash when onOutbound throws", async () => {
374
+ const runtime = new FakeRuntime({ reply: "hello back", newSessionId: "sid-1" });
375
+ const { store, dir } = await makeStore();
376
+ tempDirs.push(dir);
377
+ const channel = new FakeChannel();
378
+ const dispatcher = new Dispatcher({
379
+ config: baseConfig(),
380
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
381
+ runtime: () => runtime,
382
+ sessionStore: store,
383
+ log: silentLogger(),
384
+ onOutbound: () => {
385
+ throw new Error("boom");
386
+ },
387
+ });
388
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hi" }));
389
+ // Reply still went out; no assertion needed beyond the absence of a throw.
390
+ expect(channel.sends.length).toBe(1);
391
+ });
392
+
353
393
  it("does not crash when an errored turn has no prior session entry", async () => {
354
394
  const runtime = new FakeRuntime({ newSessionId: "", errorText: "boom" });
355
395
  const { dispatcher, store } = await scaffold({ runtimeFactory: () => runtime });
@@ -147,7 +147,13 @@ function normalizeInbox(
147
147
  ): GatewayInboundMessage | null {
148
148
  const env = msg.envelope;
149
149
  if (!env) return null;
150
- if (env.type !== "message") return null;
150
+ // `message` is the normal conversational envelope; `contact_request` is
151
+ // a lightweight inbound asking the agent to notify its owner (the
152
+ // composer appends the notify-owner hint). All other envelope types
153
+ // (notification, system, contact_added/removed, …) are still filtered
154
+ // out here — they belong in a separate push-notification path that
155
+ // daemon does not yet implement.
156
+ if (env.type !== "message" && env.type !== "contact_request") return null;
151
157
  if (!msg.room_id) return null;
152
158
 
153
159
  const rawText =
@@ -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
 
@@ -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;