@botcord/daemon 0.2.5 → 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 +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/daemon-config-map.d.ts +27 -9
- 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 +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- 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 +72 -1
- package/dist/provision.js +370 -7
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- 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 +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +132 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- 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 +394 -26
- 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 +438 -9
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -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,
|
|
@@ -83,13 +92,62 @@ export interface DispatcherOptions {
|
|
|
83
92
|
* and suppressed so observer failures never break the turn.
|
|
84
93
|
*/
|
|
85
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;
|
|
86
121
|
}
|
|
87
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
|
+
|
|
88
142
|
interface TurnSlot {
|
|
143
|
+
turnId: string;
|
|
89
144
|
controller: AbortController;
|
|
90
145
|
timedOut: boolean;
|
|
91
146
|
snapshot: TurnStatusSnapshot;
|
|
92
147
|
done: Promise<void>;
|
|
148
|
+
dispatchedAt: number;
|
|
149
|
+
/** Streamed block summaries flushed into the terminal `outbound` record. */
|
|
150
|
+
blocks: TranscriptBlockSummary[];
|
|
93
151
|
}
|
|
94
152
|
|
|
95
153
|
/**
|
|
@@ -101,6 +159,8 @@ interface BufferedSerialEntry {
|
|
|
101
159
|
route: GatewayRoute;
|
|
102
160
|
msg: GatewayInboundEnvelope["message"];
|
|
103
161
|
channel: ChannelAdapter;
|
|
162
|
+
/** Per-arrival turnId; preserved through merge so transcript can record dropped/dispatched correctly. */
|
|
163
|
+
turnId: string;
|
|
104
164
|
}
|
|
105
165
|
|
|
106
166
|
interface QueueState {
|
|
@@ -148,6 +208,11 @@ export class Dispatcher {
|
|
|
148
208
|
private readonly onOutbound?: OutboundObserver;
|
|
149
209
|
private readonly composeUserTurn?: UserTurnBuilder;
|
|
150
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;
|
|
151
216
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
152
217
|
|
|
153
218
|
constructor(opts: DispatcherOptions) {
|
|
@@ -162,21 +227,32 @@ export class Dispatcher {
|
|
|
162
227
|
this.onOutbound = opts.onOutbound;
|
|
163
228
|
this.composeUserTurn = opts.composeUserTurn;
|
|
164
229
|
this.managedRoutes = opts.managedRoutes;
|
|
230
|
+
this.attentionGate = opts.attentionGate;
|
|
231
|
+
this.resolveHubUrl = opts.resolveHubUrl;
|
|
232
|
+
this.transcript = opts.transcript ?? NOOP_TRANSCRIPT;
|
|
165
233
|
}
|
|
166
234
|
|
|
167
235
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
168
236
|
async handle(envelope: GatewayInboundEnvelope): Promise<void> {
|
|
169
237
|
const msg = envelope.message;
|
|
170
238
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
});
|
|
175
251
|
await this.safeAck(envelope);
|
|
176
252
|
return;
|
|
177
253
|
}
|
|
178
254
|
|
|
179
|
-
//
|
|
255
|
+
// Pre-skip: echo from the agent itself (own agent output looped back).
|
|
180
256
|
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
181
257
|
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
182
258
|
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
@@ -185,6 +261,18 @@ export class Dispatcher {
|
|
|
185
261
|
return;
|
|
186
262
|
}
|
|
187
263
|
|
|
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
|
+
|
|
188
276
|
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
189
277
|
const route = resolveRoute(msg, this.config, managed);
|
|
190
278
|
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
@@ -196,6 +284,7 @@ export class Dispatcher {
|
|
|
196
284
|
// the full coalesced batch instead of any single arrival), so calling
|
|
197
285
|
// the composer here would just be redundant work.
|
|
198
286
|
let text = rawText;
|
|
287
|
+
let composeFailedError: string | undefined;
|
|
199
288
|
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
200
289
|
try {
|
|
201
290
|
const composed = this.composeUserTurn(msg);
|
|
@@ -203,9 +292,10 @@ export class Dispatcher {
|
|
|
203
292
|
text = composed;
|
|
204
293
|
}
|
|
205
294
|
} catch (err) {
|
|
295
|
+
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
206
296
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
207
297
|
messageId: msg.id,
|
|
208
|
-
error:
|
|
298
|
+
error: composeFailedError,
|
|
209
299
|
});
|
|
210
300
|
}
|
|
211
301
|
}
|
|
@@ -213,6 +303,10 @@ export class Dispatcher {
|
|
|
213
303
|
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
214
304
|
await this.safeAck(envelope);
|
|
215
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
|
+
|
|
216
310
|
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
217
311
|
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
218
312
|
if (this.onInbound) {
|
|
@@ -226,19 +320,58 @@ export class Dispatcher {
|
|
|
226
320
|
}
|
|
227
321
|
}
|
|
228
322
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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",
|
|
234
368
|
});
|
|
235
|
-
return;
|
|
236
369
|
}
|
|
237
370
|
|
|
238
371
|
if (mode === "cancel-previous") {
|
|
239
|
-
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
372
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
|
|
240
373
|
} else {
|
|
241
|
-
await this.runSerial(queueKey, route, text, msg, channel);
|
|
374
|
+
await this.runSerial(queueKey, route, text, msg, channel, turnId);
|
|
242
375
|
}
|
|
243
376
|
}
|
|
244
377
|
|
|
@@ -288,6 +421,7 @@ export class Dispatcher {
|
|
|
288
421
|
text: string,
|
|
289
422
|
msg: GatewayInboundEnvelope["message"],
|
|
290
423
|
channel: ChannelAdapter,
|
|
424
|
+
turnId: string,
|
|
291
425
|
): Promise<void> {
|
|
292
426
|
const q = this.getQueue(queueKey);
|
|
293
427
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
@@ -298,7 +432,19 @@ export class Dispatcher {
|
|
|
298
432
|
const prev = q.current;
|
|
299
433
|
if (prev) {
|
|
300
434
|
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
301
|
-
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));
|
|
302
448
|
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
303
449
|
await prev.done.catch(() => undefined);
|
|
304
450
|
}
|
|
@@ -307,9 +453,22 @@ export class Dispatcher {
|
|
|
307
453
|
// drop out silently — the newest turn is the only one that should run.
|
|
308
454
|
if (myGen !== q.cancelGen) {
|
|
309
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
|
+
});
|
|
310
469
|
return;
|
|
311
470
|
}
|
|
312
|
-
await this.runTurn(queueKey, route, text, msg, channel);
|
|
471
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
|
|
313
472
|
}
|
|
314
473
|
|
|
315
474
|
/**
|
|
@@ -336,9 +495,10 @@ export class Dispatcher {
|
|
|
336
495
|
_text: string,
|
|
337
496
|
msg: GatewayInboundEnvelope["message"],
|
|
338
497
|
channel: ChannelAdapter,
|
|
498
|
+
turnId: string,
|
|
339
499
|
): Promise<void> {
|
|
340
500
|
const q = this.getQueue(queueKey);
|
|
341
|
-
q.serialBuffer.push({ route, msg, channel });
|
|
501
|
+
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
342
502
|
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
343
503
|
const dropped = q.serialBuffer.shift()!;
|
|
344
504
|
this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
|
|
@@ -346,6 +506,16 @@ export class Dispatcher {
|
|
|
346
506
|
droppedMessageId: dropped.msg.id,
|
|
347
507
|
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
348
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
|
+
});
|
|
349
519
|
}
|
|
350
520
|
if (q.serialWorkerActive) return;
|
|
351
521
|
q.serialWorkerActive = true;
|
|
@@ -354,12 +524,33 @@ export class Dispatcher {
|
|
|
354
524
|
const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
|
|
355
525
|
const merged = this.mergeSerialBuffer(drained, queueKey);
|
|
356
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) : [];
|
|
357
546
|
await this.runTurn(
|
|
358
547
|
queueKey,
|
|
359
548
|
merged.route,
|
|
360
549
|
merged.text,
|
|
361
550
|
merged.msg,
|
|
362
551
|
merged.channel,
|
|
552
|
+
merged.turnId,
|
|
553
|
+
mergedFromTurnIds,
|
|
363
554
|
);
|
|
364
555
|
}
|
|
365
556
|
} finally {
|
|
@@ -384,6 +575,7 @@ export class Dispatcher {
|
|
|
384
575
|
text: string;
|
|
385
576
|
msg: GatewayInboundEnvelope["message"];
|
|
386
577
|
channel: ChannelAdapter;
|
|
578
|
+
turnId: string;
|
|
387
579
|
} | null {
|
|
388
580
|
if (entries.length === 0) return null;
|
|
389
581
|
if (entries.length === 1) {
|
|
@@ -393,6 +585,7 @@ export class Dispatcher {
|
|
|
393
585
|
text: this.recomposeUserTurn(only.msg),
|
|
394
586
|
msg: only.msg,
|
|
395
587
|
channel: only.channel,
|
|
588
|
+
turnId: only.turnId,
|
|
396
589
|
};
|
|
397
590
|
}
|
|
398
591
|
|
|
@@ -448,6 +641,7 @@ export class Dispatcher {
|
|
|
448
641
|
text: this.recomposeUserTurn(mergedMsg),
|
|
449
642
|
msg: mergedMsg,
|
|
450
643
|
channel: latest.channel,
|
|
644
|
+
turnId: latest.turnId,
|
|
451
645
|
};
|
|
452
646
|
}
|
|
453
647
|
|
|
@@ -478,6 +672,8 @@ export class Dispatcher {
|
|
|
478
672
|
text: string,
|
|
479
673
|
msg: GatewayInboundEnvelope["message"],
|
|
480
674
|
channel: ChannelAdapter,
|
|
675
|
+
turnId: string,
|
|
676
|
+
mergedFromTurnIds: string[],
|
|
481
677
|
): Promise<void> {
|
|
482
678
|
const q = this.getQueue(queueKey);
|
|
483
679
|
const controller = new AbortController();
|
|
@@ -496,9 +692,35 @@ export class Dispatcher {
|
|
|
496
692
|
const done = new Promise<void>((res) => {
|
|
497
693
|
resolveDone = res;
|
|
498
694
|
});
|
|
499
|
-
const slot: TurnSlot = {
|
|
695
|
+
const slot: TurnSlot = {
|
|
696
|
+
turnId,
|
|
697
|
+
controller,
|
|
698
|
+
timedOut: false,
|
|
699
|
+
snapshot,
|
|
700
|
+
done,
|
|
701
|
+
dispatchedAt: startedAt,
|
|
702
|
+
blocks: [],
|
|
703
|
+
};
|
|
500
704
|
q.current = slot;
|
|
501
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
|
+
|
|
502
724
|
// Hard-cap turn with a timeout.
|
|
503
725
|
const timer = setTimeout(() => {
|
|
504
726
|
slot.timedOut = true;
|
|
@@ -526,8 +748,18 @@ export class Dispatcher {
|
|
|
526
748
|
const traceId = msg.trace?.id;
|
|
527
749
|
const canStream =
|
|
528
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
|
+
};
|
|
529
760
|
const onBlock = canStream
|
|
530
761
|
? (block: StreamBlock) => {
|
|
762
|
+
recordBlock(block);
|
|
531
763
|
// Fire-and-forget: stream errors must not break the turn.
|
|
532
764
|
channel
|
|
533
765
|
.streamBlock!({
|
|
@@ -574,11 +806,13 @@ export class Dispatcher {
|
|
|
574
806
|
sessionId,
|
|
575
807
|
cwd: route.cwd,
|
|
576
808
|
accountId: msg.accountId,
|
|
809
|
+
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
577
810
|
extraArgs: route.extraArgs,
|
|
578
811
|
signal: controller.signal,
|
|
579
812
|
trustLevel,
|
|
580
813
|
systemContext,
|
|
581
814
|
onBlock,
|
|
815
|
+
gateway: route.gateway,
|
|
582
816
|
});
|
|
583
817
|
} catch (err) {
|
|
584
818
|
threw = err;
|
|
@@ -593,6 +827,11 @@ export class Dispatcher {
|
|
|
593
827
|
// until after the reply lets the new arrival trip our abort signal, and
|
|
594
828
|
// this check then drops us silently. Timed-out turns still fall through
|
|
595
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.
|
|
596
835
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
597
836
|
return;
|
|
598
837
|
}
|
|
@@ -616,6 +855,17 @@ export class Dispatcher {
|
|
|
616
855
|
const isOwnerChat = isOwnerChatRoom(msg);
|
|
617
856
|
|
|
618
857
|
if (slot.timedOut) {
|
|
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,
|
|
868
|
+
});
|
|
619
869
|
if (isOwnerChat) {
|
|
620
870
|
await this.sendReply(channel, {
|
|
621
871
|
channel: msg.channel,
|
|
@@ -637,19 +887,30 @@ export class Dispatcher {
|
|
|
637
887
|
}
|
|
638
888
|
|
|
639
889
|
if (threw) {
|
|
890
|
+
const errMsg = threw instanceof Error ? threw.message : String(threw);
|
|
640
891
|
this.log.error("dispatcher: runtime threw", {
|
|
641
892
|
queueKey,
|
|
642
893
|
runtime: route.runtime,
|
|
643
|
-
error:
|
|
894
|
+
error: errMsg,
|
|
895
|
+
});
|
|
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,
|
|
644
906
|
});
|
|
645
907
|
if (isOwnerChat) {
|
|
646
|
-
const shortMsg = threw instanceof Error ? threw.message : String(threw);
|
|
647
908
|
await this.sendReply(channel, {
|
|
648
909
|
channel: msg.channel,
|
|
649
910
|
accountId: msg.accountId,
|
|
650
911
|
conversationId: msg.conversation.id,
|
|
651
912
|
threadId: msg.conversation.threadId ?? null,
|
|
652
|
-
text: `⚠️ Runtime error: ${truncate(
|
|
913
|
+
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
653
914
|
replyTo: msg.id,
|
|
654
915
|
traceId: msg.trace?.id ?? null,
|
|
655
916
|
});
|
|
@@ -717,7 +978,23 @@ export class Dispatcher {
|
|
|
717
978
|
}
|
|
718
979
|
|
|
719
980
|
const replyText = (result.text || "").trim();
|
|
720
|
-
|
|
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
|
+
}
|
|
721
998
|
|
|
722
999
|
if (!isOwnerChat) {
|
|
723
1000
|
// Non-owner-chat rooms: result.text never goes out. The agent is
|
|
@@ -732,6 +1009,18 @@ export class Dispatcher {
|
|
|
732
1009
|
replyTextLen: replyText.length,
|
|
733
1010
|
},
|
|
734
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
|
+
});
|
|
735
1024
|
return;
|
|
736
1025
|
}
|
|
737
1026
|
|
|
@@ -742,7 +1031,7 @@ export class Dispatcher {
|
|
|
742
1031
|
return;
|
|
743
1032
|
}
|
|
744
1033
|
|
|
745
|
-
await this.sendReply(channel, {
|
|
1034
|
+
const sendResult = await this.sendReply(channel, {
|
|
746
1035
|
channel: msg.channel,
|
|
747
1036
|
accountId: msg.accountId,
|
|
748
1037
|
conversationId: msg.conversation.id,
|
|
@@ -751,6 +1040,18 @@ export class Dispatcher {
|
|
|
751
1040
|
replyTo: msg.id,
|
|
752
1041
|
traceId: msg.trace?.id ?? null,
|
|
753
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
|
+
});
|
|
754
1055
|
} finally {
|
|
755
1056
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
756
1057
|
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
@@ -765,16 +1066,17 @@ export class Dispatcher {
|
|
|
765
1066
|
private async sendReply(
|
|
766
1067
|
channel: ChannelAdapter,
|
|
767
1068
|
outbound: GatewayOutboundMessage,
|
|
768
|
-
): Promise<
|
|
1069
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
769
1070
|
try {
|
|
770
1071
|
await channel.send({ message: outbound, log: this.log });
|
|
771
1072
|
} catch (err) {
|
|
1073
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
772
1074
|
this.log.warn("dispatcher: channel.send failed", {
|
|
773
1075
|
channel: outbound.channel,
|
|
774
1076
|
conversationId: outbound.conversationId,
|
|
775
|
-
error
|
|
1077
|
+
error,
|
|
776
1078
|
});
|
|
777
|
-
return;
|
|
1079
|
+
return { ok: false, error };
|
|
778
1080
|
}
|
|
779
1081
|
if (this.onOutbound) {
|
|
780
1082
|
try {
|
|
@@ -786,7 +1088,73 @@ export class Dispatcher {
|
|
|
786
1088
|
});
|
|
787
1089
|
}
|
|
788
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);
|
|
789
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);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function nowIso(): string {
|
|
1157
|
+
return new Date().toISOString();
|
|
790
1158
|
}
|
|
791
1159
|
|
|
792
1160
|
function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
|