@botcord/daemon 0.2.5 → 0.2.8
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 +64 -1
- package/dist/config.js +73 -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 +76 -6
- 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 +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- 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 +3 -2
- 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__/openclaw-discovery.test.ts +150 -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 +265 -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 +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- 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 +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -1,5 +1,7 @@
|
|
|
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;
|
|
4
6
|
/**
|
|
5
7
|
* Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
|
|
@@ -16,6 +18,25 @@ const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
|
16
18
|
* runtime prompt stays bounded even if the channel-side batch was huge.
|
|
17
19
|
*/
|
|
18
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
|
+
};
|
|
19
40
|
/**
|
|
20
41
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
21
42
|
* turn per message, respecting queue mode, trust level, streaming, and
|
|
@@ -38,6 +59,9 @@ export class Dispatcher {
|
|
|
38
59
|
onOutbound;
|
|
39
60
|
composeUserTurn;
|
|
40
61
|
managedRoutes;
|
|
62
|
+
attentionGate;
|
|
63
|
+
resolveHubUrl;
|
|
64
|
+
transcript;
|
|
41
65
|
queues = new Map();
|
|
42
66
|
constructor(opts) {
|
|
43
67
|
this.config = opts.config;
|
|
@@ -51,18 +75,28 @@ export class Dispatcher {
|
|
|
51
75
|
this.onOutbound = opts.onOutbound;
|
|
52
76
|
this.composeUserTurn = opts.composeUserTurn;
|
|
53
77
|
this.managedRoutes = opts.managedRoutes;
|
|
78
|
+
this.attentionGate = opts.attentionGate;
|
|
79
|
+
this.resolveHubUrl = opts.resolveHubUrl;
|
|
80
|
+
this.transcript = opts.transcript ?? NOOP_TRANSCRIPT;
|
|
54
81
|
}
|
|
55
82
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
56
83
|
async handle(envelope) {
|
|
57
84
|
const msg = envelope.message;
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
});
|
|
62
96
|
await this.safeAck(envelope);
|
|
63
97
|
return;
|
|
64
98
|
}
|
|
65
|
-
//
|
|
99
|
+
// Pre-skip: echo from the agent itself (own agent output looped back).
|
|
66
100
|
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
67
101
|
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
68
102
|
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
@@ -70,6 +104,16 @@ export class Dispatcher {
|
|
|
70
104
|
await this.safeAck(envelope);
|
|
71
105
|
return;
|
|
72
106
|
}
|
|
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();
|
|
73
117
|
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
74
118
|
const route = resolveRoute(msg, this.config, managed);
|
|
75
119
|
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
@@ -80,6 +124,7 @@ export class Dispatcher {
|
|
|
80
124
|
// the full coalesced batch instead of any single arrival), so calling
|
|
81
125
|
// the composer here would just be redundant work.
|
|
82
126
|
let text = rawText;
|
|
127
|
+
let composeFailedError;
|
|
83
128
|
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
84
129
|
try {
|
|
85
130
|
const composed = this.composeUserTurn(msg);
|
|
@@ -88,14 +133,18 @@ export class Dispatcher {
|
|
|
88
133
|
}
|
|
89
134
|
}
|
|
90
135
|
catch (err) {
|
|
136
|
+
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
91
137
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
92
138
|
messageId: msg.id,
|
|
93
|
-
error:
|
|
139
|
+
error: composeFailedError,
|
|
94
140
|
});
|
|
95
141
|
}
|
|
96
142
|
}
|
|
97
143
|
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
98
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);
|
|
99
148
|
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
100
149
|
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
101
150
|
if (this.onInbound) {
|
|
@@ -109,19 +158,58 @@ export class Dispatcher {
|
|
|
109
158
|
});
|
|
110
159
|
}
|
|
111
160
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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",
|
|
117
206
|
});
|
|
118
|
-
return;
|
|
119
207
|
}
|
|
120
208
|
if (mode === "cancel-previous") {
|
|
121
|
-
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
209
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
|
|
122
210
|
}
|
|
123
211
|
else {
|
|
124
|
-
await this.runSerial(queueKey, route, text, msg, channel);
|
|
212
|
+
await this.runSerial(queueKey, route, text, msg, channel, turnId);
|
|
125
213
|
}
|
|
126
214
|
}
|
|
127
215
|
/** Snapshot of currently running turns keyed by queue key. */
|
|
@@ -163,7 +251,7 @@ export class Dispatcher {
|
|
|
163
251
|
}
|
|
164
252
|
return q;
|
|
165
253
|
}
|
|
166
|
-
async runCancelPrevious(queueKey, route, text, msg, channel) {
|
|
254
|
+
async runCancelPrevious(queueKey, route, text, msg, channel, turnId) {
|
|
167
255
|
const q = this.getQueue(queueKey);
|
|
168
256
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
169
257
|
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
@@ -173,7 +261,19 @@ export class Dispatcher {
|
|
|
173
261
|
const prev = q.current;
|
|
174
262
|
if (prev) {
|
|
175
263
|
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
176
|
-
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));
|
|
177
277
|
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
178
278
|
await prev.done.catch(() => undefined);
|
|
179
279
|
}
|
|
@@ -182,9 +282,22 @@ export class Dispatcher {
|
|
|
182
282
|
// drop out silently — the newest turn is the only one that should run.
|
|
183
283
|
if (myGen !== q.cancelGen) {
|
|
184
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
|
+
});
|
|
185
298
|
return;
|
|
186
299
|
}
|
|
187
|
-
await this.runTurn(queueKey, route, text, msg, channel);
|
|
300
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
|
|
188
301
|
}
|
|
189
302
|
/**
|
|
190
303
|
* Serial mode with coalesce-on-drain semantics:
|
|
@@ -204,9 +317,9 @@ export class Dispatcher {
|
|
|
204
317
|
* merged message so the runtime sees a single coherent prompt covering all
|
|
205
318
|
* coalesced messages.
|
|
206
319
|
*/
|
|
207
|
-
async runSerial(queueKey, route, _text, msg, channel) {
|
|
320
|
+
async runSerial(queueKey, route, _text, msg, channel, turnId) {
|
|
208
321
|
const q = this.getQueue(queueKey);
|
|
209
|
-
q.serialBuffer.push({ route, msg, channel });
|
|
322
|
+
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
210
323
|
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
211
324
|
const dropped = q.serialBuffer.shift();
|
|
212
325
|
this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
|
|
@@ -214,6 +327,16 @@ export class Dispatcher {
|
|
|
214
327
|
droppedMessageId: dropped.msg.id,
|
|
215
328
|
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
216
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
|
+
});
|
|
217
340
|
}
|
|
218
341
|
if (q.serialWorkerActive)
|
|
219
342
|
return;
|
|
@@ -224,7 +347,25 @@ export class Dispatcher {
|
|
|
224
347
|
const merged = this.mergeSerialBuffer(drained, queueKey);
|
|
225
348
|
if (!merged)
|
|
226
349
|
continue;
|
|
227
|
-
|
|
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);
|
|
228
369
|
}
|
|
229
370
|
}
|
|
230
371
|
finally {
|
|
@@ -250,6 +391,7 @@ export class Dispatcher {
|
|
|
250
391
|
text: this.recomposeUserTurn(only.msg),
|
|
251
392
|
msg: only.msg,
|
|
252
393
|
channel: only.channel,
|
|
394
|
+
turnId: only.turnId,
|
|
253
395
|
};
|
|
254
396
|
}
|
|
255
397
|
// Flatten: each entry's raw may already be a BatchedInboxRaw with
|
|
@@ -301,6 +443,7 @@ export class Dispatcher {
|
|
|
301
443
|
text: this.recomposeUserTurn(mergedMsg),
|
|
302
444
|
msg: mergedMsg,
|
|
303
445
|
channel: latest.channel,
|
|
446
|
+
turnId: latest.turnId,
|
|
304
447
|
};
|
|
305
448
|
}
|
|
306
449
|
/**
|
|
@@ -326,7 +469,7 @@ export class Dispatcher {
|
|
|
326
469
|
}
|
|
327
470
|
return rawText;
|
|
328
471
|
}
|
|
329
|
-
async runTurn(queueKey, route, text, msg, channel) {
|
|
472
|
+
async runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds) {
|
|
330
473
|
const q = this.getQueue(queueKey);
|
|
331
474
|
const controller = new AbortController();
|
|
332
475
|
const startedAt = Date.now();
|
|
@@ -343,8 +486,35 @@ export class Dispatcher {
|
|
|
343
486
|
const done = new Promise((res) => {
|
|
344
487
|
resolveDone = res;
|
|
345
488
|
});
|
|
346
|
-
const slot = {
|
|
489
|
+
const slot = {
|
|
490
|
+
turnId,
|
|
491
|
+
controller,
|
|
492
|
+
timedOut: false,
|
|
493
|
+
snapshot,
|
|
494
|
+
done,
|
|
495
|
+
dispatchedAt: startedAt,
|
|
496
|
+
blocks: [],
|
|
497
|
+
};
|
|
347
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
|
+
}
|
|
348
518
|
// Hard-cap turn with a timeout.
|
|
349
519
|
const timer = setTimeout(() => {
|
|
350
520
|
slot.timedOut = true;
|
|
@@ -370,8 +540,20 @@ export class Dispatcher {
|
|
|
370
540
|
const streamable = msg.trace?.streamable === true;
|
|
371
541
|
const traceId = msg.trace?.id;
|
|
372
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
|
+
};
|
|
373
554
|
const onBlock = canStream
|
|
374
555
|
? (block) => {
|
|
556
|
+
recordBlock(block);
|
|
375
557
|
// Fire-and-forget: stream errors must not break the turn.
|
|
376
558
|
channel
|
|
377
559
|
.streamBlock({
|
|
@@ -417,11 +599,13 @@ export class Dispatcher {
|
|
|
417
599
|
sessionId,
|
|
418
600
|
cwd: route.cwd,
|
|
419
601
|
accountId: msg.accountId,
|
|
602
|
+
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
420
603
|
extraArgs: route.extraArgs,
|
|
421
604
|
signal: controller.signal,
|
|
422
605
|
trustLevel,
|
|
423
606
|
systemContext,
|
|
424
607
|
onBlock,
|
|
608
|
+
gateway: route.gateway,
|
|
425
609
|
});
|
|
426
610
|
}
|
|
427
611
|
catch (err) {
|
|
@@ -437,6 +621,11 @@ export class Dispatcher {
|
|
|
437
621
|
// until after the reply lets the new arrival trip our abort signal, and
|
|
438
622
|
// this check then drops us silently. Timed-out turns still fall through
|
|
439
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.
|
|
440
629
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
441
630
|
return;
|
|
442
631
|
}
|
|
@@ -458,6 +647,17 @@ export class Dispatcher {
|
|
|
458
647
|
// own loop-risk accounting downstream.
|
|
459
648
|
const isOwnerChat = isOwnerChatRoom(msg);
|
|
460
649
|
if (slot.timedOut) {
|
|
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,
|
|
660
|
+
});
|
|
461
661
|
if (isOwnerChat) {
|
|
462
662
|
await this.sendReply(channel, {
|
|
463
663
|
channel: msg.channel,
|
|
@@ -479,19 +679,30 @@ export class Dispatcher {
|
|
|
479
679
|
return;
|
|
480
680
|
}
|
|
481
681
|
if (threw) {
|
|
682
|
+
const errMsg = threw instanceof Error ? threw.message : String(threw);
|
|
482
683
|
this.log.error("dispatcher: runtime threw", {
|
|
483
684
|
queueKey,
|
|
484
685
|
runtime: route.runtime,
|
|
485
|
-
error:
|
|
686
|
+
error: errMsg,
|
|
687
|
+
});
|
|
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,
|
|
486
698
|
});
|
|
487
699
|
if (isOwnerChat) {
|
|
488
|
-
const shortMsg = threw instanceof Error ? threw.message : String(threw);
|
|
489
700
|
await this.sendReply(channel, {
|
|
490
701
|
channel: msg.channel,
|
|
491
702
|
accountId: msg.accountId,
|
|
492
703
|
conversationId: msg.conversation.id,
|
|
493
704
|
threadId: msg.conversation.threadId ?? null,
|
|
494
|
-
text: `⚠️ Runtime error: ${truncate(
|
|
705
|
+
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
495
706
|
replyTo: msg.id,
|
|
496
707
|
traceId: msg.trace?.id ?? null,
|
|
497
708
|
});
|
|
@@ -561,8 +772,22 @@ export class Dispatcher {
|
|
|
561
772
|
}
|
|
562
773
|
}
|
|
563
774
|
const replyText = (result.text || "").trim();
|
|
564
|
-
|
|
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
|
+
});
|
|
565
789
|
return;
|
|
790
|
+
}
|
|
566
791
|
if (!isOwnerChat) {
|
|
567
792
|
// Non-owner-chat rooms: result.text never goes out. The agent is
|
|
568
793
|
// expected to have used the `botcord_send` tool / `botcord send` CLI
|
|
@@ -573,6 +798,18 @@ export class Dispatcher {
|
|
|
573
798
|
conversationId: msg.conversation.id,
|
|
574
799
|
replyTextLen: replyText.length,
|
|
575
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
|
+
});
|
|
576
813
|
return;
|
|
577
814
|
}
|
|
578
815
|
// One last abort check immediately before the send. Narrows the window
|
|
@@ -581,7 +818,7 @@ export class Dispatcher {
|
|
|
581
818
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
582
819
|
return;
|
|
583
820
|
}
|
|
584
|
-
await this.sendReply(channel, {
|
|
821
|
+
const sendResult = await this.sendReply(channel, {
|
|
585
822
|
channel: msg.channel,
|
|
586
823
|
accountId: msg.accountId,
|
|
587
824
|
conversationId: msg.conversation.id,
|
|
@@ -590,6 +827,18 @@ export class Dispatcher {
|
|
|
590
827
|
replyTo: msg.id,
|
|
591
828
|
traceId: msg.trace?.id ?? null,
|
|
592
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
|
+
});
|
|
593
842
|
}
|
|
594
843
|
finally {
|
|
595
844
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
@@ -607,12 +856,13 @@ export class Dispatcher {
|
|
|
607
856
|
await channel.send({ message: outbound, log: this.log });
|
|
608
857
|
}
|
|
609
858
|
catch (err) {
|
|
859
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
610
860
|
this.log.warn("dispatcher: channel.send failed", {
|
|
611
861
|
channel: outbound.channel,
|
|
612
862
|
conversationId: outbound.conversationId,
|
|
613
|
-
error
|
|
863
|
+
error,
|
|
614
864
|
});
|
|
615
|
-
return;
|
|
865
|
+
return { ok: false, error };
|
|
616
866
|
}
|
|
617
867
|
if (this.onOutbound) {
|
|
618
868
|
try {
|
|
@@ -625,7 +875,65 @@ export class Dispatcher {
|
|
|
625
875
|
});
|
|
626
876
|
}
|
|
627
877
|
}
|
|
878
|
+
return { ok: true };
|
|
628
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();
|
|
629
937
|
}
|
|
630
938
|
function buildQueueKey(msg) {
|
|
631
939
|
const thread = msg.conversation.threadId ?? "";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
2
2
|
import { type RuntimeFactory } from "./dispatcher.js";
|
|
3
3
|
import { type GatewayLogger } from "./log.js";
|
|
4
|
-
import
|
|
4
|
+
import { type TranscriptWriter } from "./transcript.js";
|
|
5
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
6
|
/** Constructor options for `Gateway`. */
|
|
6
7
|
export interface GatewayBootOptions {
|
|
7
8
|
config: GatewayConfig;
|
|
@@ -35,6 +36,33 @@ export interface GatewayBootOptions {
|
|
|
35
36
|
* bookkeeping like loop-risk tracking.
|
|
36
37
|
*/
|
|
37
38
|
onOutbound?: OutboundObserver;
|
|
39
|
+
/**
|
|
40
|
+
* Optional attention gate (PR3, design §4.2). Forwarded to the dispatcher
|
|
41
|
+
* verbatim — see {@link Dispatcher} for semantics. Returning `false` skips
|
|
42
|
+
* the runtime turn while preserving ack + onInbound side effects.
|
|
43
|
+
*/
|
|
44
|
+
attentionGate?: (message: GatewayInboundMessage) => Promise<boolean> | boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the per-agent hub URL for an inbound message. Forwarded to the
|
|
47
|
+
* dispatcher as `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
|
|
48
|
+
* (`BOTCORD_HUB`) target the correct hub for the owning agent.
|
|
49
|
+
*/
|
|
50
|
+
resolveHubUrl?: (accountId: string) => string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Persistent NDJSON transcript writer (design §3 / §6). Optional — when
|
|
53
|
+
* omitted the dispatcher uses a noop writer. Pass `transcriptEnabled` plus
|
|
54
|
+
* `transcriptRootDir` to let the gateway construct one for you.
|
|
55
|
+
*/
|
|
56
|
+
transcript?: TranscriptWriter;
|
|
57
|
+
/**
|
|
58
|
+
* Tri-state convenience: if `transcript` is not provided, the gateway
|
|
59
|
+
* constructs a writer using this flag plus `transcriptRootDir`. Use
|
|
60
|
+
* {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
|
|
61
|
+
* the persistent daemon-config flag.
|
|
62
|
+
*/
|
|
63
|
+
transcriptEnabled?: boolean;
|
|
64
|
+
/** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
|
|
65
|
+
transcriptRootDir?: string;
|
|
38
66
|
}
|
|
39
67
|
/**
|
|
40
68
|
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Dispatcher } from "./dispatcher.js";
|
|
|
3
3
|
import { consoleLogger } from "./log.js";
|
|
4
4
|
import { createRuntime } from "./runtimes/registry.js";
|
|
5
5
|
import { DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS, SessionStore } from "./session-store.js";
|
|
6
|
+
import { createTranscriptWriter, } from "./transcript.js";
|
|
6
7
|
/** Default runtime factory: delegates to the built-in registry; ignores extraArgs at construction. */
|
|
7
8
|
const defaultRuntimeFactory = (runtimeId) => createRuntime(runtimeId);
|
|
8
9
|
/**
|
|
@@ -53,6 +54,12 @@ export class Gateway {
|
|
|
53
54
|
maxEntryAgeMs: opts.sessionStoreMaxEntryAgeMs ?? DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS,
|
|
54
55
|
});
|
|
55
56
|
const runtimeFactory = opts.createRuntime ?? defaultRuntimeFactory;
|
|
57
|
+
const transcript = opts.transcript
|
|
58
|
+
?? createTranscriptWriter({
|
|
59
|
+
log: this.log,
|
|
60
|
+
enabled: opts.transcriptEnabled === true,
|
|
61
|
+
rootDir: opts.transcriptRootDir,
|
|
62
|
+
});
|
|
56
63
|
this.dispatcher = new Dispatcher({
|
|
57
64
|
config: this.config,
|
|
58
65
|
channels: this.channelMap,
|
|
@@ -65,6 +72,9 @@ export class Gateway {
|
|
|
65
72
|
composeUserTurn: opts.composeUserTurn,
|
|
66
73
|
onOutbound: opts.onOutbound,
|
|
67
74
|
managedRoutes: this.managedRoutes,
|
|
75
|
+
attentionGate: opts.attentionGate,
|
|
76
|
+
resolveHubUrl: opts.resolveHubUrl,
|
|
77
|
+
transcript,
|
|
68
78
|
});
|
|
69
79
|
this.channelManager = new ChannelManager({
|
|
70
80
|
config: this.config,
|