@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.
- package/dist/daemon.js +35 -1
- package/dist/gateway/channels/botcord.js +7 -1
- 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/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 +32 -2
- 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 +32 -0
- package/src/daemon.ts +42 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +26 -0
- package/src/gateway/__tests__/dispatcher.test.ts +40 -0
- package/src/gateway/channels/botcord.ts +7 -1
- package/src/gateway/dispatcher.ts +20 -0
- package/src/gateway/gateway.ts +7 -0
- 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 +32 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
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/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;
|