@botcord/daemon 0.2.4 → 0.2.6
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/agent-discovery.d.ts +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +2 -1
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +681 -58
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import type { GatewayLogger } from "./log.js";
|
|
2
4
|
import { resolveRoute } from "./router.js";
|
|
3
5
|
import { sessionKey, type SessionStore } from "./session-store.js";
|
|
6
|
+
import {
|
|
7
|
+
truncateTextField,
|
|
8
|
+
type DeliveryStatus,
|
|
9
|
+
type TranscriptBlockSummary,
|
|
10
|
+
type TranscriptWriter,
|
|
11
|
+
} from "./transcript.js";
|
|
4
12
|
import type {
|
|
5
13
|
ChannelAdapter,
|
|
6
14
|
GatewayConfig,
|
|
7
15
|
GatewayInboundEnvelope,
|
|
16
|
+
GatewayInboundMessage,
|
|
8
17
|
GatewayOutboundMessage,
|
|
9
18
|
GatewayRoute,
|
|
10
19
|
GatewaySessionEntry,
|
|
@@ -20,6 +29,24 @@ import type {
|
|
|
20
29
|
|
|
21
30
|
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
|
|
34
|
+
* `result.text` forwarded to the channel; in every other room the runtime's
|
|
35
|
+
* plain text output is discarded — agents must use the `botcord_send` tool
|
|
36
|
+
* (or `botcord send` CLI via Bash) to actually deliver replies.
|
|
37
|
+
*/
|
|
38
|
+
const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
|
|
39
|
+
|
|
40
|
+
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
41
|
+
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Soft cap on the total characters across raw.batch members in a merged
|
|
45
|
+
* turn. When exceeded, oldest entries are dropped (with a warn log) so the
|
|
46
|
+
* runtime prompt stays bounded even if the channel-side batch was huge.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_BATCH_BUFFER_CHARS = 16000;
|
|
49
|
+
|
|
23
50
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
24
51
|
export type RuntimeFactory = (
|
|
25
52
|
runtimeId: string,
|
|
@@ -65,20 +92,80 @@ export interface DispatcherOptions {
|
|
|
65
92
|
* and suppressed so observer failures never break the turn.
|
|
66
93
|
*/
|
|
67
94
|
onOutbound?: OutboundObserver;
|
|
95
|
+
/**
|
|
96
|
+
* Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
|
|
97
|
+
* runs and BEFORE the runtime turn enqueues, so working memory / activity
|
|
98
|
+
* tracking still observe the message even when the gate skips the wake.
|
|
99
|
+
*
|
|
100
|
+
* Return `true` to wake the runtime, `false` to skip the turn. Errors are
|
|
101
|
+
* logged and treated as `true` (fail-open) so a buggy gate cannot silence
|
|
102
|
+
* the agent.
|
|
103
|
+
*/
|
|
104
|
+
attentionGate?: (
|
|
105
|
+
message: GatewayInboundMessage,
|
|
106
|
+
) => Promise<boolean> | boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the hub URL the inbound message's agent is registered against.
|
|
109
|
+
* Threaded into `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
|
|
110
|
+
* target the correct hub. If unset, runtimes leave `BOTCORD_HUB`
|
|
111
|
+
* unspecified and fall back to whatever the bundled CLI defaults to.
|
|
112
|
+
*/
|
|
113
|
+
resolveHubUrl?: (accountId: string) => string | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Optional NDJSON transcript writer. When provided, dispatcher emits one
|
|
116
|
+
* inbound record + one path record + (for dispatched turns) one terminal
|
|
117
|
+
* record per `handle()` call. A noop writer is used by default so existing
|
|
118
|
+
* call sites keep working unchanged. See `docs/transcript-logging.md`.
|
|
119
|
+
*/
|
|
120
|
+
transcript?: TranscriptWriter;
|
|
68
121
|
}
|
|
69
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
125
|
+
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
126
|
+
* `runTurn`'s finalize know NOT to write a `turn_error` — the supersede
|
|
127
|
+
* path already wrote a `dropped` record for the old turnId before the abort.
|
|
128
|
+
*/
|
|
129
|
+
class TurnSupersededError extends Error {
|
|
130
|
+
constructor(public readonly supersededBy: string) {
|
|
131
|
+
super("turn superseded");
|
|
132
|
+
this.name = "TurnSupersededError";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const NOOP_TRANSCRIPT: TranscriptWriter = {
|
|
137
|
+
enabled: false,
|
|
138
|
+
rootDir: "",
|
|
139
|
+
write: () => {},
|
|
140
|
+
};
|
|
141
|
+
|
|
70
142
|
interface TurnSlot {
|
|
143
|
+
turnId: string;
|
|
71
144
|
controller: AbortController;
|
|
72
145
|
timedOut: boolean;
|
|
73
146
|
snapshot: TurnStatusSnapshot;
|
|
74
147
|
done: Promise<void>;
|
|
148
|
+
dispatchedAt: number;
|
|
149
|
+
/** Streamed block summaries flushed into the terminal `outbound` record. */
|
|
150
|
+
blocks: TranscriptBlockSummary[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One entry buffered for serial-mode coalescing. Each successful `runSerial`
|
|
155
|
+
* call pushes one entry; the worker drains the entire buffer on the next
|
|
156
|
+
* turn boundary and merges them into a single dispatch.
|
|
157
|
+
*/
|
|
158
|
+
interface BufferedSerialEntry {
|
|
159
|
+
route: GatewayRoute;
|
|
160
|
+
msg: GatewayInboundEnvelope["message"];
|
|
161
|
+
channel: ChannelAdapter;
|
|
162
|
+
/** Per-arrival turnId; preserved through merge so transcript can record dropped/dispatched correctly. */
|
|
163
|
+
turnId: string;
|
|
75
164
|
}
|
|
76
165
|
|
|
77
166
|
interface QueueState {
|
|
78
167
|
/** The currently executing turn on this queue key, if any. */
|
|
79
168
|
current: TurnSlot | null;
|
|
80
|
-
/** Tail of the serial-mode queue — chained via promises; replaced each append. */
|
|
81
|
-
tail: Promise<void>;
|
|
82
169
|
/**
|
|
83
170
|
* Generation counter bumped every time a cancel-previous turn arrives.
|
|
84
171
|
* Any in-flight cancel-previous arrival captures the value at entry; if a
|
|
@@ -88,6 +175,15 @@ interface QueueState {
|
|
|
88
175
|
* `current === null` after an abort and run concurrently.
|
|
89
176
|
*/
|
|
90
177
|
cancelGen: number;
|
|
178
|
+
/**
|
|
179
|
+
* Serial-mode coalescing buffer. Messages pushed here while a turn is in
|
|
180
|
+
* flight are drained — and merged into a single user turn — on the next
|
|
181
|
+
* iteration of the worker loop. First message in an idle queue triggers a
|
|
182
|
+
* turn immediately; subsequent arrivals fold into the next batch.
|
|
183
|
+
*/
|
|
184
|
+
serialBuffer: BufferedSerialEntry[];
|
|
185
|
+
/** True when the serial-drain worker is actively running (or about to). */
|
|
186
|
+
serialWorkerActive: boolean;
|
|
91
187
|
}
|
|
92
188
|
|
|
93
189
|
/**
|
|
@@ -112,6 +208,11 @@ export class Dispatcher {
|
|
|
112
208
|
private readonly onOutbound?: OutboundObserver;
|
|
113
209
|
private readonly composeUserTurn?: UserTurnBuilder;
|
|
114
210
|
private readonly managedRoutes?: Map<string, GatewayRoute>;
|
|
211
|
+
private readonly attentionGate?: (
|
|
212
|
+
message: GatewayInboundMessage,
|
|
213
|
+
) => Promise<boolean> | boolean;
|
|
214
|
+
private readonly resolveHubUrl?: (accountId: string) => string | undefined;
|
|
215
|
+
private readonly transcript: TranscriptWriter;
|
|
115
216
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
116
217
|
|
|
117
218
|
constructor(opts: DispatcherOptions) {
|
|
@@ -126,21 +227,32 @@ export class Dispatcher {
|
|
|
126
227
|
this.onOutbound = opts.onOutbound;
|
|
127
228
|
this.composeUserTurn = opts.composeUserTurn;
|
|
128
229
|
this.managedRoutes = opts.managedRoutes;
|
|
230
|
+
this.attentionGate = opts.attentionGate;
|
|
231
|
+
this.resolveHubUrl = opts.resolveHubUrl;
|
|
232
|
+
this.transcript = opts.transcript ?? NOOP_TRANSCRIPT;
|
|
129
233
|
}
|
|
130
234
|
|
|
131
235
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
132
236
|
async handle(envelope: GatewayInboundEnvelope): Promise<void> {
|
|
133
237
|
const msg = envelope.message;
|
|
134
238
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
239
|
+
// ---- Pre-skip branches: NEVER write a transcript record (design §3.2).
|
|
240
|
+
// Order matters: unknown channel → own echo → empty text. Each ack's the
|
|
241
|
+
// envelope (when applicable) and returns silently with only a debug/warn
|
|
242
|
+
// line in the daemon log.
|
|
243
|
+
|
|
244
|
+
// Pre-skip: unknown channel — configuration error, not a conversation event.
|
|
245
|
+
const channel = this.channels.get(msg.channel);
|
|
246
|
+
if (!channel) {
|
|
247
|
+
this.log.warn("dispatcher: unknown channel for outbound reply", {
|
|
248
|
+
channel: msg.channel,
|
|
249
|
+
messageId: msg.id,
|
|
250
|
+
});
|
|
139
251
|
await this.safeAck(envelope);
|
|
140
252
|
return;
|
|
141
253
|
}
|
|
142
254
|
|
|
143
|
-
//
|
|
255
|
+
// Pre-skip: echo from the agent itself (own agent output looped back).
|
|
144
256
|
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
145
257
|
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
146
258
|
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
@@ -149,33 +261,52 @@ export class Dispatcher {
|
|
|
149
261
|
return;
|
|
150
262
|
}
|
|
151
263
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
264
|
+
// Pre-skip: empty/whitespace text.
|
|
265
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
266
|
+
if (!rawText) {
|
|
267
|
+
this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
|
|
268
|
+
await this.safeAck(envelope);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// From here on, the inbound is a real conversation event — generate a
|
|
273
|
+
// turnId and write the inbound transcript record.
|
|
274
|
+
const turnId = randomUUID();
|
|
275
|
+
|
|
276
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
277
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
278
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
279
|
+
const queueKey = buildQueueKey(msg);
|
|
280
|
+
|
|
281
|
+
// Compose the final user-turn text only for cancel-previous mode, where
|
|
282
|
+
// the dispatcher consumes the pre-composed text directly. Serial mode
|
|
283
|
+
// re-runs the composer at drain time on the merged message (so it sees
|
|
284
|
+
// the full coalesced batch instead of any single arrival), so calling
|
|
285
|
+
// the composer here would just be redundant work.
|
|
156
286
|
let text = rawText;
|
|
157
|
-
|
|
287
|
+
let composeFailedError: string | undefined;
|
|
288
|
+
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
158
289
|
try {
|
|
159
290
|
const composed = this.composeUserTurn(msg);
|
|
160
291
|
if (typeof composed === "string" && composed.length > 0) {
|
|
161
292
|
text = composed;
|
|
162
293
|
}
|
|
163
294
|
} catch (err) {
|
|
295
|
+
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
164
296
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
165
297
|
messageId: msg.id,
|
|
166
|
-
error:
|
|
298
|
+
error: composeFailedError,
|
|
167
299
|
});
|
|
168
300
|
}
|
|
169
301
|
}
|
|
170
302
|
|
|
171
|
-
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
172
|
-
const route = resolveRoute(msg, this.config, managed);
|
|
173
|
-
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
174
|
-
const queueKey = buildQueueKey(msg);
|
|
175
|
-
|
|
176
303
|
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
177
304
|
await this.safeAck(envelope);
|
|
178
305
|
|
|
306
|
+
// Inbound transcript record — always before observers / gates so we have a
|
|
307
|
+
// grounded turnId for any downstream attention_skipped / dropped / etc.
|
|
308
|
+
this.emitInbound(turnId, msg);
|
|
309
|
+
|
|
179
310
|
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
180
311
|
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
181
312
|
if (this.onInbound) {
|
|
@@ -189,19 +320,58 @@ export class Dispatcher {
|
|
|
189
320
|
}
|
|
190
321
|
}
|
|
191
322
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
323
|
+
// Attention gate (PR3, design §4.2). Inserted AFTER `onInbound` so the
|
|
324
|
+
// working-memory append + activity tracking still see the message — only
|
|
325
|
+
// the runtime turn is suppressed. Errors are treated as wake (fail-open)
|
|
326
|
+
// so a buggy gate cannot silence the agent.
|
|
327
|
+
if (this.attentionGate) {
|
|
328
|
+
let wake = true;
|
|
329
|
+
try {
|
|
330
|
+
const result = this.attentionGate(msg);
|
|
331
|
+
wake = result instanceof Promise ? await result : result;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
this.log.warn("dispatcher: attentionGate threw — waking", {
|
|
334
|
+
messageId: msg.id,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
});
|
|
337
|
+
wake = true;
|
|
338
|
+
}
|
|
339
|
+
if (!wake) {
|
|
340
|
+
this.log.debug("dispatcher skip turn: attention policy", {
|
|
341
|
+
messageId: msg.id,
|
|
342
|
+
accountId: msg.accountId,
|
|
343
|
+
conversationId: msg.conversation.id,
|
|
344
|
+
});
|
|
345
|
+
this.transcript.write({
|
|
346
|
+
ts: nowIso(),
|
|
347
|
+
kind: "attention_skipped",
|
|
348
|
+
turnId,
|
|
349
|
+
agentId: msg.accountId,
|
|
350
|
+
roomId: msg.conversation.id,
|
|
351
|
+
topicId: msg.conversation.threadId ?? null,
|
|
352
|
+
reason: "attention_gate_false",
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (composeFailedError) {
|
|
359
|
+
this.transcript.write({
|
|
360
|
+
ts: nowIso(),
|
|
361
|
+
kind: "compose_failed",
|
|
362
|
+
turnId,
|
|
363
|
+
agentId: msg.accountId,
|
|
364
|
+
roomId: msg.conversation.id,
|
|
365
|
+
topicId: msg.conversation.threadId ?? null,
|
|
366
|
+
error: composeFailedError,
|
|
367
|
+
fallback: "raw_text",
|
|
197
368
|
});
|
|
198
|
-
return;
|
|
199
369
|
}
|
|
200
370
|
|
|
201
371
|
if (mode === "cancel-previous") {
|
|
202
|
-
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
372
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
|
|
203
373
|
} else {
|
|
204
|
-
await this.runSerial(queueKey, route, text, msg, channel);
|
|
374
|
+
await this.runSerial(queueKey, route, text, msg, channel, turnId);
|
|
205
375
|
}
|
|
206
376
|
}
|
|
207
377
|
|
|
@@ -236,8 +406,9 @@ export class Dispatcher {
|
|
|
236
406
|
if (!q) {
|
|
237
407
|
q = {
|
|
238
408
|
current: null,
|
|
239
|
-
tail: Promise.resolve(),
|
|
240
409
|
cancelGen: 0,
|
|
410
|
+
serialBuffer: [],
|
|
411
|
+
serialWorkerActive: false,
|
|
241
412
|
};
|
|
242
413
|
this.queues.set(key, q);
|
|
243
414
|
}
|
|
@@ -250,6 +421,7 @@ export class Dispatcher {
|
|
|
250
421
|
text: string,
|
|
251
422
|
msg: GatewayInboundEnvelope["message"],
|
|
252
423
|
channel: ChannelAdapter,
|
|
424
|
+
turnId: string,
|
|
253
425
|
): Promise<void> {
|
|
254
426
|
const q = this.getQueue(queueKey);
|
|
255
427
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
@@ -260,7 +432,19 @@ export class Dispatcher {
|
|
|
260
432
|
const prev = q.current;
|
|
261
433
|
if (prev) {
|
|
262
434
|
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
263
|
-
prev
|
|
435
|
+
// Record the supersede BEFORE aborting so the prev turn's finalize sees
|
|
436
|
+
// the abort reason (TurnSupersededError) and skips writing turn_error.
|
|
437
|
+
this.transcript.write({
|
|
438
|
+
ts: nowIso(),
|
|
439
|
+
kind: "dropped",
|
|
440
|
+
turnId: prev.turnId,
|
|
441
|
+
agentId: msg.accountId,
|
|
442
|
+
roomId: msg.conversation.id,
|
|
443
|
+
topicId: msg.conversation.threadId ?? null,
|
|
444
|
+
reason: "queue_cancel_previous",
|
|
445
|
+
supersededBy: turnId,
|
|
446
|
+
});
|
|
447
|
+
prev.controller.abort(new TurnSupersededError(turnId));
|
|
264
448
|
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
265
449
|
await prev.done.catch(() => undefined);
|
|
266
450
|
}
|
|
@@ -269,23 +453,217 @@ export class Dispatcher {
|
|
|
269
453
|
// drop out silently — the newest turn is the only one that should run.
|
|
270
454
|
if (myGen !== q.cancelGen) {
|
|
271
455
|
this.log.info("dispatcher: cancel-previous superseded", { queueKey });
|
|
456
|
+
// We didn't run the turn; emit dropped so the caller's inbound has a
|
|
457
|
+
// matching path record. supersededBy is unknown at this layer (newer
|
|
458
|
+
// arrival owns its own bump) — leave null.
|
|
459
|
+
this.transcript.write({
|
|
460
|
+
ts: nowIso(),
|
|
461
|
+
kind: "dropped",
|
|
462
|
+
turnId,
|
|
463
|
+
agentId: msg.accountId,
|
|
464
|
+
roomId: msg.conversation.id,
|
|
465
|
+
topicId: msg.conversation.threadId ?? null,
|
|
466
|
+
reason: "queue_cancel_previous",
|
|
467
|
+
supersededBy: null,
|
|
468
|
+
});
|
|
272
469
|
return;
|
|
273
470
|
}
|
|
274
|
-
await this.runTurn(queueKey, route, text, msg, channel);
|
|
471
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
|
|
275
472
|
}
|
|
276
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Serial mode with coalesce-on-drain semantics:
|
|
476
|
+
*
|
|
477
|
+
* 1. First arrival on an idle queue boots the worker, which dispatches a
|
|
478
|
+
* single-message turn immediately (no batching delay).
|
|
479
|
+
* 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
|
|
480
|
+
* worker finishes the current turn it drains the entire buffer and
|
|
481
|
+
* merges all pending entries into ONE next turn (folded into a single
|
|
482
|
+
* `raw.batch` so the composer renders them as multi-block input).
|
|
483
|
+
* 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
|
|
484
|
+
* (drop oldest) and merged turns are further trimmed to fit
|
|
485
|
+
* `MAX_BATCH_BUFFER_CHARS` of total raw text.
|
|
486
|
+
*
|
|
487
|
+
* Note: the pre-composed `text` from `handle()` is intentionally discarded
|
|
488
|
+
* here — at drain time the worker re-invokes `composeUserTurn` on the
|
|
489
|
+
* merged message so the runtime sees a single coherent prompt covering all
|
|
490
|
+
* coalesced messages.
|
|
491
|
+
*/
|
|
277
492
|
private async runSerial(
|
|
278
493
|
queueKey: string,
|
|
279
494
|
route: GatewayRoute,
|
|
280
|
-
|
|
495
|
+
_text: string,
|
|
281
496
|
msg: GatewayInboundEnvelope["message"],
|
|
282
497
|
channel: ChannelAdapter,
|
|
498
|
+
turnId: string,
|
|
283
499
|
): Promise<void> {
|
|
284
500
|
const q = this.getQueue(queueKey);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
501
|
+
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
502
|
+
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
503
|
+
const dropped = q.serialBuffer.shift()!;
|
|
504
|
+
this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
|
|
505
|
+
queueKey,
|
|
506
|
+
droppedMessageId: dropped.msg.id,
|
|
507
|
+
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
508
|
+
});
|
|
509
|
+
this.transcript.write({
|
|
510
|
+
ts: nowIso(),
|
|
511
|
+
kind: "dropped",
|
|
512
|
+
turnId: dropped.turnId,
|
|
513
|
+
agentId: dropped.msg.accountId,
|
|
514
|
+
roomId: dropped.msg.conversation.id,
|
|
515
|
+
topicId: dropped.msg.conversation.threadId ?? null,
|
|
516
|
+
reason: "queue_overflow",
|
|
517
|
+
supersededBy: null,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (q.serialWorkerActive) return;
|
|
521
|
+
q.serialWorkerActive = true;
|
|
522
|
+
try {
|
|
523
|
+
while (q.serialBuffer.length > 0) {
|
|
524
|
+
const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
|
|
525
|
+
const merged = this.mergeSerialBuffer(drained, queueKey);
|
|
526
|
+
if (!merged) continue;
|
|
527
|
+
// Drained entries other than the winner get a `batch_merged` dropped
|
|
528
|
+
// record now (winner is always the last entry — see mergeSerialBuffer).
|
|
529
|
+
if (drained.length > 1) {
|
|
530
|
+
for (let i = 0; i < drained.length - 1; i++) {
|
|
531
|
+
const lost = drained[i]!;
|
|
532
|
+
this.transcript.write({
|
|
533
|
+
ts: nowIso(),
|
|
534
|
+
kind: "dropped",
|
|
535
|
+
turnId: lost.turnId,
|
|
536
|
+
agentId: lost.msg.accountId,
|
|
537
|
+
roomId: lost.msg.conversation.id,
|
|
538
|
+
topicId: lost.msg.conversation.threadId ?? null,
|
|
539
|
+
reason: "batch_merged",
|
|
540
|
+
supersededBy: merged.turnId,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const mergedFromTurnIds =
|
|
545
|
+
drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
|
|
546
|
+
await this.runTurn(
|
|
547
|
+
queueKey,
|
|
548
|
+
merged.route,
|
|
549
|
+
merged.text,
|
|
550
|
+
merged.msg,
|
|
551
|
+
merged.channel,
|
|
552
|
+
merged.turnId,
|
|
553
|
+
mergedFromTurnIds,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
} finally {
|
|
557
|
+
q.serialWorkerActive = false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Merge buffered serial entries into a single dispatchable unit. With one
|
|
563
|
+
* entry the call is a near no-op (just recompose). With ≥2 entries this
|
|
564
|
+
* flattens any per-entry `raw.batch` (the BotCord channel already groups
|
|
565
|
+
* one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
|
|
566
|
+
* applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
|
|
567
|
+
* messages, and then synthesizes a merged inbound message anchored on the
|
|
568
|
+
* latest entry's metadata (mentioned = OR across all entries).
|
|
569
|
+
*/
|
|
570
|
+
private mergeSerialBuffer(
|
|
571
|
+
entries: BufferedSerialEntry[],
|
|
572
|
+
queueKey: string,
|
|
573
|
+
): {
|
|
574
|
+
route: GatewayRoute;
|
|
575
|
+
text: string;
|
|
576
|
+
msg: GatewayInboundEnvelope["message"];
|
|
577
|
+
channel: ChannelAdapter;
|
|
578
|
+
turnId: string;
|
|
579
|
+
} | null {
|
|
580
|
+
if (entries.length === 0) return null;
|
|
581
|
+
if (entries.length === 1) {
|
|
582
|
+
const only = entries[0]!;
|
|
583
|
+
return {
|
|
584
|
+
route: only.route,
|
|
585
|
+
text: this.recomposeUserTurn(only.msg),
|
|
586
|
+
msg: only.msg,
|
|
587
|
+
channel: only.channel,
|
|
588
|
+
turnId: only.turnId,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Flatten: each entry's raw may already be a BatchedInboxRaw with
|
|
593
|
+
// `.batch`; otherwise it's a single InboxMessage we treat as a 1-element
|
|
594
|
+
// batch. Insertion order preserves chronology.
|
|
595
|
+
const items: Array<Record<string, unknown>> = [];
|
|
596
|
+
for (const e of entries) {
|
|
597
|
+
const raw = e.msg.raw as Record<string, unknown> | null | undefined;
|
|
598
|
+
const batch = raw && Array.isArray((raw as { batch?: unknown }).batch)
|
|
599
|
+
? ((raw as { batch: Array<Record<string, unknown>> }).batch)
|
|
600
|
+
: null;
|
|
601
|
+
if (batch) {
|
|
602
|
+
for (const m of batch) items.push(m);
|
|
603
|
+
} else if (raw) {
|
|
604
|
+
items.push(raw);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Char-cap: drop oldest until we fit. Reserve at least one item so we
|
|
609
|
+
// never produce an empty merged batch.
|
|
610
|
+
let totalChars = items.reduce(
|
|
611
|
+
(acc, m) => acc + (typeof m?.text === "string" ? (m.text as string).length : 0),
|
|
612
|
+
0,
|
|
613
|
+
);
|
|
614
|
+
let droppedCount = 0;
|
|
615
|
+
while (totalChars > MAX_BATCH_BUFFER_CHARS && items.length > 1) {
|
|
616
|
+
const removed = items.shift()!;
|
|
617
|
+
totalChars -= typeof removed?.text === "string" ? (removed.text as string).length : 0;
|
|
618
|
+
droppedCount += 1;
|
|
619
|
+
}
|
|
620
|
+
if (droppedCount > 0) {
|
|
621
|
+
this.log.warn("dispatcher: merged batch exceeded char cap — dropped oldest", {
|
|
622
|
+
queueKey,
|
|
623
|
+
droppedCount,
|
|
624
|
+
remaining: items.length,
|
|
625
|
+
totalChars,
|
|
626
|
+
charCap: MAX_BATCH_BUFFER_CHARS,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const latest = entries[entries.length - 1]!;
|
|
631
|
+
const latestRaw = (latest.msg.raw as Record<string, unknown> | null | undefined) ?? {};
|
|
632
|
+
const mergedRaw = { ...latestRaw, batch: items };
|
|
633
|
+
const anyMentioned = entries.some((e) => e.msg.mentioned === true);
|
|
634
|
+
const mergedMsg: GatewayInboundEnvelope["message"] = {
|
|
635
|
+
...latest.msg,
|
|
636
|
+
mentioned: anyMentioned,
|
|
637
|
+
raw: mergedRaw,
|
|
638
|
+
};
|
|
639
|
+
return {
|
|
640
|
+
route: latest.route,
|
|
641
|
+
text: this.recomposeUserTurn(mergedMsg),
|
|
642
|
+
msg: mergedMsg,
|
|
643
|
+
channel: latest.channel,
|
|
644
|
+
turnId: latest.turnId,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Re-run the user-turn composer at drain time. Mirrors the logic in
|
|
650
|
+
* `handle()` but operates on the (possibly merged) message. Falls back to
|
|
651
|
+
* raw trimmed text on composer failure so a buggy composer never drops a
|
|
652
|
+
* turn.
|
|
653
|
+
*/
|
|
654
|
+
private recomposeUserTurn(msg: GatewayInboundEnvelope["message"]): string {
|
|
655
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
656
|
+
if (!this.composeUserTurn) return rawText;
|
|
657
|
+
try {
|
|
658
|
+
const composed = this.composeUserTurn(msg);
|
|
659
|
+
if (typeof composed === "string" && composed.length > 0) return composed;
|
|
660
|
+
} catch (err) {
|
|
661
|
+
this.log.warn("dispatcher: composeUserTurn (drain) threw — using raw text", {
|
|
662
|
+
messageId: msg.id,
|
|
663
|
+
error: err instanceof Error ? err.message : String(err),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return rawText;
|
|
289
667
|
}
|
|
290
668
|
|
|
291
669
|
private async runTurn(
|
|
@@ -294,6 +672,8 @@ export class Dispatcher {
|
|
|
294
672
|
text: string,
|
|
295
673
|
msg: GatewayInboundEnvelope["message"],
|
|
296
674
|
channel: ChannelAdapter,
|
|
675
|
+
turnId: string,
|
|
676
|
+
mergedFromTurnIds: string[],
|
|
297
677
|
): Promise<void> {
|
|
298
678
|
const q = this.getQueue(queueKey);
|
|
299
679
|
const controller = new AbortController();
|
|
@@ -312,9 +692,35 @@ export class Dispatcher {
|
|
|
312
692
|
const done = new Promise<void>((res) => {
|
|
313
693
|
resolveDone = res;
|
|
314
694
|
});
|
|
315
|
-
const slot: TurnSlot = {
|
|
695
|
+
const slot: TurnSlot = {
|
|
696
|
+
turnId,
|
|
697
|
+
controller,
|
|
698
|
+
timedOut: false,
|
|
699
|
+
snapshot,
|
|
700
|
+
done,
|
|
701
|
+
dispatchedAt: startedAt,
|
|
702
|
+
blocks: [],
|
|
703
|
+
};
|
|
316
704
|
q.current = slot;
|
|
317
705
|
|
|
706
|
+
// Dispatched record — marks "this turn entered runtime".
|
|
707
|
+
{
|
|
708
|
+
const composedField = truncateTextField(text);
|
|
709
|
+
const dispatched: import("./transcript.js").DispatchedTranscriptRecord = {
|
|
710
|
+
ts: nowIso(),
|
|
711
|
+
kind: "dispatched",
|
|
712
|
+
turnId,
|
|
713
|
+
agentId: msg.accountId,
|
|
714
|
+
roomId: msg.conversation.id,
|
|
715
|
+
topicId: msg.conversation.threadId ?? null,
|
|
716
|
+
composedText: composedField.text,
|
|
717
|
+
runtime: route.runtime,
|
|
718
|
+
};
|
|
719
|
+
if (mergedFromTurnIds.length > 0) dispatched.mergedFromTurnIds = mergedFromTurnIds;
|
|
720
|
+
if (composedField.truncated) dispatched.truncated = { composedText: true };
|
|
721
|
+
this.transcript.write(dispatched);
|
|
722
|
+
}
|
|
723
|
+
|
|
318
724
|
// Hard-cap turn with a timeout.
|
|
319
725
|
const timer = setTimeout(() => {
|
|
320
726
|
slot.timedOut = true;
|
|
@@ -342,8 +748,18 @@ export class Dispatcher {
|
|
|
342
748
|
const traceId = msg.trace?.id;
|
|
343
749
|
const canStream =
|
|
344
750
|
streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
751
|
+
const recordBlock = (block: StreamBlock): void => {
|
|
752
|
+
const summary: TranscriptBlockSummary = { type: block.kind };
|
|
753
|
+
const raw = block.raw as { text?: unknown; name?: unknown } | null | undefined;
|
|
754
|
+
if (raw && typeof raw === "object") {
|
|
755
|
+
if (typeof raw.text === "string") summary.chars = raw.text.length;
|
|
756
|
+
if (typeof raw.name === "string") summary.name = raw.name;
|
|
757
|
+
}
|
|
758
|
+
slot.blocks.push(summary);
|
|
759
|
+
};
|
|
345
760
|
const onBlock = canStream
|
|
346
761
|
? (block: StreamBlock) => {
|
|
762
|
+
recordBlock(block);
|
|
347
763
|
// Fire-and-forget: stream errors must not break the turn.
|
|
348
764
|
channel
|
|
349
765
|
.streamBlock!({
|
|
@@ -390,11 +806,13 @@ export class Dispatcher {
|
|
|
390
806
|
sessionId,
|
|
391
807
|
cwd: route.cwd,
|
|
392
808
|
accountId: msg.accountId,
|
|
809
|
+
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
393
810
|
extraArgs: route.extraArgs,
|
|
394
811
|
signal: controller.signal,
|
|
395
812
|
trustLevel,
|
|
396
813
|
systemContext,
|
|
397
814
|
onBlock,
|
|
815
|
+
gateway: route.gateway,
|
|
398
816
|
});
|
|
399
817
|
} catch (err) {
|
|
400
818
|
threw = err;
|
|
@@ -409,39 +827,99 @@ export class Dispatcher {
|
|
|
409
827
|
// until after the reply lets the new arrival trip our abort signal, and
|
|
410
828
|
// this check then drops us silently. Timed-out turns still fall through
|
|
411
829
|
// to send their error reply.
|
|
830
|
+
//
|
|
831
|
+
// Note on transcript: the supersede path already wrote the `dropped`
|
|
832
|
+
// record from `runCancelPrevious` BEFORE aborting, so we MUST NOT also
|
|
833
|
+
// emit a `turn_error` here — that would violate the "exactly one
|
|
834
|
+
// terminal record per turnId" invariant.
|
|
412
835
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
413
836
|
return;
|
|
414
837
|
}
|
|
415
838
|
|
|
839
|
+
// Reply gating: only owner-chat rooms accept the runtime's plain text
|
|
840
|
+
// output as a delivered message. Every other room expects the agent to
|
|
841
|
+
// call the `botcord_send` tool (or `botcord send` CLI via Bash)
|
|
842
|
+
// explicitly; runtime text in those rooms is logged and dropped,
|
|
843
|
+
// including timeout / error notifications.
|
|
844
|
+
//
|
|
845
|
+
// Owner-chat is identified by either the `rm_oc_` room prefix OR
|
|
846
|
+
// `source_type === "dashboard_user_chat"` on the raw envelope — the
|
|
847
|
+
// same dual check used by `sender-classify.ts:classifyActivitySender`,
|
|
848
|
+
// so the dispatcher's reply gating stays in lock-step with the
|
|
849
|
+
// composer's owner-bypass.
|
|
850
|
+
//
|
|
851
|
+
// Side effect: `onOutbound` (loop-risk tracking) only fires when a
|
|
852
|
+
// reply actually leaves the dispatcher. In non-owner-chat rooms the
|
|
853
|
+
// expectation is that the agent's `botcord_send` tool calls do their
|
|
854
|
+
// own loop-risk accounting downstream.
|
|
855
|
+
const isOwnerChat = isOwnerChatRoom(msg);
|
|
856
|
+
|
|
416
857
|
if (slot.timedOut) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
858
|
+
this.transcript.write({
|
|
859
|
+
ts: nowIso(),
|
|
860
|
+
kind: "turn_error",
|
|
861
|
+
turnId,
|
|
862
|
+
agentId: msg.accountId,
|
|
863
|
+
roomId: msg.conversation.id,
|
|
864
|
+
topicId: msg.conversation.threadId ?? null,
|
|
865
|
+
phase: "timeout",
|
|
866
|
+
error: `runtime timeout after ${this.turnTimeoutMs}ms`,
|
|
867
|
+
durationMs: Date.now() - slot.dispatchedAt,
|
|
425
868
|
});
|
|
869
|
+
if (isOwnerChat) {
|
|
870
|
+
await this.sendReply(channel, {
|
|
871
|
+
channel: msg.channel,
|
|
872
|
+
accountId: msg.accountId,
|
|
873
|
+
conversationId: msg.conversation.id,
|
|
874
|
+
threadId: msg.conversation.threadId ?? null,
|
|
875
|
+
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
876
|
+
replyTo: msg.id,
|
|
877
|
+
traceId: msg.trace?.id ?? null,
|
|
878
|
+
});
|
|
879
|
+
} else {
|
|
880
|
+
this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
|
|
881
|
+
queueKey,
|
|
882
|
+
conversationId: msg.conversation.id,
|
|
883
|
+
timeoutMs: this.turnTimeoutMs,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
426
886
|
return;
|
|
427
887
|
}
|
|
428
888
|
|
|
429
889
|
if (threw) {
|
|
890
|
+
const errMsg = threw instanceof Error ? threw.message : String(threw);
|
|
430
891
|
this.log.error("dispatcher: runtime threw", {
|
|
431
892
|
queueKey,
|
|
432
893
|
runtime: route.runtime,
|
|
433
|
-
error:
|
|
894
|
+
error: errMsg,
|
|
434
895
|
});
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
896
|
+
this.transcript.write({
|
|
897
|
+
ts: nowIso(),
|
|
898
|
+
kind: "turn_error",
|
|
899
|
+
turnId,
|
|
900
|
+
agentId: msg.accountId,
|
|
901
|
+
roomId: msg.conversation.id,
|
|
902
|
+
topicId: msg.conversation.threadId ?? null,
|
|
903
|
+
phase: "runtime",
|
|
904
|
+
error: errMsg,
|
|
905
|
+
durationMs: Date.now() - slot.dispatchedAt,
|
|
444
906
|
});
|
|
907
|
+
if (isOwnerChat) {
|
|
908
|
+
await this.sendReply(channel, {
|
|
909
|
+
channel: msg.channel,
|
|
910
|
+
accountId: msg.accountId,
|
|
911
|
+
conversationId: msg.conversation.id,
|
|
912
|
+
threadId: msg.conversation.threadId ?? null,
|
|
913
|
+
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
914
|
+
replyTo: msg.id,
|
|
915
|
+
traceId: msg.trace?.id ?? null,
|
|
916
|
+
});
|
|
917
|
+
} else {
|
|
918
|
+
this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
|
|
919
|
+
queueKey,
|
|
920
|
+
conversationId: msg.conversation.id,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
445
923
|
return;
|
|
446
924
|
}
|
|
447
925
|
|
|
@@ -500,7 +978,51 @@ export class Dispatcher {
|
|
|
500
978
|
}
|
|
501
979
|
|
|
502
980
|
const replyText = (result.text || "").trim();
|
|
503
|
-
|
|
981
|
+
const finalTextField = truncateTextField(result.text || "");
|
|
982
|
+
|
|
983
|
+
if (!replyText) {
|
|
984
|
+
this.emitOutbound({
|
|
985
|
+
turnId,
|
|
986
|
+
msg,
|
|
987
|
+
runtime: route.runtime,
|
|
988
|
+
runtimeSessionId: result.newSessionId || null,
|
|
989
|
+
startedAt: slot.dispatchedAt,
|
|
990
|
+
costUsd: result.costUsd,
|
|
991
|
+
finalText: finalTextField,
|
|
992
|
+
deliveryStatus: "empty_text",
|
|
993
|
+
deliveryReason: null,
|
|
994
|
+
blocks: slot.blocks,
|
|
995
|
+
});
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (!isOwnerChat) {
|
|
1000
|
+
// Non-owner-chat rooms: result.text never goes out. The agent is
|
|
1001
|
+
// expected to have used the `botcord_send` tool / `botcord send` CLI
|
|
1002
|
+
// already; whatever it left in the runtime's final assistant text is
|
|
1003
|
+
// discarded so it doesn't leak into the room.
|
|
1004
|
+
this.log.debug(
|
|
1005
|
+
"dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)",
|
|
1006
|
+
{
|
|
1007
|
+
queueKey,
|
|
1008
|
+
conversationId: msg.conversation.id,
|
|
1009
|
+
replyTextLen: replyText.length,
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1012
|
+
this.emitOutbound({
|
|
1013
|
+
turnId,
|
|
1014
|
+
msg,
|
|
1015
|
+
runtime: route.runtime,
|
|
1016
|
+
runtimeSessionId: result.newSessionId || null,
|
|
1017
|
+
startedAt: slot.dispatchedAt,
|
|
1018
|
+
costUsd: result.costUsd,
|
|
1019
|
+
finalText: finalTextField,
|
|
1020
|
+
deliveryStatus: "gated_non_owner_chat",
|
|
1021
|
+
deliveryReason: null,
|
|
1022
|
+
blocks: slot.blocks,
|
|
1023
|
+
});
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
504
1026
|
|
|
505
1027
|
// One last abort check immediately before the send. Narrows the window
|
|
506
1028
|
// in which a cancel-previous arriving during session-store.set could
|
|
@@ -509,7 +1031,7 @@ export class Dispatcher {
|
|
|
509
1031
|
return;
|
|
510
1032
|
}
|
|
511
1033
|
|
|
512
|
-
await this.sendReply(channel, {
|
|
1034
|
+
const sendResult = await this.sendReply(channel, {
|
|
513
1035
|
channel: msg.channel,
|
|
514
1036
|
accountId: msg.accountId,
|
|
515
1037
|
conversationId: msg.conversation.id,
|
|
@@ -518,6 +1040,18 @@ export class Dispatcher {
|
|
|
518
1040
|
replyTo: msg.id,
|
|
519
1041
|
traceId: msg.trace?.id ?? null,
|
|
520
1042
|
});
|
|
1043
|
+
this.emitOutbound({
|
|
1044
|
+
turnId,
|
|
1045
|
+
msg,
|
|
1046
|
+
runtime: route.runtime,
|
|
1047
|
+
runtimeSessionId: result.newSessionId || null,
|
|
1048
|
+
startedAt: slot.dispatchedAt,
|
|
1049
|
+
costUsd: result.costUsd,
|
|
1050
|
+
finalText: finalTextField,
|
|
1051
|
+
deliveryStatus: sendResult.ok ? "delivered" : "send_failed",
|
|
1052
|
+
deliveryReason: sendResult.ok ? null : sendResult.error,
|
|
1053
|
+
blocks: slot.blocks,
|
|
1054
|
+
});
|
|
521
1055
|
} finally {
|
|
522
1056
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
523
1057
|
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
@@ -532,16 +1066,17 @@ export class Dispatcher {
|
|
|
532
1066
|
private async sendReply(
|
|
533
1067
|
channel: ChannelAdapter,
|
|
534
1068
|
outbound: GatewayOutboundMessage,
|
|
535
|
-
): Promise<
|
|
1069
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
536
1070
|
try {
|
|
537
1071
|
await channel.send({ message: outbound, log: this.log });
|
|
538
1072
|
} catch (err) {
|
|
1073
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
539
1074
|
this.log.warn("dispatcher: channel.send failed", {
|
|
540
1075
|
channel: outbound.channel,
|
|
541
1076
|
conversationId: outbound.conversationId,
|
|
542
|
-
error
|
|
1077
|
+
error,
|
|
543
1078
|
});
|
|
544
|
-
return;
|
|
1079
|
+
return { ok: false, error };
|
|
545
1080
|
}
|
|
546
1081
|
if (this.onOutbound) {
|
|
547
1082
|
try {
|
|
@@ -553,14 +1088,102 @@ export class Dispatcher {
|
|
|
553
1088
|
});
|
|
554
1089
|
}
|
|
555
1090
|
}
|
|
1091
|
+
return { ok: true };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
|
|
1095
|
+
if (!this.transcript.enabled) return;
|
|
1096
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
1097
|
+
const tField = truncateTextField(rawText);
|
|
1098
|
+
const raw = msg.raw as Record<string, unknown> | null | undefined;
|
|
1099
|
+
const batch =
|
|
1100
|
+
raw && typeof raw === "object" && Array.isArray((raw as { batch?: unknown }).batch)
|
|
1101
|
+
? (raw as { batch: unknown[] }).batch.length
|
|
1102
|
+
: undefined;
|
|
1103
|
+
const rec: import("./transcript.js").InboundTranscriptRecord = {
|
|
1104
|
+
ts: nowIso(),
|
|
1105
|
+
kind: "inbound",
|
|
1106
|
+
turnId,
|
|
1107
|
+
agentId: msg.accountId,
|
|
1108
|
+
roomId: msg.conversation.id,
|
|
1109
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1110
|
+
messageId: msg.id,
|
|
1111
|
+
sender: { id: msg.sender.id, kind: msg.sender.kind, ...(msg.sender.name ? { name: msg.sender.name } : {}) },
|
|
1112
|
+
text: tField.text,
|
|
1113
|
+
};
|
|
1114
|
+
if (batch !== undefined && batch > 1) rec.rawBatchEntries = batch;
|
|
1115
|
+
if (msg.trace?.id) {
|
|
1116
|
+
rec.trace = { id: msg.trace.id, ...(msg.trace.streamable ? { streamable: true } : {}) };
|
|
1117
|
+
}
|
|
1118
|
+
if (tField.truncated) rec.truncated = { text: true };
|
|
1119
|
+
this.transcript.write(rec);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private emitOutbound(args: {
|
|
1123
|
+
turnId: string;
|
|
1124
|
+
msg: GatewayInboundEnvelope["message"];
|
|
1125
|
+
runtime: string;
|
|
1126
|
+
runtimeSessionId: string | null;
|
|
1127
|
+
startedAt: number;
|
|
1128
|
+
costUsd?: number;
|
|
1129
|
+
finalText: { text: string; truncated: boolean };
|
|
1130
|
+
deliveryStatus: DeliveryStatus;
|
|
1131
|
+
deliveryReason: string | null;
|
|
1132
|
+
blocks: TranscriptBlockSummary[];
|
|
1133
|
+
}): void {
|
|
1134
|
+
if (!this.transcript.enabled) return;
|
|
1135
|
+
const rec: import("./transcript.js").OutboundTranscriptRecord = {
|
|
1136
|
+
ts: nowIso(),
|
|
1137
|
+
kind: "outbound",
|
|
1138
|
+
turnId: args.turnId,
|
|
1139
|
+
agentId: args.msg.accountId,
|
|
1140
|
+
roomId: args.msg.conversation.id,
|
|
1141
|
+
topicId: args.msg.conversation.threadId ?? null,
|
|
1142
|
+
runtime: args.runtime,
|
|
1143
|
+
runtimeSessionId: args.runtimeSessionId,
|
|
1144
|
+
durationMs: Date.now() - args.startedAt,
|
|
1145
|
+
finalText: args.finalText.text,
|
|
1146
|
+
deliveryStatus: args.deliveryStatus,
|
|
1147
|
+
deliveryReason: args.deliveryReason,
|
|
1148
|
+
};
|
|
1149
|
+
if (typeof args.costUsd === "number") rec.costUsd = args.costUsd;
|
|
1150
|
+
if (args.blocks.length > 0) rec.blocks = args.blocks;
|
|
1151
|
+
if (args.finalText.truncated) rec.truncated = { finalText: true };
|
|
1152
|
+
this.transcript.write(rec);
|
|
556
1153
|
}
|
|
557
1154
|
}
|
|
558
1155
|
|
|
1156
|
+
function nowIso(): string {
|
|
1157
|
+
return new Date().toISOString();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
559
1160
|
function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
|
|
560
1161
|
const thread = msg.conversation.threadId ?? "";
|
|
561
1162
|
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|
|
562
1163
|
}
|
|
563
1164
|
|
|
1165
|
+
/**
|
|
1166
|
+
* Owner-chat predicate used by the dispatcher's reply gating. Matches the
|
|
1167
|
+
* dual check in `sender-classify.ts:classifyActivitySender` so the
|
|
1168
|
+
* dispatcher's gate stays consistent with the composer's owner-bypass:
|
|
1169
|
+
*
|
|
1170
|
+
* 1. `rm_oc_*` room id, OR
|
|
1171
|
+
* 2. `source_type === "dashboard_user_chat"` on the raw envelope.
|
|
1172
|
+
*
|
|
1173
|
+
* The latter exists because the dashboard's user-chat surface can route
|
|
1174
|
+
* messages through non-`rm_oc_` rooms in some flows; treating them as
|
|
1175
|
+
* owner-trust here keeps the agent's plain reply text reachable.
|
|
1176
|
+
*/
|
|
1177
|
+
function isOwnerChatRoom(msg: GatewayInboundEnvelope["message"]): boolean {
|
|
1178
|
+
if (msg.conversation.id.startsWith(OWNER_CHAT_ROOM_PREFIX)) return true;
|
|
1179
|
+
const raw = msg.raw;
|
|
1180
|
+
if (raw && typeof raw === "object") {
|
|
1181
|
+
const sourceType = (raw as { source_type?: unknown }).source_type;
|
|
1182
|
+
if (sourceType === "dashboard_user_chat") return true;
|
|
1183
|
+
}
|
|
1184
|
+
return false;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
564
1187
|
function resolveQueueMode(
|
|
565
1188
|
route: GatewayRoute,
|
|
566
1189
|
kind: "direct" | "group",
|