@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,6 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { resolveRoute } from "./router.js";
|
|
2
3
|
import { sessionKey } from "./session-store.js";
|
|
4
|
+
import { truncateTextField, } from "./transcript.js";
|
|
3
5
|
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
|
|
8
|
+
* `result.text` forwarded to the channel; in every other room the runtime's
|
|
9
|
+
* plain text output is discarded — agents must use the `botcord_send` tool
|
|
10
|
+
* (or `botcord send` CLI via Bash) to actually deliver replies.
|
|
11
|
+
*/
|
|
12
|
+
const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
|
|
13
|
+
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
14
|
+
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
15
|
+
/**
|
|
16
|
+
* Soft cap on the total characters across raw.batch members in a merged
|
|
17
|
+
* turn. When exceeded, oldest entries are dropped (with a warn log) so the
|
|
18
|
+
* runtime prompt stays bounded even if the channel-side batch was huge.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_BATCH_BUFFER_CHARS = 16000;
|
|
21
|
+
/**
|
|
22
|
+
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
23
|
+
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
24
|
+
* `runTurn`'s finalize know NOT to write a `turn_error` — the supersede
|
|
25
|
+
* path already wrote a `dropped` record for the old turnId before the abort.
|
|
26
|
+
*/
|
|
27
|
+
class TurnSupersededError extends Error {
|
|
28
|
+
supersededBy;
|
|
29
|
+
constructor(supersededBy) {
|
|
30
|
+
super("turn superseded");
|
|
31
|
+
this.supersededBy = supersededBy;
|
|
32
|
+
this.name = "TurnSupersededError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const NOOP_TRANSCRIPT = {
|
|
36
|
+
enabled: false,
|
|
37
|
+
rootDir: "",
|
|
38
|
+
write: () => { },
|
|
39
|
+
};
|
|
4
40
|
/**
|
|
5
41
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
6
42
|
* turn per message, respecting queue mode, trust level, streaming, and
|
|
@@ -23,6 +59,9 @@ export class Dispatcher {
|
|
|
23
59
|
onOutbound;
|
|
24
60
|
composeUserTurn;
|
|
25
61
|
managedRoutes;
|
|
62
|
+
attentionGate;
|
|
63
|
+
resolveHubUrl;
|
|
64
|
+
transcript;
|
|
26
65
|
queues = new Map();
|
|
27
66
|
constructor(opts) {
|
|
28
67
|
this.config = opts.config;
|
|
@@ -36,18 +75,28 @@ export class Dispatcher {
|
|
|
36
75
|
this.onOutbound = opts.onOutbound;
|
|
37
76
|
this.composeUserTurn = opts.composeUserTurn;
|
|
38
77
|
this.managedRoutes = opts.managedRoutes;
|
|
78
|
+
this.attentionGate = opts.attentionGate;
|
|
79
|
+
this.resolveHubUrl = opts.resolveHubUrl;
|
|
80
|
+
this.transcript = opts.transcript ?? NOOP_TRANSCRIPT;
|
|
39
81
|
}
|
|
40
82
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
41
83
|
async handle(envelope) {
|
|
42
84
|
const msg = envelope.message;
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
85
|
+
// ---- Pre-skip branches: NEVER write a transcript record (design §3.2).
|
|
86
|
+
// Order matters: unknown channel → own echo → empty text. Each ack's the
|
|
87
|
+
// envelope (when applicable) and returns silently with only a debug/warn
|
|
88
|
+
// line in the daemon log.
|
|
89
|
+
// Pre-skip: unknown channel — configuration error, not a conversation event.
|
|
90
|
+
const channel = this.channels.get(msg.channel);
|
|
91
|
+
if (!channel) {
|
|
92
|
+
this.log.warn("dispatcher: unknown channel for outbound reply", {
|
|
93
|
+
channel: msg.channel,
|
|
94
|
+
messageId: msg.id,
|
|
95
|
+
});
|
|
47
96
|
await this.safeAck(envelope);
|
|
48
97
|
return;
|
|
49
98
|
}
|
|
50
|
-
//
|
|
99
|
+
// Pre-skip: echo from the agent itself (own agent output looped back).
|
|
51
100
|
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
52
101
|
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
53
102
|
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
@@ -55,12 +104,28 @@ export class Dispatcher {
|
|
|
55
104
|
await this.safeAck(envelope);
|
|
56
105
|
return;
|
|
57
106
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
107
|
+
// Pre-skip: empty/whitespace text.
|
|
108
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
109
|
+
if (!rawText) {
|
|
110
|
+
this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
|
|
111
|
+
await this.safeAck(envelope);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// From here on, the inbound is a real conversation event — generate a
|
|
115
|
+
// turnId and write the inbound transcript record.
|
|
116
|
+
const turnId = randomUUID();
|
|
117
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
118
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
119
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
120
|
+
const queueKey = buildQueueKey(msg);
|
|
121
|
+
// Compose the final user-turn text only for cancel-previous mode, where
|
|
122
|
+
// the dispatcher consumes the pre-composed text directly. Serial mode
|
|
123
|
+
// re-runs the composer at drain time on the merged message (so it sees
|
|
124
|
+
// the full coalesced batch instead of any single arrival), so calling
|
|
125
|
+
// the composer here would just be redundant work.
|
|
62
126
|
let text = rawText;
|
|
63
|
-
|
|
127
|
+
let composeFailedError;
|
|
128
|
+
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
64
129
|
try {
|
|
65
130
|
const composed = this.composeUserTurn(msg);
|
|
66
131
|
if (typeof composed === "string" && composed.length > 0) {
|
|
@@ -68,18 +133,18 @@ export class Dispatcher {
|
|
|
68
133
|
}
|
|
69
134
|
}
|
|
70
135
|
catch (err) {
|
|
136
|
+
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
71
137
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
72
138
|
messageId: msg.id,
|
|
73
|
-
error:
|
|
139
|
+
error: composeFailedError,
|
|
74
140
|
});
|
|
75
141
|
}
|
|
76
142
|
}
|
|
77
|
-
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
78
|
-
const route = resolveRoute(msg, this.config, managed);
|
|
79
|
-
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
80
|
-
const queueKey = buildQueueKey(msg);
|
|
81
143
|
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
82
144
|
await this.safeAck(envelope);
|
|
145
|
+
// Inbound transcript record — always before observers / gates so we have a
|
|
146
|
+
// grounded turnId for any downstream attention_skipped / dropped / etc.
|
|
147
|
+
this.emitInbound(turnId, msg);
|
|
83
148
|
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
84
149
|
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
85
150
|
if (this.onInbound) {
|
|
@@ -93,19 +158,58 @@ export class Dispatcher {
|
|
|
93
158
|
});
|
|
94
159
|
}
|
|
95
160
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
161
|
+
// Attention gate (PR3, design §4.2). Inserted AFTER `onInbound` so the
|
|
162
|
+
// working-memory append + activity tracking still see the message — only
|
|
163
|
+
// the runtime turn is suppressed. Errors are treated as wake (fail-open)
|
|
164
|
+
// so a buggy gate cannot silence the agent.
|
|
165
|
+
if (this.attentionGate) {
|
|
166
|
+
let wake = true;
|
|
167
|
+
try {
|
|
168
|
+
const result = this.attentionGate(msg);
|
|
169
|
+
wake = result instanceof Promise ? await result : result;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
this.log.warn("dispatcher: attentionGate threw — waking", {
|
|
173
|
+
messageId: msg.id,
|
|
174
|
+
error: err instanceof Error ? err.message : String(err),
|
|
175
|
+
});
|
|
176
|
+
wake = true;
|
|
177
|
+
}
|
|
178
|
+
if (!wake) {
|
|
179
|
+
this.log.debug("dispatcher skip turn: attention policy", {
|
|
180
|
+
messageId: msg.id,
|
|
181
|
+
accountId: msg.accountId,
|
|
182
|
+
conversationId: msg.conversation.id,
|
|
183
|
+
});
|
|
184
|
+
this.transcript.write({
|
|
185
|
+
ts: nowIso(),
|
|
186
|
+
kind: "attention_skipped",
|
|
187
|
+
turnId,
|
|
188
|
+
agentId: msg.accountId,
|
|
189
|
+
roomId: msg.conversation.id,
|
|
190
|
+
topicId: msg.conversation.threadId ?? null,
|
|
191
|
+
reason: "attention_gate_false",
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (composeFailedError) {
|
|
197
|
+
this.transcript.write({
|
|
198
|
+
ts: nowIso(),
|
|
199
|
+
kind: "compose_failed",
|
|
200
|
+
turnId,
|
|
201
|
+
agentId: msg.accountId,
|
|
202
|
+
roomId: msg.conversation.id,
|
|
203
|
+
topicId: msg.conversation.threadId ?? null,
|
|
204
|
+
error: composeFailedError,
|
|
205
|
+
fallback: "raw_text",
|
|
101
206
|
});
|
|
102
|
-
return;
|
|
103
207
|
}
|
|
104
208
|
if (mode === "cancel-previous") {
|
|
105
|
-
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
209
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
|
|
106
210
|
}
|
|
107
211
|
else {
|
|
108
|
-
await this.runSerial(queueKey, route, text, msg, channel);
|
|
212
|
+
await this.runSerial(queueKey, route, text, msg, channel, turnId);
|
|
109
213
|
}
|
|
110
214
|
}
|
|
111
215
|
/** Snapshot of currently running turns keyed by queue key. */
|
|
@@ -139,14 +243,15 @@ export class Dispatcher {
|
|
|
139
243
|
if (!q) {
|
|
140
244
|
q = {
|
|
141
245
|
current: null,
|
|
142
|
-
tail: Promise.resolve(),
|
|
143
246
|
cancelGen: 0,
|
|
247
|
+
serialBuffer: [],
|
|
248
|
+
serialWorkerActive: false,
|
|
144
249
|
};
|
|
145
250
|
this.queues.set(key, q);
|
|
146
251
|
}
|
|
147
252
|
return q;
|
|
148
253
|
}
|
|
149
|
-
async runCancelPrevious(queueKey, route, text, msg, channel) {
|
|
254
|
+
async runCancelPrevious(queueKey, route, text, msg, channel, turnId) {
|
|
150
255
|
const q = this.getQueue(queueKey);
|
|
151
256
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
152
257
|
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
@@ -156,7 +261,19 @@ export class Dispatcher {
|
|
|
156
261
|
const prev = q.current;
|
|
157
262
|
if (prev) {
|
|
158
263
|
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
159
|
-
prev
|
|
264
|
+
// Record the supersede BEFORE aborting so the prev turn's finalize sees
|
|
265
|
+
// the abort reason (TurnSupersededError) and skips writing turn_error.
|
|
266
|
+
this.transcript.write({
|
|
267
|
+
ts: nowIso(),
|
|
268
|
+
kind: "dropped",
|
|
269
|
+
turnId: prev.turnId,
|
|
270
|
+
agentId: msg.accountId,
|
|
271
|
+
roomId: msg.conversation.id,
|
|
272
|
+
topicId: msg.conversation.threadId ?? null,
|
|
273
|
+
reason: "queue_cancel_previous",
|
|
274
|
+
supersededBy: turnId,
|
|
275
|
+
});
|
|
276
|
+
prev.controller.abort(new TurnSupersededError(turnId));
|
|
160
277
|
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
161
278
|
await prev.done.catch(() => undefined);
|
|
162
279
|
}
|
|
@@ -165,18 +282,194 @@ export class Dispatcher {
|
|
|
165
282
|
// drop out silently — the newest turn is the only one that should run.
|
|
166
283
|
if (myGen !== q.cancelGen) {
|
|
167
284
|
this.log.info("dispatcher: cancel-previous superseded", { queueKey });
|
|
285
|
+
// We didn't run the turn; emit dropped so the caller's inbound has a
|
|
286
|
+
// matching path record. supersededBy is unknown at this layer (newer
|
|
287
|
+
// arrival owns its own bump) — leave null.
|
|
288
|
+
this.transcript.write({
|
|
289
|
+
ts: nowIso(),
|
|
290
|
+
kind: "dropped",
|
|
291
|
+
turnId,
|
|
292
|
+
agentId: msg.accountId,
|
|
293
|
+
roomId: msg.conversation.id,
|
|
294
|
+
topicId: msg.conversation.threadId ?? null,
|
|
295
|
+
reason: "queue_cancel_previous",
|
|
296
|
+
supersededBy: null,
|
|
297
|
+
});
|
|
168
298
|
return;
|
|
169
299
|
}
|
|
170
|
-
await this.runTurn(queueKey, route, text, msg, channel);
|
|
300
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
|
|
171
301
|
}
|
|
172
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Serial mode with coalesce-on-drain semantics:
|
|
304
|
+
*
|
|
305
|
+
* 1. First arrival on an idle queue boots the worker, which dispatches a
|
|
306
|
+
* single-message turn immediately (no batching delay).
|
|
307
|
+
* 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
|
|
308
|
+
* worker finishes the current turn it drains the entire buffer and
|
|
309
|
+
* merges all pending entries into ONE next turn (folded into a single
|
|
310
|
+
* `raw.batch` so the composer renders them as multi-block input).
|
|
311
|
+
* 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
|
|
312
|
+
* (drop oldest) and merged turns are further trimmed to fit
|
|
313
|
+
* `MAX_BATCH_BUFFER_CHARS` of total raw text.
|
|
314
|
+
*
|
|
315
|
+
* Note: the pre-composed `text` from `handle()` is intentionally discarded
|
|
316
|
+
* here — at drain time the worker re-invokes `composeUserTurn` on the
|
|
317
|
+
* merged message so the runtime sees a single coherent prompt covering all
|
|
318
|
+
* coalesced messages.
|
|
319
|
+
*/
|
|
320
|
+
async runSerial(queueKey, route, _text, msg, channel, turnId) {
|
|
173
321
|
const q = this.getQueue(queueKey);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
322
|
+
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
323
|
+
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
324
|
+
const dropped = q.serialBuffer.shift();
|
|
325
|
+
this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
|
|
326
|
+
queueKey,
|
|
327
|
+
droppedMessageId: dropped.msg.id,
|
|
328
|
+
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
329
|
+
});
|
|
330
|
+
this.transcript.write({
|
|
331
|
+
ts: nowIso(),
|
|
332
|
+
kind: "dropped",
|
|
333
|
+
turnId: dropped.turnId,
|
|
334
|
+
agentId: dropped.msg.accountId,
|
|
335
|
+
roomId: dropped.msg.conversation.id,
|
|
336
|
+
topicId: dropped.msg.conversation.threadId ?? null,
|
|
337
|
+
reason: "queue_overflow",
|
|
338
|
+
supersededBy: null,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (q.serialWorkerActive)
|
|
342
|
+
return;
|
|
343
|
+
q.serialWorkerActive = true;
|
|
344
|
+
try {
|
|
345
|
+
while (q.serialBuffer.length > 0) {
|
|
346
|
+
const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
|
|
347
|
+
const merged = this.mergeSerialBuffer(drained, queueKey);
|
|
348
|
+
if (!merged)
|
|
349
|
+
continue;
|
|
350
|
+
// Drained entries other than the winner get a `batch_merged` dropped
|
|
351
|
+
// record now (winner is always the last entry — see mergeSerialBuffer).
|
|
352
|
+
if (drained.length > 1) {
|
|
353
|
+
for (let i = 0; i < drained.length - 1; i++) {
|
|
354
|
+
const lost = drained[i];
|
|
355
|
+
this.transcript.write({
|
|
356
|
+
ts: nowIso(),
|
|
357
|
+
kind: "dropped",
|
|
358
|
+
turnId: lost.turnId,
|
|
359
|
+
agentId: lost.msg.accountId,
|
|
360
|
+
roomId: lost.msg.conversation.id,
|
|
361
|
+
topicId: lost.msg.conversation.threadId ?? null,
|
|
362
|
+
reason: "batch_merged",
|
|
363
|
+
supersededBy: merged.turnId,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const mergedFromTurnIds = drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
|
|
368
|
+
await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedFromTurnIds);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
q.serialWorkerActive = false;
|
|
373
|
+
}
|
|
178
374
|
}
|
|
179
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Merge buffered serial entries into a single dispatchable unit. With one
|
|
377
|
+
* entry the call is a near no-op (just recompose). With ≥2 entries this
|
|
378
|
+
* flattens any per-entry `raw.batch` (the BotCord channel already groups
|
|
379
|
+
* one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
|
|
380
|
+
* applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
|
|
381
|
+
* messages, and then synthesizes a merged inbound message anchored on the
|
|
382
|
+
* latest entry's metadata (mentioned = OR across all entries).
|
|
383
|
+
*/
|
|
384
|
+
mergeSerialBuffer(entries, queueKey) {
|
|
385
|
+
if (entries.length === 0)
|
|
386
|
+
return null;
|
|
387
|
+
if (entries.length === 1) {
|
|
388
|
+
const only = entries[0];
|
|
389
|
+
return {
|
|
390
|
+
route: only.route,
|
|
391
|
+
text: this.recomposeUserTurn(only.msg),
|
|
392
|
+
msg: only.msg,
|
|
393
|
+
channel: only.channel,
|
|
394
|
+
turnId: only.turnId,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Flatten: each entry's raw may already be a BatchedInboxRaw with
|
|
398
|
+
// `.batch`; otherwise it's a single InboxMessage we treat as a 1-element
|
|
399
|
+
// batch. Insertion order preserves chronology.
|
|
400
|
+
const items = [];
|
|
401
|
+
for (const e of entries) {
|
|
402
|
+
const raw = e.msg.raw;
|
|
403
|
+
const batch = raw && Array.isArray(raw.batch)
|
|
404
|
+
? (raw.batch)
|
|
405
|
+
: null;
|
|
406
|
+
if (batch) {
|
|
407
|
+
for (const m of batch)
|
|
408
|
+
items.push(m);
|
|
409
|
+
}
|
|
410
|
+
else if (raw) {
|
|
411
|
+
items.push(raw);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Char-cap: drop oldest until we fit. Reserve at least one item so we
|
|
415
|
+
// never produce an empty merged batch.
|
|
416
|
+
let totalChars = items.reduce((acc, m) => acc + (typeof m?.text === "string" ? m.text.length : 0), 0);
|
|
417
|
+
let droppedCount = 0;
|
|
418
|
+
while (totalChars > MAX_BATCH_BUFFER_CHARS && items.length > 1) {
|
|
419
|
+
const removed = items.shift();
|
|
420
|
+
totalChars -= typeof removed?.text === "string" ? removed.text.length : 0;
|
|
421
|
+
droppedCount += 1;
|
|
422
|
+
}
|
|
423
|
+
if (droppedCount > 0) {
|
|
424
|
+
this.log.warn("dispatcher: merged batch exceeded char cap — dropped oldest", {
|
|
425
|
+
queueKey,
|
|
426
|
+
droppedCount,
|
|
427
|
+
remaining: items.length,
|
|
428
|
+
totalChars,
|
|
429
|
+
charCap: MAX_BATCH_BUFFER_CHARS,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const latest = entries[entries.length - 1];
|
|
433
|
+
const latestRaw = latest.msg.raw ?? {};
|
|
434
|
+
const mergedRaw = { ...latestRaw, batch: items };
|
|
435
|
+
const anyMentioned = entries.some((e) => e.msg.mentioned === true);
|
|
436
|
+
const mergedMsg = {
|
|
437
|
+
...latest.msg,
|
|
438
|
+
mentioned: anyMentioned,
|
|
439
|
+
raw: mergedRaw,
|
|
440
|
+
};
|
|
441
|
+
return {
|
|
442
|
+
route: latest.route,
|
|
443
|
+
text: this.recomposeUserTurn(mergedMsg),
|
|
444
|
+
msg: mergedMsg,
|
|
445
|
+
channel: latest.channel,
|
|
446
|
+
turnId: latest.turnId,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Re-run the user-turn composer at drain time. Mirrors the logic in
|
|
451
|
+
* `handle()` but operates on the (possibly merged) message. Falls back to
|
|
452
|
+
* raw trimmed text on composer failure so a buggy composer never drops a
|
|
453
|
+
* turn.
|
|
454
|
+
*/
|
|
455
|
+
recomposeUserTurn(msg) {
|
|
456
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
457
|
+
if (!this.composeUserTurn)
|
|
458
|
+
return rawText;
|
|
459
|
+
try {
|
|
460
|
+
const composed = this.composeUserTurn(msg);
|
|
461
|
+
if (typeof composed === "string" && composed.length > 0)
|
|
462
|
+
return composed;
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
this.log.warn("dispatcher: composeUserTurn (drain) threw — using raw text", {
|
|
466
|
+
messageId: msg.id,
|
|
467
|
+
error: err instanceof Error ? err.message : String(err),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return rawText;
|
|
471
|
+
}
|
|
472
|
+
async runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds) {
|
|
180
473
|
const q = this.getQueue(queueKey);
|
|
181
474
|
const controller = new AbortController();
|
|
182
475
|
const startedAt = Date.now();
|
|
@@ -193,8 +486,35 @@ export class Dispatcher {
|
|
|
193
486
|
const done = new Promise((res) => {
|
|
194
487
|
resolveDone = res;
|
|
195
488
|
});
|
|
196
|
-
const slot = {
|
|
489
|
+
const slot = {
|
|
490
|
+
turnId,
|
|
491
|
+
controller,
|
|
492
|
+
timedOut: false,
|
|
493
|
+
snapshot,
|
|
494
|
+
done,
|
|
495
|
+
dispatchedAt: startedAt,
|
|
496
|
+
blocks: [],
|
|
497
|
+
};
|
|
197
498
|
q.current = slot;
|
|
499
|
+
// Dispatched record — marks "this turn entered runtime".
|
|
500
|
+
{
|
|
501
|
+
const composedField = truncateTextField(text);
|
|
502
|
+
const dispatched = {
|
|
503
|
+
ts: nowIso(),
|
|
504
|
+
kind: "dispatched",
|
|
505
|
+
turnId,
|
|
506
|
+
agentId: msg.accountId,
|
|
507
|
+
roomId: msg.conversation.id,
|
|
508
|
+
topicId: msg.conversation.threadId ?? null,
|
|
509
|
+
composedText: composedField.text,
|
|
510
|
+
runtime: route.runtime,
|
|
511
|
+
};
|
|
512
|
+
if (mergedFromTurnIds.length > 0)
|
|
513
|
+
dispatched.mergedFromTurnIds = mergedFromTurnIds;
|
|
514
|
+
if (composedField.truncated)
|
|
515
|
+
dispatched.truncated = { composedText: true };
|
|
516
|
+
this.transcript.write(dispatched);
|
|
517
|
+
}
|
|
198
518
|
// Hard-cap turn with a timeout.
|
|
199
519
|
const timer = setTimeout(() => {
|
|
200
520
|
slot.timedOut = true;
|
|
@@ -220,8 +540,20 @@ export class Dispatcher {
|
|
|
220
540
|
const streamable = msg.trace?.streamable === true;
|
|
221
541
|
const traceId = msg.trace?.id;
|
|
222
542
|
const canStream = streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
543
|
+
const recordBlock = (block) => {
|
|
544
|
+
const summary = { type: block.kind };
|
|
545
|
+
const raw = block.raw;
|
|
546
|
+
if (raw && typeof raw === "object") {
|
|
547
|
+
if (typeof raw.text === "string")
|
|
548
|
+
summary.chars = raw.text.length;
|
|
549
|
+
if (typeof raw.name === "string")
|
|
550
|
+
summary.name = raw.name;
|
|
551
|
+
}
|
|
552
|
+
slot.blocks.push(summary);
|
|
553
|
+
};
|
|
223
554
|
const onBlock = canStream
|
|
224
555
|
? (block) => {
|
|
556
|
+
recordBlock(block);
|
|
225
557
|
// Fire-and-forget: stream errors must not break the turn.
|
|
226
558
|
channel
|
|
227
559
|
.streamBlock({
|
|
@@ -267,11 +599,13 @@ export class Dispatcher {
|
|
|
267
599
|
sessionId,
|
|
268
600
|
cwd: route.cwd,
|
|
269
601
|
accountId: msg.accountId,
|
|
602
|
+
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
270
603
|
extraArgs: route.extraArgs,
|
|
271
604
|
signal: controller.signal,
|
|
272
605
|
trustLevel,
|
|
273
606
|
systemContext,
|
|
274
607
|
onBlock,
|
|
608
|
+
gateway: route.gateway,
|
|
275
609
|
});
|
|
276
610
|
}
|
|
277
611
|
catch (err) {
|
|
@@ -287,37 +621,98 @@ export class Dispatcher {
|
|
|
287
621
|
// until after the reply lets the new arrival trip our abort signal, and
|
|
288
622
|
// this check then drops us silently. Timed-out turns still fall through
|
|
289
623
|
// to send their error reply.
|
|
624
|
+
//
|
|
625
|
+
// Note on transcript: the supersede path already wrote the `dropped`
|
|
626
|
+
// record from `runCancelPrevious` BEFORE aborting, so we MUST NOT also
|
|
627
|
+
// emit a `turn_error` here — that would violate the "exactly one
|
|
628
|
+
// terminal record per turnId" invariant.
|
|
290
629
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
291
630
|
return;
|
|
292
631
|
}
|
|
632
|
+
// Reply gating: only owner-chat rooms accept the runtime's plain text
|
|
633
|
+
// output as a delivered message. Every other room expects the agent to
|
|
634
|
+
// call the `botcord_send` tool (or `botcord send` CLI via Bash)
|
|
635
|
+
// explicitly; runtime text in those rooms is logged and dropped,
|
|
636
|
+
// including timeout / error notifications.
|
|
637
|
+
//
|
|
638
|
+
// Owner-chat is identified by either the `rm_oc_` room prefix OR
|
|
639
|
+
// `source_type === "dashboard_user_chat"` on the raw envelope — the
|
|
640
|
+
// same dual check used by `sender-classify.ts:classifyActivitySender`,
|
|
641
|
+
// so the dispatcher's reply gating stays in lock-step with the
|
|
642
|
+
// composer's owner-bypass.
|
|
643
|
+
//
|
|
644
|
+
// Side effect: `onOutbound` (loop-risk tracking) only fires when a
|
|
645
|
+
// reply actually leaves the dispatcher. In non-owner-chat rooms the
|
|
646
|
+
// expectation is that the agent's `botcord_send` tool calls do their
|
|
647
|
+
// own loop-risk accounting downstream.
|
|
648
|
+
const isOwnerChat = isOwnerChatRoom(msg);
|
|
293
649
|
if (slot.timedOut) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
650
|
+
this.transcript.write({
|
|
651
|
+
ts: nowIso(),
|
|
652
|
+
kind: "turn_error",
|
|
653
|
+
turnId,
|
|
654
|
+
agentId: msg.accountId,
|
|
655
|
+
roomId: msg.conversation.id,
|
|
656
|
+
topicId: msg.conversation.threadId ?? null,
|
|
657
|
+
phase: "timeout",
|
|
658
|
+
error: `runtime timeout after ${this.turnTimeoutMs}ms`,
|
|
659
|
+
durationMs: Date.now() - slot.dispatchedAt,
|
|
302
660
|
});
|
|
661
|
+
if (isOwnerChat) {
|
|
662
|
+
await this.sendReply(channel, {
|
|
663
|
+
channel: msg.channel,
|
|
664
|
+
accountId: msg.accountId,
|
|
665
|
+
conversationId: msg.conversation.id,
|
|
666
|
+
threadId: msg.conversation.threadId ?? null,
|
|
667
|
+
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
668
|
+
replyTo: msg.id,
|
|
669
|
+
traceId: msg.trace?.id ?? null,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
|
|
674
|
+
queueKey,
|
|
675
|
+
conversationId: msg.conversation.id,
|
|
676
|
+
timeoutMs: this.turnTimeoutMs,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
303
679
|
return;
|
|
304
680
|
}
|
|
305
681
|
if (threw) {
|
|
682
|
+
const errMsg = threw instanceof Error ? threw.message : String(threw);
|
|
306
683
|
this.log.error("dispatcher: runtime threw", {
|
|
307
684
|
queueKey,
|
|
308
685
|
runtime: route.runtime,
|
|
309
|
-
error:
|
|
686
|
+
error: errMsg,
|
|
310
687
|
});
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
688
|
+
this.transcript.write({
|
|
689
|
+
ts: nowIso(),
|
|
690
|
+
kind: "turn_error",
|
|
691
|
+
turnId,
|
|
692
|
+
agentId: msg.accountId,
|
|
693
|
+
roomId: msg.conversation.id,
|
|
694
|
+
topicId: msg.conversation.threadId ?? null,
|
|
695
|
+
phase: "runtime",
|
|
696
|
+
error: errMsg,
|
|
697
|
+
durationMs: Date.now() - slot.dispatchedAt,
|
|
320
698
|
});
|
|
699
|
+
if (isOwnerChat) {
|
|
700
|
+
await this.sendReply(channel, {
|
|
701
|
+
channel: msg.channel,
|
|
702
|
+
accountId: msg.accountId,
|
|
703
|
+
conversationId: msg.conversation.id,
|
|
704
|
+
threadId: msg.conversation.threadId ?? null,
|
|
705
|
+
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
706
|
+
replyTo: msg.id,
|
|
707
|
+
traceId: msg.trace?.id ?? null,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
|
|
712
|
+
queueKey,
|
|
713
|
+
conversationId: msg.conversation.id,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
321
716
|
return;
|
|
322
717
|
}
|
|
323
718
|
if (!result)
|
|
@@ -377,15 +772,53 @@ export class Dispatcher {
|
|
|
377
772
|
}
|
|
378
773
|
}
|
|
379
774
|
const replyText = (result.text || "").trim();
|
|
380
|
-
|
|
775
|
+
const finalTextField = truncateTextField(result.text || "");
|
|
776
|
+
if (!replyText) {
|
|
777
|
+
this.emitOutbound({
|
|
778
|
+
turnId,
|
|
779
|
+
msg,
|
|
780
|
+
runtime: route.runtime,
|
|
781
|
+
runtimeSessionId: result.newSessionId || null,
|
|
782
|
+
startedAt: slot.dispatchedAt,
|
|
783
|
+
costUsd: result.costUsd,
|
|
784
|
+
finalText: finalTextField,
|
|
785
|
+
deliveryStatus: "empty_text",
|
|
786
|
+
deliveryReason: null,
|
|
787
|
+
blocks: slot.blocks,
|
|
788
|
+
});
|
|
381
789
|
return;
|
|
790
|
+
}
|
|
791
|
+
if (!isOwnerChat) {
|
|
792
|
+
// Non-owner-chat rooms: result.text never goes out. The agent is
|
|
793
|
+
// expected to have used the `botcord_send` tool / `botcord send` CLI
|
|
794
|
+
// already; whatever it left in the runtime's final assistant text is
|
|
795
|
+
// discarded so it doesn't leak into the room.
|
|
796
|
+
this.log.debug("dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)", {
|
|
797
|
+
queueKey,
|
|
798
|
+
conversationId: msg.conversation.id,
|
|
799
|
+
replyTextLen: replyText.length,
|
|
800
|
+
});
|
|
801
|
+
this.emitOutbound({
|
|
802
|
+
turnId,
|
|
803
|
+
msg,
|
|
804
|
+
runtime: route.runtime,
|
|
805
|
+
runtimeSessionId: result.newSessionId || null,
|
|
806
|
+
startedAt: slot.dispatchedAt,
|
|
807
|
+
costUsd: result.costUsd,
|
|
808
|
+
finalText: finalTextField,
|
|
809
|
+
deliveryStatus: "gated_non_owner_chat",
|
|
810
|
+
deliveryReason: null,
|
|
811
|
+
blocks: slot.blocks,
|
|
812
|
+
});
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
382
815
|
// One last abort check immediately before the send. Narrows the window
|
|
383
816
|
// in which a cancel-previous arriving during session-store.set could
|
|
384
817
|
// still slip a stale reply past us.
|
|
385
818
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
386
819
|
return;
|
|
387
820
|
}
|
|
388
|
-
await this.sendReply(channel, {
|
|
821
|
+
const sendResult = await this.sendReply(channel, {
|
|
389
822
|
channel: msg.channel,
|
|
390
823
|
accountId: msg.accountId,
|
|
391
824
|
conversationId: msg.conversation.id,
|
|
@@ -394,6 +827,18 @@ export class Dispatcher {
|
|
|
394
827
|
replyTo: msg.id,
|
|
395
828
|
traceId: msg.trace?.id ?? null,
|
|
396
829
|
});
|
|
830
|
+
this.emitOutbound({
|
|
831
|
+
turnId,
|
|
832
|
+
msg,
|
|
833
|
+
runtime: route.runtime,
|
|
834
|
+
runtimeSessionId: result.newSessionId || null,
|
|
835
|
+
startedAt: slot.dispatchedAt,
|
|
836
|
+
costUsd: result.costUsd,
|
|
837
|
+
finalText: finalTextField,
|
|
838
|
+
deliveryStatus: sendResult.ok ? "delivered" : "send_failed",
|
|
839
|
+
deliveryReason: sendResult.ok ? null : sendResult.error,
|
|
840
|
+
blocks: slot.blocks,
|
|
841
|
+
});
|
|
397
842
|
}
|
|
398
843
|
finally {
|
|
399
844
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
@@ -411,12 +856,13 @@ export class Dispatcher {
|
|
|
411
856
|
await channel.send({ message: outbound, log: this.log });
|
|
412
857
|
}
|
|
413
858
|
catch (err) {
|
|
859
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
414
860
|
this.log.warn("dispatcher: channel.send failed", {
|
|
415
861
|
channel: outbound.channel,
|
|
416
862
|
conversationId: outbound.conversationId,
|
|
417
|
-
error
|
|
863
|
+
error,
|
|
418
864
|
});
|
|
419
|
-
return;
|
|
865
|
+
return { ok: false, error };
|
|
420
866
|
}
|
|
421
867
|
if (this.onOutbound) {
|
|
422
868
|
try {
|
|
@@ -429,12 +875,93 @@ export class Dispatcher {
|
|
|
429
875
|
});
|
|
430
876
|
}
|
|
431
877
|
}
|
|
878
|
+
return { ok: true };
|
|
432
879
|
}
|
|
880
|
+
emitInbound(turnId, msg) {
|
|
881
|
+
if (!this.transcript.enabled)
|
|
882
|
+
return;
|
|
883
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
884
|
+
const tField = truncateTextField(rawText);
|
|
885
|
+
const raw = msg.raw;
|
|
886
|
+
const batch = raw && typeof raw === "object" && Array.isArray(raw.batch)
|
|
887
|
+
? raw.batch.length
|
|
888
|
+
: undefined;
|
|
889
|
+
const rec = {
|
|
890
|
+
ts: nowIso(),
|
|
891
|
+
kind: "inbound",
|
|
892
|
+
turnId,
|
|
893
|
+
agentId: msg.accountId,
|
|
894
|
+
roomId: msg.conversation.id,
|
|
895
|
+
topicId: msg.conversation.threadId ?? null,
|
|
896
|
+
messageId: msg.id,
|
|
897
|
+
sender: { id: msg.sender.id, kind: msg.sender.kind, ...(msg.sender.name ? { name: msg.sender.name } : {}) },
|
|
898
|
+
text: tField.text,
|
|
899
|
+
};
|
|
900
|
+
if (batch !== undefined && batch > 1)
|
|
901
|
+
rec.rawBatchEntries = batch;
|
|
902
|
+
if (msg.trace?.id) {
|
|
903
|
+
rec.trace = { id: msg.trace.id, ...(msg.trace.streamable ? { streamable: true } : {}) };
|
|
904
|
+
}
|
|
905
|
+
if (tField.truncated)
|
|
906
|
+
rec.truncated = { text: true };
|
|
907
|
+
this.transcript.write(rec);
|
|
908
|
+
}
|
|
909
|
+
emitOutbound(args) {
|
|
910
|
+
if (!this.transcript.enabled)
|
|
911
|
+
return;
|
|
912
|
+
const rec = {
|
|
913
|
+
ts: nowIso(),
|
|
914
|
+
kind: "outbound",
|
|
915
|
+
turnId: args.turnId,
|
|
916
|
+
agentId: args.msg.accountId,
|
|
917
|
+
roomId: args.msg.conversation.id,
|
|
918
|
+
topicId: args.msg.conversation.threadId ?? null,
|
|
919
|
+
runtime: args.runtime,
|
|
920
|
+
runtimeSessionId: args.runtimeSessionId,
|
|
921
|
+
durationMs: Date.now() - args.startedAt,
|
|
922
|
+
finalText: args.finalText.text,
|
|
923
|
+
deliveryStatus: args.deliveryStatus,
|
|
924
|
+
deliveryReason: args.deliveryReason,
|
|
925
|
+
};
|
|
926
|
+
if (typeof args.costUsd === "number")
|
|
927
|
+
rec.costUsd = args.costUsd;
|
|
928
|
+
if (args.blocks.length > 0)
|
|
929
|
+
rec.blocks = args.blocks;
|
|
930
|
+
if (args.finalText.truncated)
|
|
931
|
+
rec.truncated = { finalText: true };
|
|
932
|
+
this.transcript.write(rec);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function nowIso() {
|
|
936
|
+
return new Date().toISOString();
|
|
433
937
|
}
|
|
434
938
|
function buildQueueKey(msg) {
|
|
435
939
|
const thread = msg.conversation.threadId ?? "";
|
|
436
940
|
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|
|
437
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* Owner-chat predicate used by the dispatcher's reply gating. Matches the
|
|
944
|
+
* dual check in `sender-classify.ts:classifyActivitySender` so the
|
|
945
|
+
* dispatcher's gate stays consistent with the composer's owner-bypass:
|
|
946
|
+
*
|
|
947
|
+
* 1. `rm_oc_*` room id, OR
|
|
948
|
+
* 2. `source_type === "dashboard_user_chat"` on the raw envelope.
|
|
949
|
+
*
|
|
950
|
+
* The latter exists because the dashboard's user-chat surface can route
|
|
951
|
+
* messages through non-`rm_oc_` rooms in some flows; treating them as
|
|
952
|
+
* owner-trust here keeps the agent's plain reply text reachable.
|
|
953
|
+
*/
|
|
954
|
+
function isOwnerChatRoom(msg) {
|
|
955
|
+
if (msg.conversation.id.startsWith(OWNER_CHAT_ROOM_PREFIX))
|
|
956
|
+
return true;
|
|
957
|
+
const raw = msg.raw;
|
|
958
|
+
if (raw && typeof raw === "object") {
|
|
959
|
+
const sourceType = raw.source_type;
|
|
960
|
+
if (sourceType === "dashboard_user_chat")
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
438
965
|
function resolveQueueMode(route, kind) {
|
|
439
966
|
if (route.queueMode)
|
|
440
967
|
return route.queueMode;
|