@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
|
@@ -113,9 +113,10 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
113
113
|
const routes = (cfg.routes ?? []).map(mapRoute);
|
|
114
114
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
115
115
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
116
|
-
// `cfg.routes[]` stay untouched
|
|
117
|
-
//
|
|
118
|
-
// defaultRoute
|
|
116
|
+
// `cfg.routes[]` stay untouched. Match priority (see router.ts):
|
|
117
|
+
// `routes[] with explicit accountId → managedRoutes → other routes[] →
|
|
118
|
+
// defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
|
|
119
|
+
// chosen runtime — only routes that name the agent by `accountId` do.
|
|
119
120
|
const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
|
|
120
121
|
return {
|
|
121
122
|
channels,
|
package/dist/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { SnapshotWriter } from "./snapshot-writer.js";
|
|
|
12
12
|
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
13
13
|
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
14
14
|
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
15
|
+
import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
|
|
15
16
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
16
17
|
import { UserAuthManager } from "./user-auth.js";
|
|
17
18
|
/**
|
|
@@ -144,11 +145,19 @@ export async function startDaemon(opts) {
|
|
|
144
145
|
log: logger,
|
|
145
146
|
});
|
|
146
147
|
const scBuilders = new Map();
|
|
148
|
+
const loopRiskBuilder = (msg) => buildLoopRiskPrompt({
|
|
149
|
+
sessionKey: loopRiskSessionKey({
|
|
150
|
+
accountId: msg.accountId,
|
|
151
|
+
conversationId: msg.conversation.id,
|
|
152
|
+
threadId: msg.conversation.threadId ?? null,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
147
155
|
for (const aid of agentIds) {
|
|
148
156
|
scBuilders.set(aid, createDaemonSystemContextBuilder({
|
|
149
157
|
agentId: aid,
|
|
150
158
|
activityTracker,
|
|
151
159
|
roomContextBuilder,
|
|
160
|
+
loopRiskBuilder,
|
|
152
161
|
}));
|
|
153
162
|
}
|
|
154
163
|
const buildSystemContext = (message) => {
|
|
@@ -169,10 +178,34 @@ export async function startDaemon(opts) {
|
|
|
169
178
|
// outside the system-context builder (option A) means the builder stays
|
|
170
179
|
// pure — a cleaner contract the gateway can also expose to non-daemon
|
|
171
180
|
// callers in the future.
|
|
172
|
-
const
|
|
181
|
+
const recordActivity = createActivityRecorder({
|
|
173
182
|
activityTracker,
|
|
174
183
|
...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
|
|
175
184
|
});
|
|
185
|
+
const onInbound = (msg) => {
|
|
186
|
+
recordActivity(msg);
|
|
187
|
+
// Feed the loop-risk tracker with the sanitized inbound text so
|
|
188
|
+
// detectShortAckTail + detectHighTurnRate have a timeline.
|
|
189
|
+
recordLoopRiskInbound({
|
|
190
|
+
sessionKey: loopRiskSessionKey({
|
|
191
|
+
accountId: msg.accountId,
|
|
192
|
+
conversationId: msg.conversation.id,
|
|
193
|
+
threadId: msg.conversation.threadId ?? null,
|
|
194
|
+
}),
|
|
195
|
+
text: msg.text,
|
|
196
|
+
timestamp: msg.receivedAt,
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
const onOutbound = (out) => {
|
|
200
|
+
recordLoopRiskOutbound({
|
|
201
|
+
sessionKey: loopRiskSessionKey({
|
|
202
|
+
accountId: out.accountId,
|
|
203
|
+
conversationId: out.conversationId,
|
|
204
|
+
threadId: out.threadId ?? null,
|
|
205
|
+
}),
|
|
206
|
+
text: out.text,
|
|
207
|
+
});
|
|
208
|
+
};
|
|
176
209
|
const gateway = new Gateway({
|
|
177
210
|
config: gwConfig,
|
|
178
211
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -190,6 +223,7 @@ export async function startDaemon(opts) {
|
|
|
190
223
|
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
191
224
|
buildSystemContext,
|
|
192
225
|
onInbound,
|
|
226
|
+
onOutbound,
|
|
193
227
|
composeUserTurn: composeBotCordUserTurn,
|
|
194
228
|
});
|
|
195
229
|
logger.info("daemon starting", {
|
|
@@ -82,6 +82,17 @@ declare function normalizeInbox(msg: InboxMessage, options: {
|
|
|
82
82
|
channelId: string;
|
|
83
83
|
accountId: string;
|
|
84
84
|
}): GatewayInboundMessage | null;
|
|
85
|
+
/**
|
|
86
|
+
* Shape of the `raw` field when the channel batches multiple messages into
|
|
87
|
+
* one envelope. Keeps the latest message's InboxMessage fields at top level
|
|
88
|
+
* so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
|
|
89
|
+
* work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
|
|
90
|
+
* reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
|
|
91
|
+
* per entry.
|
|
92
|
+
*/
|
|
93
|
+
export interface BatchedInboxRaw extends InboxMessage {
|
|
94
|
+
batch: InboxMessage[];
|
|
95
|
+
}
|
|
85
96
|
/**
|
|
86
97
|
* Construct a BotCord channel adapter.
|
|
87
98
|
*
|
|
@@ -69,7 +69,13 @@ function normalizeInbox(msg, options) {
|
|
|
69
69
|
const env = msg.envelope;
|
|
70
70
|
if (!env)
|
|
71
71
|
return null;
|
|
72
|
-
|
|
72
|
+
// `message` is the normal conversational envelope; `contact_request` is
|
|
73
|
+
// a lightweight inbound asking the agent to notify its owner (the
|
|
74
|
+
// composer appends the notify-owner hint). All other envelope types
|
|
75
|
+
// (notification, system, contact_added/removed, …) are still filtered
|
|
76
|
+
// out here — they belong in a separate push-notification path that
|
|
77
|
+
// daemon does not yet implement.
|
|
78
|
+
if (env.type !== "message" && env.type !== "contact_request")
|
|
73
79
|
return null;
|
|
74
80
|
if (!msg.room_id)
|
|
75
81
|
return null;
|
|
@@ -107,6 +113,50 @@ function normalizeInbox(msg, options) {
|
|
|
107
113
|
trace: { id: msg.hub_msg_id, streamable },
|
|
108
114
|
};
|
|
109
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Normalize a group of InboxMessages for the same `(room, topic)` into a
|
|
118
|
+
* single `GatewayInboundMessage`. The envelope carries the latest msg's
|
|
119
|
+
* metadata (routing, session key, trace) and a `raw.batch` array the
|
|
120
|
+
* composer uses to render per-sender blocks.
|
|
121
|
+
*
|
|
122
|
+
* `mentioned` is sticky: true if ANY message in the group is a mention.
|
|
123
|
+
* Returns null if no message in the group is normalizable on its own.
|
|
124
|
+
*/
|
|
125
|
+
function normalizeInboxBatch(msgs, options) {
|
|
126
|
+
if (msgs.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
if (msgs.length === 1)
|
|
129
|
+
return normalizeInbox(msgs[0], options);
|
|
130
|
+
const latest = msgs[msgs.length - 1];
|
|
131
|
+
const base = normalizeInbox(latest, options);
|
|
132
|
+
if (!base)
|
|
133
|
+
return null;
|
|
134
|
+
// Fold sibling metadata into the base envelope. `text` is kept non-empty
|
|
135
|
+
// when at least one batched member has a body, so the dispatcher's empty-
|
|
136
|
+
// text skip rule doesn't drop the whole batch just because the latest
|
|
137
|
+
// envelope was e.g. a zero-payload contact_request.
|
|
138
|
+
const anyMentioned = msgs.some((m) => m.mentioned === true);
|
|
139
|
+
let representativeText = base.text ?? "";
|
|
140
|
+
if (!representativeText.trim()) {
|
|
141
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
142
|
+
const m = msgs[i];
|
|
143
|
+
const candidate = m.text ??
|
|
144
|
+
(typeof m.envelope?.payload?.text === "string"
|
|
145
|
+
? m.envelope.payload.text
|
|
146
|
+
: "");
|
|
147
|
+
if (candidate && candidate.trim()) {
|
|
148
|
+
representativeText = candidate;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
text: representativeText,
|
|
156
|
+
mentioned: anyMentioned,
|
|
157
|
+
raw: { ...latest, batch: msgs },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
110
160
|
/**
|
|
111
161
|
* Construct a BotCord channel adapter.
|
|
112
162
|
*
|
|
@@ -155,9 +205,14 @@ export function createBotCordChannel(options) {
|
|
|
155
205
|
log.info("botcord inbox drained", { count: msgs.length });
|
|
156
206
|
if (msgs.length === 0)
|
|
157
207
|
return;
|
|
208
|
+
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
209
|
+
// and collect eligible messages preserving poll order. Grouping by
|
|
210
|
+
// `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
|
|
211
|
+
// same conversation thread folds into one turn so the agent sees all
|
|
212
|
+
// new messages at once instead of running N turns back-to-back.
|
|
213
|
+
const eligible = [];
|
|
158
214
|
for (const msg of msgs) {
|
|
159
215
|
if (!rememberSeen(msg.hub_msg_id)) {
|
|
160
|
-
// Already emitted; ack again so Hub stops requeueing.
|
|
161
216
|
try {
|
|
162
217
|
await client.ackMessages([msg.hub_msg_id]);
|
|
163
218
|
}
|
|
@@ -171,7 +226,6 @@ export function createBotCordChannel(options) {
|
|
|
171
226
|
accountId: options.accountId,
|
|
172
227
|
});
|
|
173
228
|
if (!normalized) {
|
|
174
|
-
// Not eligible (wrong type, missing room, etc.) — ack so it drops.
|
|
175
229
|
try {
|
|
176
230
|
await client.ackMessages([msg.hub_msg_id]);
|
|
177
231
|
}
|
|
@@ -180,16 +234,42 @@ export function createBotCordChannel(options) {
|
|
|
180
234
|
}
|
|
181
235
|
continue;
|
|
182
236
|
}
|
|
237
|
+
eligible.push(msg);
|
|
238
|
+
}
|
|
239
|
+
if (eligible.length === 0)
|
|
240
|
+
return;
|
|
241
|
+
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
242
|
+
// iterating the map yields groups with the same external chronology.
|
|
243
|
+
const groups = new Map();
|
|
244
|
+
for (const msg of eligible) {
|
|
245
|
+
const topic = msg.topic_id ?? msg.topic ?? "";
|
|
246
|
+
const key = `${msg.room_id ?? ""}:${topic}`;
|
|
247
|
+
const list = groups.get(key);
|
|
248
|
+
if (list)
|
|
249
|
+
list.push(msg);
|
|
250
|
+
else
|
|
251
|
+
groups.set(key, [msg]);
|
|
252
|
+
}
|
|
253
|
+
for (const group of groups.values()) {
|
|
254
|
+
const normalized = normalizeInboxBatch(group, {
|
|
255
|
+
channelId: options.id,
|
|
256
|
+
accountId: options.accountId,
|
|
257
|
+
});
|
|
258
|
+
if (!normalized)
|
|
259
|
+
continue;
|
|
260
|
+
const hubIds = group.map((m) => m.hub_msg_id);
|
|
183
261
|
const envelope = {
|
|
184
262
|
message: normalized,
|
|
185
263
|
ack: {
|
|
186
264
|
accept: async () => {
|
|
187
265
|
try {
|
|
188
|
-
|
|
266
|
+
// Ack the entire batch together so Hub never re-delivers any
|
|
267
|
+
// member of this turn if the agent succeeds on the group.
|
|
268
|
+
await client.ackMessages(hubIds);
|
|
189
269
|
}
|
|
190
270
|
catch (err) {
|
|
191
271
|
log.warn("botcord ack failed — relying on seen-cache dedup", {
|
|
192
|
-
|
|
272
|
+
hubMsgIds: hubIds,
|
|
193
273
|
err: String(err),
|
|
194
274
|
});
|
|
195
275
|
}
|
|
@@ -201,7 +281,7 @@ export function createBotCordChannel(options) {
|
|
|
201
281
|
}
|
|
202
282
|
catch (err) {
|
|
203
283
|
log.error("botcord emit threw", {
|
|
204
|
-
|
|
284
|
+
hubMsgIds: hubIds,
|
|
205
285
|
err: String(err),
|
|
206
286
|
});
|
|
207
287
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
|
-
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
3
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
4
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
5
5
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
6
6
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -36,6 +36,12 @@ export interface DispatcherOptions {
|
|
|
36
36
|
* a fallback so a buggy composer cannot drop turns.
|
|
37
37
|
*/
|
|
38
38
|
composeUserTurn?: UserTurnBuilder;
|
|
39
|
+
/**
|
|
40
|
+
* Optional observer fired after each reply is dispatched. Intended for
|
|
41
|
+
* outbound bookkeeping such as loop-risk tracking. Errors are logged
|
|
42
|
+
* and suppressed so observer failures never break the turn.
|
|
43
|
+
*/
|
|
44
|
+
onOutbound?: OutboundObserver;
|
|
39
45
|
}
|
|
40
46
|
/**
|
|
41
47
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
@@ -56,6 +62,7 @@ export declare class Dispatcher {
|
|
|
56
62
|
private readonly turnTimeoutMs;
|
|
57
63
|
private readonly buildSystemContext?;
|
|
58
64
|
private readonly onInbound?;
|
|
65
|
+
private readonly onOutbound?;
|
|
59
66
|
private readonly composeUserTurn?;
|
|
60
67
|
private readonly managedRoutes?;
|
|
61
68
|
private readonly queues;
|
|
@@ -20,6 +20,7 @@ export class Dispatcher {
|
|
|
20
20
|
turnTimeoutMs;
|
|
21
21
|
buildSystemContext;
|
|
22
22
|
onInbound;
|
|
23
|
+
onOutbound;
|
|
23
24
|
composeUserTurn;
|
|
24
25
|
managedRoutes;
|
|
25
26
|
queues = new Map();
|
|
@@ -32,6 +33,7 @@ export class Dispatcher {
|
|
|
32
33
|
this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
33
34
|
this.buildSystemContext = opts.buildSystemContext;
|
|
34
35
|
this.onInbound = opts.onInbound;
|
|
36
|
+
this.onOutbound = opts.onOutbound;
|
|
35
37
|
this.composeUserTurn = opts.composeUserTurn;
|
|
36
38
|
this.managedRoutes = opts.managedRoutes;
|
|
37
39
|
}
|
|
@@ -414,6 +416,18 @@ export class Dispatcher {
|
|
|
414
416
|
conversationId: outbound.conversationId,
|
|
415
417
|
error: err instanceof Error ? err.message : String(err),
|
|
416
418
|
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (this.onOutbound) {
|
|
422
|
+
try {
|
|
423
|
+
await this.onOutbound(outbound);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
this.log.warn("dispatcher: onOutbound threw — continuing", {
|
|
427
|
+
conversationId: outbound.conversationId,
|
|
428
|
+
error: err instanceof Error ? err.message : String(err),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
417
431
|
}
|
|
418
432
|
}
|
|
419
433
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
2
2
|
import { type RuntimeFactory } from "./dispatcher.js";
|
|
3
3
|
import { type GatewayLogger } from "./log.js";
|
|
4
|
-
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
5
|
/** Constructor options for `Gateway`. */
|
|
6
6
|
export interface GatewayBootOptions {
|
|
7
7
|
config: GatewayConfig;
|
|
@@ -30,6 +30,11 @@ export interface GatewayBootOptions {
|
|
|
30
30
|
* to the runtime. Forwarded to the dispatcher; see {@link UserTurnBuilder}.
|
|
31
31
|
*/
|
|
32
32
|
composeUserTurn?: UserTurnBuilder;
|
|
33
|
+
/**
|
|
34
|
+
* Optional observer fired after each reply is sent. Intended for outbound
|
|
35
|
+
* bookkeeping like loop-risk tracking.
|
|
36
|
+
*/
|
|
37
|
+
onOutbound?: OutboundObserver;
|
|
33
38
|
}
|
|
34
39
|
/**
|
|
35
40
|
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -63,6 +63,7 @@ export class Gateway {
|
|
|
63
63
|
buildSystemContext: opts.buildSystemContext,
|
|
64
64
|
onInbound: opts.onInbound,
|
|
65
65
|
composeUserTurn: opts.composeUserTurn,
|
|
66
|
+
onOutbound: opts.onOutbound,
|
|
66
67
|
managedRoutes: this.managedRoutes,
|
|
67
68
|
});
|
|
68
69
|
this.channelManager = new ChannelManager({
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -3,8 +3,13 @@ import type { GatewayConfig, GatewayInboundMessage, GatewayRoute, RouteMatch } f
|
|
|
3
3
|
export declare function matchesRoute(message: GatewayInboundMessage, match: RouteMatch | undefined): boolean;
|
|
4
4
|
/**
|
|
5
5
|
* Picks the first matching route in priority order:
|
|
6
|
-
* 1. `config.routes[]`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
7
|
+
* accountId — explicit operator override for a specific agent.
|
|
8
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
9
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
10
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
11
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
12
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
13
|
+
* 4. `config.defaultRoute`.
|
|
9
14
|
*/
|
|
10
15
|
export declare function resolveRoute(message: GatewayInboundMessage, config: Pick<GatewayConfig, "defaultRoute" | "routes">, managedRoutes?: readonly GatewayRoute[]): GatewayRoute;
|
package/dist/gateway/router.js
CHANGED
|
@@ -28,15 +28,21 @@ export function matchesRoute(message, match) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Picks the first matching route in priority order:
|
|
31
|
-
* 1. `config.routes[]`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
32
|
+
* accountId — explicit operator override for a specific agent.
|
|
33
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
34
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
35
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
36
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
37
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
38
|
+
* 4. `config.defaultRoute`.
|
|
34
39
|
*/
|
|
35
40
|
export function resolveRoute(message, config, managedRoutes) {
|
|
36
41
|
const routes = config.routes ?? [];
|
|
37
42
|
for (const route of routes) {
|
|
38
|
-
if (matchesRoute(message, route.match))
|
|
43
|
+
if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
|
|
39
44
|
return route;
|
|
45
|
+
}
|
|
40
46
|
}
|
|
41
47
|
if (managedRoutes) {
|
|
42
48
|
for (const route of managedRoutes) {
|
|
@@ -44,5 +50,9 @@ export function resolveRoute(message, config, managedRoutes) {
|
|
|
44
50
|
return route;
|
|
45
51
|
}
|
|
46
52
|
}
|
|
53
|
+
for (const route of routes) {
|
|
54
|
+
if (matchesRoute(message, route.match))
|
|
55
|
+
return route;
|
|
56
|
+
}
|
|
47
57
|
return config.defaultRoute;
|
|
48
58
|
}
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -110,6 +110,12 @@ export type InboundObserver = (message: GatewayInboundMessage) => Promise<void>
|
|
|
110
110
|
* a buggy composer never drops turns.
|
|
111
111
|
*/
|
|
112
112
|
export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
|
|
113
|
+
/**
|
|
114
|
+
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
115
|
+
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
116
|
+
* are caught and logged so observer failures never break the turn.
|
|
117
|
+
*/
|
|
118
|
+
export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
|
|
113
119
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
114
120
|
export interface GatewayOutboundMessage {
|
|
115
121
|
channel: string;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
export type LoopRiskReason = {
|
|
21
|
+
id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
|
|
22
|
+
summary: string;
|
|
23
|
+
};
|
|
24
|
+
export interface LoopRiskEvaluation {
|
|
25
|
+
reasons: LoopRiskReason[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Strip the `[BotCord Message] | …` header and `<agent-message>` / hint
|
|
29
|
+
* wrappers the user-turn composer adds around the raw inbound text. Leaves
|
|
30
|
+
* the plain body so similarity / ack detection operates on actual content.
|
|
31
|
+
* Kept in sync with `turn-text.ts` output shape.
|
|
32
|
+
*/
|
|
33
|
+
export declare function stripBotCordPromptScaffolding(text: string): string;
|
|
34
|
+
export declare function normalizeLoopText(text: string): string;
|
|
35
|
+
export declare function recordInboundText(params: {
|
|
36
|
+
sessionKey?: string;
|
|
37
|
+
text?: unknown;
|
|
38
|
+
timestamp?: number;
|
|
39
|
+
}): void;
|
|
40
|
+
export declare function recordOutboundText(params: {
|
|
41
|
+
sessionKey?: string;
|
|
42
|
+
text?: unknown;
|
|
43
|
+
timestamp?: number;
|
|
44
|
+
}): void;
|
|
45
|
+
export declare function clearLoopRiskSession(sessionKey?: string): void;
|
|
46
|
+
export declare function resetLoopRiskStateForTests(): void;
|
|
47
|
+
export declare function evaluateLoopRisk(params: {
|
|
48
|
+
sessionKey?: string;
|
|
49
|
+
now?: number;
|
|
50
|
+
}): LoopRiskEvaluation;
|
|
51
|
+
/** Build the injected system-context hint, or `null` if no risk detected. */
|
|
52
|
+
export declare function buildLoopRiskPrompt(params: {
|
|
53
|
+
sessionKey?: string;
|
|
54
|
+
now?: number;
|
|
55
|
+
}): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Derive a loop-risk session key from a gateway inbound message or
|
|
58
|
+
* outbound reply. Keyed on (accountId, conversationId, threadId) so a
|
|
59
|
+
* DM and a group thread under the same agent don't share state.
|
|
60
|
+
*/
|
|
61
|
+
export declare function loopRiskSessionKey(params: {
|
|
62
|
+
accountId: string;
|
|
63
|
+
conversationId: string;
|
|
64
|
+
threadId?: string | null;
|
|
65
|
+
}): string;
|