botschat 0.1.18 → 0.1.20
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/README.md +4 -0
- package/migrations/0013_agents_table.sql +29 -0
- package/migrations/0014_agent_sessions.sql +19 -0
- package/migrations/0015_message_traces.sql +27 -0
- package/migrations/0016_multi_agent_channels_messages.sql +9 -0
- package/migrations/0017_rename_cron_job_id.sql +2 -0
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +375 -42
- package/packages/api/src/index.ts +67 -24
- package/packages/api/src/protocol-v2.ts +154 -0
- package/packages/api/src/routes/agents-v2.ts +192 -0
- package/packages/api/src/routes/agents.ts +3 -3
- package/packages/api/src/routes/channels.ts +11 -11
- package/packages/api/src/routes/history-v2.ts +221 -0
- package/packages/api/src/routes/migrate-v2.ts +110 -0
- package/packages/api/src/routes/sessions.ts +5 -5
- package/packages/api/src/routes/tasks.ts +33 -33
- package/packages/plugin/dist/index.d.ts +1 -0
- package/packages/plugin/dist/index.d.ts.map +1 -1
- package/packages/plugin/dist/index.js +2 -1
- package/packages/plugin/dist/index.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +10 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +311 -69
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/runtime.d.ts +2 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -1
- package/packages/plugin/dist/src/runtime.js +10 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +25 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/{index-B5GU1yVt.css → index-BARPtt0v.css} +1 -1
- package/packages/web/dist/assets/index-Bf-XL3te.js +2 -0
- package/packages/web/dist/assets/{index-DzYqprDN.js → index-CYQMu_-c.js} +1 -1
- package/packages/web/dist/assets/index-CYlvfpX9.js +1519 -0
- package/packages/web/dist/assets/index-CxcpA4Qo.js +1 -0
- package/packages/web/dist/assets/{index-D3T7sc-R.js → index-DYCO-ry1.js} +1 -1
- package/packages/web/dist/assets/index-QebPVqwj.js +2 -0
- package/packages/web/dist/assets/{index.esm-COzWPkKi.js → index.esm-CvOpngZM.js} +1 -1
- package/packages/web/dist/assets/{web-CxXbaApe.js → web-1cdhq2RW.js} +1 -1
- package/packages/web/dist/assets/{web-DFQypSd0.js → web-D3LMODYp.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/src/App.tsx +84 -5
- package/packages/web/src/api.ts +61 -3
- package/packages/web/src/components/AgentSettings.tsx +328 -0
- package/packages/web/src/components/ChatWindow.tsx +124 -4
- package/packages/web/src/components/CronDetail.tsx +1 -1
- package/packages/web/src/components/SessionTabs.tsx +1 -1
- package/packages/web/src/components/Sidebar.tsx +3 -1
- package/packages/web/src/store.ts +86 -11
- package/packages/web/src/ws.ts +22 -1
- package/scripts/dev.sh +53 -0
- package/scripts/mock-openclaw-v2.mjs +486 -0
- package/scripts/mock-openclaw.mjs +35 -0
- package/packages/web/dist/assets/index-CO9YgLst.js +0 -2
- package/packages/web/dist/assets/index-ClDrCe_c.js +0 -1
- package/packages/web/dist/assets/index-DPEosppm.js +0 -2
- package/packages/web/dist/assets/index-IVUdSd9w.js +0 -1516
|
@@ -29,7 +29,8 @@ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
|
|
|
29
29
|
* - Use WebSocket Hibernation API so idle users cost zero compute
|
|
30
30
|
*
|
|
31
31
|
* Connection tagging (via serializeAttachment / deserializeAttachment):
|
|
32
|
-
* - "
|
|
32
|
+
* - "agent:<agentId>" = WebSocket from an agent plugin (v2, per-agent)
|
|
33
|
+
* - "openclaw" = legacy WebSocket from the OpenClaw plugin (backward compat)
|
|
33
34
|
* - "browser:<sessionId>" = a browser client WebSocket
|
|
34
35
|
*/
|
|
35
36
|
export class ConnectionDO implements DurableObject {
|
|
@@ -53,6 +54,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
53
54
|
/** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
|
|
54
55
|
private lastOpenClawAcceptedAt = 0;
|
|
55
56
|
|
|
57
|
+
/** Pending agent.request delegations — maps requestId to originator info. */
|
|
58
|
+
private pendingRequests = new Map<string, { fromAgentId: string; sessionKey: string; depth: number; createdAt: number }>();
|
|
59
|
+
|
|
56
60
|
constructor(state: DurableObjectState, env: Env) {
|
|
57
61
|
this.state = state;
|
|
58
62
|
this.env = env;
|
|
@@ -88,7 +92,8 @@ export class ConnectionDO implements DurableObject {
|
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
const preVerified = url.searchParams.get("verified") === "1";
|
|
91
|
-
|
|
95
|
+
const agentId = url.searchParams.get("agentId");
|
|
96
|
+
return this.handleOpenClawConnect(request, preVerified, agentId);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
// Route: /client/:sessionId — Browser client connects here
|
|
@@ -141,7 +146,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
141
146
|
return;
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
if (tag === "openclaw") {
|
|
149
|
+
if (tag === "openclaw" || tag?.startsWith("agent:")) {
|
|
145
150
|
await this.handleOpenClawMessage(ws, parsed);
|
|
146
151
|
} else if (tag?.startsWith("browser:")) {
|
|
147
152
|
await this.handleBrowserMessage(ws, parsed);
|
|
@@ -162,8 +167,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
162
167
|
/** Called when a WebSocket is closed. */
|
|
163
168
|
async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise<void> {
|
|
164
169
|
const tag = this.getTag(ws);
|
|
165
|
-
if (tag === "openclaw") {
|
|
166
|
-
// OpenClaw disconnected — notify all browser clients
|
|
170
|
+
if (tag === "openclaw" || tag?.startsWith("agent:")) {
|
|
167
171
|
this.broadcastToBrowsers(
|
|
168
172
|
JSON.stringify({ type: "openclaw.disconnected" }),
|
|
169
173
|
);
|
|
@@ -186,7 +190,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
186
190
|
|
|
187
191
|
// ---- Connection handlers ----
|
|
188
192
|
|
|
189
|
-
private handleOpenClawConnect(request: Request, preVerified = false): Response {
|
|
193
|
+
private handleOpenClawConnect(request: Request, preVerified = false, agentId?: string | null): Response {
|
|
190
194
|
if (request.headers.get("Upgrade") !== "websocket") {
|
|
191
195
|
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
192
196
|
}
|
|
@@ -202,12 +206,14 @@ export class ConnectionDO implements DurableObject {
|
|
|
202
206
|
}
|
|
203
207
|
this.lastOpenClawAcceptedAt = now;
|
|
204
208
|
|
|
205
|
-
|
|
209
|
+
const wsTag = agentId ? "agent:" + agentId : "openclaw";
|
|
210
|
+
|
|
211
|
+
// Safety valve: if stale sockets with the same tag accumulated (e.g. from
|
|
206
212
|
// rapid reconnects that authenticated but then lost their edge
|
|
207
213
|
// connection), close them all before accepting a new one.
|
|
208
|
-
const existing = this.state.getWebSockets(
|
|
214
|
+
const existing = this.state.getWebSockets(wsTag);
|
|
209
215
|
if (existing.length > 3) {
|
|
210
|
-
console.warn(`[DO] Safety valve: ${existing.length}
|
|
216
|
+
console.warn(`[DO] Safety valve: ${existing.length} ${wsTag} sockets, closing all`);
|
|
211
217
|
for (const s of existing) {
|
|
212
218
|
try { s.close(4009, "replaced"); } catch { /* dead */ }
|
|
213
219
|
}
|
|
@@ -216,12 +222,12 @@ export class ConnectionDO implements DurableObject {
|
|
|
216
222
|
const pair = new WebSocketPair();
|
|
217
223
|
const [client, server] = [pair[0], pair[1]];
|
|
218
224
|
|
|
219
|
-
this.state.acceptWebSocket(server, [
|
|
225
|
+
this.state.acceptWebSocket(server, [wsTag]);
|
|
220
226
|
|
|
221
227
|
// If the API worker already verified the token against D1, mark as
|
|
222
228
|
// pre-verified. The plugin still sends an auth message, which we'll
|
|
223
229
|
// fast-track through without re-validating the token.
|
|
224
|
-
server.serializeAttachment({ authenticated: false, tag:
|
|
230
|
+
server.serializeAttachment({ authenticated: false, tag: wsTag, agentId: agentId ?? null, preVerified });
|
|
225
231
|
|
|
226
232
|
return new Response(null, { status: 101, webSocket: client });
|
|
227
233
|
}
|
|
@@ -270,11 +276,12 @@ export class ConnectionDO implements DurableObject {
|
|
|
270
276
|
const isValid = attachment?.preVerified || await this.validatePairingToken(token);
|
|
271
277
|
|
|
272
278
|
if (isValid) {
|
|
273
|
-
// Close
|
|
279
|
+
// Close other sockets with the SAME tag. Use custom code 4009 so
|
|
274
280
|
// well-behaved plugins know they were replaced (not a crash)
|
|
275
281
|
// and should NOT reconnect. The Worker-level rate limit (10s)
|
|
276
282
|
// prevents the resulting close event from flooding the DO.
|
|
277
|
-
const
|
|
283
|
+
const wsTag = attachment?.tag ?? "openclaw";
|
|
284
|
+
const existingSockets = this.state.getWebSockets(wsTag);
|
|
278
285
|
let closedCount = 0;
|
|
279
286
|
for (const oldWs of existingSockets) {
|
|
280
287
|
if (oldWs !== ws) {
|
|
@@ -316,6 +323,15 @@ export class ConnectionDO implements DurableObject {
|
|
|
316
323
|
return;
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
// Resolve @channelName session keys to real adhoc task session keys
|
|
327
|
+
if (
|
|
328
|
+
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui" ||
|
|
329
|
+
msg.type === "agent.stream.start" || msg.type === "agent.stream.chunk" || msg.type === "agent.stream.end") &&
|
|
330
|
+
typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("@")
|
|
331
|
+
) {
|
|
332
|
+
msg.sessionKey = await this.resolveChannelSessionKey(msg.sessionKey);
|
|
333
|
+
}
|
|
334
|
+
|
|
319
335
|
// Persist agent messages to D1 (skip transient stream events)
|
|
320
336
|
if (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") {
|
|
321
337
|
console.log("[DO] Agent outbound:", JSON.stringify({
|
|
@@ -341,6 +357,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
341
357
|
// Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
|
|
342
358
|
const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
|
|
343
359
|
|
|
360
|
+
// Extract agentId from the sending WebSocket's attachment
|
|
361
|
+
const wsAtt = ws.deserializeAttachment() as { agentId?: string } | null;
|
|
362
|
+
|
|
344
363
|
await this.persistMessage({
|
|
345
364
|
id: msg.messageId as string | undefined,
|
|
346
365
|
sender: "agent",
|
|
@@ -350,6 +369,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
350
369
|
mediaUrl: persistedMediaUrl,
|
|
351
370
|
a2ui: msg.jsonl as string | undefined,
|
|
352
371
|
encrypted: encryptedBits,
|
|
372
|
+
senderAgentId: (msg.agentId as string | undefined) ?? wsAtt?.agentId ?? undefined,
|
|
353
373
|
});
|
|
354
374
|
}
|
|
355
375
|
|
|
@@ -363,7 +383,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
363
383
|
if (msg.type === "task.schedule.ack" && msg.ok && msg.taskId && msg.cronJobId) {
|
|
364
384
|
try {
|
|
365
385
|
await this.env.DB.prepare(
|
|
366
|
-
"UPDATE tasks SET
|
|
386
|
+
"UPDATE tasks SET provider_job_id = ? WHERE id = ?",
|
|
367
387
|
).bind(msg.cronJobId, msg.taskId).run();
|
|
368
388
|
console.log(`[DO] Updated task ${msg.taskId} with cronJobId ${msg.cronJobId}`);
|
|
369
389
|
} catch (err) {
|
|
@@ -404,11 +424,44 @@ export class ConnectionDO implements DurableObject {
|
|
|
404
424
|
await this.handleJobUpdate(msg);
|
|
405
425
|
}
|
|
406
426
|
|
|
427
|
+
// Handle agent.request — Agent-to-Agent delegation
|
|
428
|
+
if (msg.type === "agent.request") {
|
|
429
|
+
await this.handleAgentRequest(ws, msg);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Handle agent.trace — persist verbose execution traces (lv2/lv3)
|
|
434
|
+
if (msg.type === "agent.trace") {
|
|
435
|
+
await this.handleAgentTrace(msg);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
407
439
|
// Forward all messages to browser clients (strip notifyPreview — plaintext
|
|
408
440
|
// must not be relayed to browser WebSockets; browsers decrypt locally)
|
|
409
441
|
if (msg.type === "agent.text") {
|
|
410
442
|
console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
|
|
411
443
|
}
|
|
444
|
+
|
|
445
|
+
const wsAttForReply = ws.deserializeAttachment() as { agentId?: string } | null;
|
|
446
|
+
|
|
447
|
+
// Check if this agent.text is a response to a pending agent.request
|
|
448
|
+
if ((msg.type === "agent.text" || msg.type === "agent.stream.end") && msg.requestId) {
|
|
449
|
+
const pending = this.pendingRequests.get(msg.requestId as string);
|
|
450
|
+
if (pending) {
|
|
451
|
+
const originSocket = this.getAgentSocket(pending.fromAgentId);
|
|
452
|
+
if (originSocket) {
|
|
453
|
+
originSocket.send(JSON.stringify({
|
|
454
|
+
type: "agent.response",
|
|
455
|
+
requestId: msg.requestId,
|
|
456
|
+
fromAgentId: (msg.agentId as string) ?? wsAttForReply?.agentId ?? null,
|
|
457
|
+
text: (msg.text ?? "") as string,
|
|
458
|
+
sessionKey: (msg.sessionKey ?? "") as string,
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
this.pendingRequests.delete(msg.requestId as string);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
412
465
|
const { notifyPreview: _stripped, ...msgForBrowser } = msg;
|
|
413
466
|
this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
|
|
414
467
|
|
|
@@ -456,11 +509,31 @@ export class ConnectionDO implements DurableObject {
|
|
|
456
509
|
}
|
|
457
510
|
|
|
458
511
|
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
459
|
-
// Include userId so the browser can derive the E2E key
|
|
460
512
|
const doUserId2 = doUserId ?? payload.sub;
|
|
461
|
-
ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2 }));
|
|
462
513
|
|
|
463
|
-
//
|
|
514
|
+
// Fetch available agents from D1 for the auth.ok response
|
|
515
|
+
let availableAgents: Array<{ id: string; name: string; type: string; role: string; capabilities: string[]; status: string }> = [];
|
|
516
|
+
try {
|
|
517
|
+
const { results } = await this.env.DB.prepare(
|
|
518
|
+
"SELECT id, name, type, role, capabilities, status FROM agents WHERE user_id = ? ORDER BY created_at ASC",
|
|
519
|
+
)
|
|
520
|
+
.bind(doUserId2)
|
|
521
|
+
.all<{ id: string; name: string; type: string; role: string; capabilities: string; status: string }>();
|
|
522
|
+
availableAgents = (results ?? []).map((r) => ({
|
|
523
|
+
id: r.id,
|
|
524
|
+
name: r.name,
|
|
525
|
+
type: r.type,
|
|
526
|
+
role: r.role,
|
|
527
|
+
capabilities: JSON.parse(r.capabilities || "[]"),
|
|
528
|
+
status: r.status,
|
|
529
|
+
}));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error("[DO] Failed to fetch agents for auth.ok:", err);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2, availableAgents }));
|
|
535
|
+
|
|
536
|
+
// Send current connection status + cached models
|
|
464
537
|
await this.ensureCachedModels();
|
|
465
538
|
const openclawConnected = this.getOpenClawSocket() !== null;
|
|
466
539
|
ws.send(
|
|
@@ -469,6 +542,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
469
542
|
openclawConnected,
|
|
470
543
|
defaultModel: this.defaultModel,
|
|
471
544
|
models: this.cachedModels,
|
|
545
|
+
connectedAgents: availableAgents.filter((a) => a.status === "connected"),
|
|
472
546
|
}),
|
|
473
547
|
);
|
|
474
548
|
return;
|
|
@@ -511,6 +585,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
511
585
|
type: msg.type,
|
|
512
586
|
sessionKey: msg.sessionKey,
|
|
513
587
|
messageId: msg.messageId,
|
|
588
|
+
targetAgentId: msg.targetAgentId,
|
|
514
589
|
hasMedia: !!msg.mediaUrl,
|
|
515
590
|
}));
|
|
516
591
|
await this.persistMessage({
|
|
@@ -520,14 +595,34 @@ export class ConnectionDO implements DurableObject {
|
|
|
520
595
|
text: (msg.text ?? "") as string,
|
|
521
596
|
mediaUrl: msg.mediaUrl as string | undefined,
|
|
522
597
|
encrypted: msg.encrypted ? 1 : 0,
|
|
598
|
+
targetAgentId: msg.targetAgentId as string | undefined,
|
|
523
599
|
});
|
|
524
600
|
}
|
|
525
601
|
|
|
526
|
-
//
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
602
|
+
// Route user message to the target agent
|
|
603
|
+
const targetAgentId = msg.targetAgentId as string | undefined;
|
|
604
|
+
let targetWs: WebSocket | null = null;
|
|
605
|
+
|
|
606
|
+
if (targetAgentId) {
|
|
607
|
+
targetWs = this.getAgentSocket(targetAgentId);
|
|
608
|
+
}
|
|
609
|
+
if (!targetWs) {
|
|
610
|
+
// No explicit target or target not found — try channel's default agent
|
|
611
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
612
|
+
if (sessionKey) {
|
|
613
|
+
const channelDefault = await this.resolveDefaultAgentId(sessionKey);
|
|
614
|
+
if (channelDefault) {
|
|
615
|
+
targetWs = this.getAgentSocket(channelDefault);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (!targetWs) {
|
|
620
|
+
// Final fallback: any connected agent (backward compat)
|
|
621
|
+
targetWs = this.getAnyAgentSocket();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (targetWs) {
|
|
625
|
+
// Enrich thread messages with parent context
|
|
531
626
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
532
627
|
const threadMatch = sessionKey?.match(/:thread:(.+)$/);
|
|
533
628
|
let enrichedMsg = msg;
|
|
@@ -547,18 +642,17 @@ export class ConnectionDO implements DurableObject {
|
|
|
547
642
|
parentSender: parentRow.sender as string,
|
|
548
643
|
parentEncrypted: (parentRow.encrypted ?? 0) as number,
|
|
549
644
|
};
|
|
550
|
-
console.log(`[DO] Attached parent message for thread: parentId=${parentId}, sender=${parentRow.sender}, encrypted=${parentRow.encrypted}`);
|
|
551
645
|
}
|
|
552
646
|
} catch (err) {
|
|
553
|
-
console.error(`[DO] Failed to fetch parent message for thread
|
|
647
|
+
console.error(`[DO] Failed to fetch parent message for thread:`, err);
|
|
554
648
|
}
|
|
555
649
|
}
|
|
556
|
-
|
|
650
|
+
targetWs.send(JSON.stringify(enrichedMsg));
|
|
557
651
|
} else {
|
|
558
652
|
ws.send(
|
|
559
653
|
JSON.stringify({
|
|
560
654
|
type: "error",
|
|
561
|
-
message: "
|
|
655
|
+
message: "No agent is currently connected. Please check your agent instances.",
|
|
562
656
|
}),
|
|
563
657
|
);
|
|
564
658
|
}
|
|
@@ -616,7 +710,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
616
710
|
|
|
617
711
|
private handleStatus(): Response {
|
|
618
712
|
const sockets = this.state.getWebSockets();
|
|
619
|
-
const openclawSocket = sockets.find((s) =>
|
|
713
|
+
const openclawSocket = sockets.find((s) => {
|
|
714
|
+
const t = this.getTag(s);
|
|
715
|
+
return t === "openclaw" || t?.startsWith("agent:");
|
|
716
|
+
});
|
|
620
717
|
const browserCount = sockets.filter((s) =>
|
|
621
718
|
this.getTag(s)?.startsWith("browser:"),
|
|
622
719
|
).length;
|
|
@@ -687,11 +784,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
687
784
|
return att?.tag ?? null;
|
|
688
785
|
}
|
|
689
786
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
// After a reconnection there may briefly be multiple sockets
|
|
694
|
-
// before the stale cleanup in handleOpenClawMessage runs.
|
|
787
|
+
/** Get a specific agent's socket by agentId. */
|
|
788
|
+
private getAgentSocket(agentId: string): WebSocket | null {
|
|
789
|
+
const sockets = this.state.getWebSockets("agent:" + agentId);
|
|
695
790
|
let newest: WebSocket | null = null;
|
|
696
791
|
for (const s of sockets) {
|
|
697
792
|
const att = s.deserializeAttachment() as { authenticated: boolean } | null;
|
|
@@ -700,6 +795,147 @@ export class ConnectionDO implements DurableObject {
|
|
|
700
795
|
return newest ?? sockets[sockets.length - 1] ?? null;
|
|
701
796
|
}
|
|
702
797
|
|
|
798
|
+
/** Get any connected agent socket (new agent:xxx or legacy openclaw). */
|
|
799
|
+
private getAnyAgentSocket(): WebSocket | null {
|
|
800
|
+
const allSockets = this.state.getWebSockets();
|
|
801
|
+
for (const s of allSockets) {
|
|
802
|
+
const att = s.deserializeAttachment() as { authenticated: boolean; tag?: string } | null;
|
|
803
|
+
if (att?.authenticated && att?.tag?.startsWith("agent:")) return s;
|
|
804
|
+
}
|
|
805
|
+
const legacySockets = this.state.getWebSockets("openclaw");
|
|
806
|
+
for (const s of legacySockets) {
|
|
807
|
+
const att = s.deserializeAttachment() as { authenticated: boolean } | null;
|
|
808
|
+
if (att?.authenticated) return s;
|
|
809
|
+
}
|
|
810
|
+
return legacySockets[legacySockets.length - 1] ?? null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private getOpenClawSocket(): WebSocket | null {
|
|
814
|
+
return this.getAnyAgentSocket();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---- Agent-to-Agent delegation ----
|
|
818
|
+
|
|
819
|
+
private async handleAgentRequest(ws: WebSocket, msg: Record<string, unknown>): Promise<void> {
|
|
820
|
+
const MAX_DEPTH = 5;
|
|
821
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
822
|
+
const depth = (msg.depth as number) ?? 0;
|
|
823
|
+
const requestId = msg.requestId as string;
|
|
824
|
+
const targetAgentId = msg.targetAgentId as string;
|
|
825
|
+
const fromAtt = ws.deserializeAttachment() as { agentId?: string } | null;
|
|
826
|
+
const fromAgentId = (msg.agentId as string) ?? fromAtt?.agentId ?? "unknown";
|
|
827
|
+
|
|
828
|
+
if (depth >= MAX_DEPTH) {
|
|
829
|
+
const originSocket = this.getAgentSocket(fromAgentId);
|
|
830
|
+
if (originSocket) {
|
|
831
|
+
originSocket.send(JSON.stringify({
|
|
832
|
+
type: "agent.response",
|
|
833
|
+
requestId,
|
|
834
|
+
fromAgentId: targetAgentId,
|
|
835
|
+
error: "Maximum delegation depth exceeded",
|
|
836
|
+
sessionKey: msg.sessionKey,
|
|
837
|
+
}));
|
|
838
|
+
}
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const targetSocket = this.getAgentSocket(targetAgentId);
|
|
843
|
+
if (!targetSocket) {
|
|
844
|
+
// Target agent offline — notify originator and broadcast system message
|
|
845
|
+
const originSocket = this.getAgentSocket(fromAgentId);
|
|
846
|
+
if (originSocket) {
|
|
847
|
+
originSocket.send(JSON.stringify({
|
|
848
|
+
type: "agent.response",
|
|
849
|
+
requestId,
|
|
850
|
+
fromAgentId: targetAgentId,
|
|
851
|
+
error: `Agent ${targetAgentId} is currently offline`,
|
|
852
|
+
sessionKey: msg.sessionKey,
|
|
853
|
+
}));
|
|
854
|
+
}
|
|
855
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
856
|
+
type: "system.message",
|
|
857
|
+
text: `Agent delegation failed: target agent is offline`,
|
|
858
|
+
sessionKey: msg.sessionKey,
|
|
859
|
+
}));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Track the pending request for response routing
|
|
864
|
+
this.pendingRequests.set(requestId, {
|
|
865
|
+
fromAgentId,
|
|
866
|
+
sessionKey: msg.sessionKey as string,
|
|
867
|
+
depth,
|
|
868
|
+
createdAt: Date.now(),
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Clean up expired pending requests
|
|
872
|
+
const now = Date.now();
|
|
873
|
+
for (const [rid, pr] of this.pendingRequests) {
|
|
874
|
+
if (now - pr.createdAt > TIMEOUT_MS) {
|
|
875
|
+
this.pendingRequests.delete(rid);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Persist the delegation message to D1 (visible as agent-to-agent in chat)
|
|
880
|
+
await this.persistMessage({
|
|
881
|
+
sender: "agent",
|
|
882
|
+
sessionKey: msg.sessionKey as string,
|
|
883
|
+
text: (msg.text ?? "") as string,
|
|
884
|
+
senderAgentId: fromAgentId,
|
|
885
|
+
targetAgentId,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// Forward to target agent as a user.message (agents don't need to know it's a delegation)
|
|
889
|
+
targetSocket.send(JSON.stringify({
|
|
890
|
+
type: "user.message",
|
|
891
|
+
sessionKey: msg.sessionKey,
|
|
892
|
+
text: msg.text,
|
|
893
|
+
userId: fromAgentId,
|
|
894
|
+
messageId: requestId,
|
|
895
|
+
targetAgentId,
|
|
896
|
+
context: msg.context,
|
|
897
|
+
depth: depth + 1,
|
|
898
|
+
}));
|
|
899
|
+
|
|
900
|
+
// Also broadcast to browser so user can see the delegation
|
|
901
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
902
|
+
type: "agent.text",
|
|
903
|
+
agentId: fromAgentId,
|
|
904
|
+
sessionKey: msg.sessionKey,
|
|
905
|
+
text: msg.text,
|
|
906
|
+
targetAgentId,
|
|
907
|
+
isDelegation: true,
|
|
908
|
+
}));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ---- Verbose trace persistence ----
|
|
912
|
+
|
|
913
|
+
private async handleAgentTrace(msg: Record<string, unknown>): Promise<void> {
|
|
914
|
+
try {
|
|
915
|
+
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
916
|
+
const id = generateIdUtil("mt_");
|
|
917
|
+
await this.env.DB.prepare(
|
|
918
|
+
`INSERT INTO message_traces (id, message_id, user_id, session_key, agent_id, verbose_level, trace_type, content, metadata_json, encrypted)
|
|
919
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
920
|
+
)
|
|
921
|
+
.bind(
|
|
922
|
+
id,
|
|
923
|
+
(msg.messageId ?? "") as string,
|
|
924
|
+
userId,
|
|
925
|
+
(msg.sessionKey ?? "") as string,
|
|
926
|
+
(msg.agentId ?? "") as string,
|
|
927
|
+
(msg.verboseLevel ?? 2) as number,
|
|
928
|
+
(msg.traceType ?? "thinking") as string,
|
|
929
|
+
(msg.content ?? "") as string,
|
|
930
|
+
JSON.stringify(msg.metadata ?? {}),
|
|
931
|
+
msg.encrypted ? 1 : 0,
|
|
932
|
+
)
|
|
933
|
+
.run();
|
|
934
|
+
} catch (err) {
|
|
935
|
+
console.error("[DO] Failed to persist agent trace:", err);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
703
939
|
private broadcastToBrowsers(message: string): void {
|
|
704
940
|
const sockets = this.state.getWebSockets();
|
|
705
941
|
for (const s of sockets) {
|
|
@@ -1028,6 +1264,8 @@ export class ConnectionDO implements DurableObject {
|
|
|
1028
1264
|
mediaUrl?: string;
|
|
1029
1265
|
a2ui?: string;
|
|
1030
1266
|
encrypted?: number;
|
|
1267
|
+
senderAgentId?: string;
|
|
1268
|
+
targetAgentId?: string;
|
|
1031
1269
|
}): Promise<void> {
|
|
1032
1270
|
try {
|
|
1033
1271
|
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
@@ -1042,10 +1280,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
1042
1280
|
}
|
|
1043
1281
|
|
|
1044
1282
|
await this.env.DB.prepare(
|
|
1045
|
-
`INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted)
|
|
1046
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1283
|
+
`INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted, sender_agent_id, target_agent_id)
|
|
1284
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1047
1285
|
)
|
|
1048
|
-
.bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted)
|
|
1286
|
+
.bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted, opts.senderAgentId ?? null, opts.targetAgentId ?? null)
|
|
1049
1287
|
.run();
|
|
1050
1288
|
} catch (err) {
|
|
1051
1289
|
console.error("Failed to persist message:", err);
|
|
@@ -1207,7 +1445,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1207
1445
|
|
|
1208
1446
|
// Check if a matching task already exists
|
|
1209
1447
|
const existingTask = await this.env.DB.prepare(
|
|
1210
|
-
"SELECT id, session_key FROM tasks WHERE
|
|
1448
|
+
"SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
|
|
1211
1449
|
)
|
|
1212
1450
|
.bind(t.cronJobId)
|
|
1213
1451
|
.first<{ id: string; session_key: string }>();
|
|
@@ -1226,7 +1464,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1226
1464
|
t.cronJobId,
|
|
1227
1465
|
];
|
|
1228
1466
|
await this.env.DB.prepare(
|
|
1229
|
-
`UPDATE tasks SET ${updateParts.join(", ")} WHERE
|
|
1467
|
+
`UPDATE tasks SET ${updateParts.join(", ")} WHERE provider_job_id = ?`,
|
|
1230
1468
|
)
|
|
1231
1469
|
.bind(...updateVals)
|
|
1232
1470
|
.run();
|
|
@@ -1245,7 +1483,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1245
1483
|
// D1 only stores basic task metadata — schedule/instructions/model
|
|
1246
1484
|
// belong to OpenClaw and are delivered via task.scan.result WebSocket.
|
|
1247
1485
|
await this.env.DB.prepare(
|
|
1248
|
-
`INSERT INTO tasks (id, channel_id, name, kind,
|
|
1486
|
+
`INSERT INTO tasks (id, channel_id, name, kind, provider_job_id, session_key, enabled)
|
|
1249
1487
|
VALUES (?, ?, ?, 'background', ?, ?, ?)`,
|
|
1250
1488
|
)
|
|
1251
1489
|
.bind(taskId, defaultChannelId, taskName, t.cronJobId, sessionKey, t.enabled ? 1 : 0)
|
|
@@ -1256,7 +1494,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1256
1494
|
|
|
1257
1495
|
// Resolve the task record for job persistence (may have just been created)
|
|
1258
1496
|
const taskRecord = await this.env.DB.prepare(
|
|
1259
|
-
"SELECT id, session_key FROM tasks WHERE
|
|
1497
|
+
"SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
|
|
1260
1498
|
)
|
|
1261
1499
|
.bind(t.cronJobId)
|
|
1262
1500
|
.first<{ id: string; session_key: string }>();
|
|
@@ -1320,7 +1558,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1320
1558
|
private async ensureDefaultChannel(userId: string): Promise<string> {
|
|
1321
1559
|
// Check if a default channel already exists
|
|
1322
1560
|
const existing = await this.env.DB.prepare(
|
|
1323
|
-
"SELECT id FROM channels WHERE user_id = ? AND
|
|
1561
|
+
"SELECT id FROM channels WHERE user_id = ? AND provider_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
|
|
1324
1562
|
)
|
|
1325
1563
|
.bind(userId)
|
|
1326
1564
|
.first<{ id: string }>();
|
|
@@ -1330,7 +1568,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1330
1568
|
// Create the default channel
|
|
1331
1569
|
const channelId = this.generateId("ch_");
|
|
1332
1570
|
await this.env.DB.prepare(
|
|
1333
|
-
"INSERT INTO channels (id, user_id, name, description,
|
|
1571
|
+
"INSERT INTO channels (id, user_id, name, description, provider_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
|
|
1334
1572
|
)
|
|
1335
1573
|
.bind(channelId, userId)
|
|
1336
1574
|
.run();
|
|
@@ -1348,6 +1586,101 @@ export class ConnectionDO implements DurableObject {
|
|
|
1348
1586
|
return channelId;
|
|
1349
1587
|
}
|
|
1350
1588
|
|
|
1589
|
+
/**
|
|
1590
|
+
* Resolve a "@channelName" session key to a real adhoc task session key.
|
|
1591
|
+
* Resolve the default agent ID for a given session key.
|
|
1592
|
+
* Looks up the channel from the session key pattern and returns its default_agent_id.
|
|
1593
|
+
*/
|
|
1594
|
+
private async resolveDefaultAgentId(sessionKey: string): Promise<string | null> {
|
|
1595
|
+
try {
|
|
1596
|
+
const userId = await this.state.storage.get<string>("userId");
|
|
1597
|
+
if (!userId) return null;
|
|
1598
|
+
|
|
1599
|
+
// Try to find channel by matching session_key in sessions or tasks table
|
|
1600
|
+
const session = await this.env.DB.prepare(
|
|
1601
|
+
"SELECT channel_id FROM sessions WHERE session_key = ? AND user_id = ? LIMIT 1",
|
|
1602
|
+
)
|
|
1603
|
+
.bind(sessionKey.replace(/:thread:.+$/, ""), userId)
|
|
1604
|
+
.first<{ channel_id: string }>();
|
|
1605
|
+
|
|
1606
|
+
const channelId = session?.channel_id;
|
|
1607
|
+
if (!channelId) return null;
|
|
1608
|
+
|
|
1609
|
+
const channel = await this.env.DB.prepare(
|
|
1610
|
+
"SELECT default_agent_id FROM channels WHERE id = ? LIMIT 1",
|
|
1611
|
+
)
|
|
1612
|
+
.bind(channelId)
|
|
1613
|
+
.first<{ default_agent_id: string | null }>();
|
|
1614
|
+
|
|
1615
|
+
return channel?.default_agent_id ?? null;
|
|
1616
|
+
} catch {
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* - "@default" → first channel (by created_at ASC)
|
|
1623
|
+
* - "@SomeName" → channel matched by name (case-insensitive)
|
|
1624
|
+
* Returns the original sessionKey if it doesn't start with "@".
|
|
1625
|
+
*/
|
|
1626
|
+
private async resolveChannelSessionKey(rawSessionKey: string): Promise<string> {
|
|
1627
|
+
if (!rawSessionKey.startsWith("@")) return rawSessionKey;
|
|
1628
|
+
|
|
1629
|
+
const userId = await this.state.storage.get<string>("userId");
|
|
1630
|
+
if (!userId) {
|
|
1631
|
+
console.error("[DO] resolveChannelSessionKey: no userId in storage");
|
|
1632
|
+
return rawSessionKey;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const isDefault = rawSessionKey.toLowerCase() === "@default";
|
|
1636
|
+
const channelName = isDefault ? null : rawSessionKey.slice(1);
|
|
1637
|
+
|
|
1638
|
+
let channel: { id: string; provider_agent_id: string } | null = null;
|
|
1639
|
+
|
|
1640
|
+
if (channelName) {
|
|
1641
|
+
channel = await this.env.DB.prepare(
|
|
1642
|
+
"SELECT id, provider_agent_id FROM channels WHERE user_id = ? AND LOWER(name) = LOWER(?) LIMIT 1",
|
|
1643
|
+
)
|
|
1644
|
+
.bind(userId, channelName)
|
|
1645
|
+
.first<{ id: string; provider_agent_id: string }>();
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (!channel) {
|
|
1649
|
+
channel = await this.env.DB.prepare(
|
|
1650
|
+
"SELECT id, provider_agent_id FROM channels WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
1651
|
+
)
|
|
1652
|
+
.bind(userId)
|
|
1653
|
+
.first<{ id: string; provider_agent_id: string }>();
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if (!channel) {
|
|
1657
|
+
const channelId = await this.ensureDefaultChannel(userId);
|
|
1658
|
+
channel = { id: channelId, provider_agent_id: "main" };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const task = await this.env.DB.prepare(
|
|
1662
|
+
"SELECT session_key FROM tasks WHERE channel_id = ? AND kind = 'adhoc' LIMIT 1",
|
|
1663
|
+
)
|
|
1664
|
+
.bind(channel.id)
|
|
1665
|
+
.first<{ session_key: string }>();
|
|
1666
|
+
|
|
1667
|
+
if (task?.session_key) {
|
|
1668
|
+
console.log(`[DO] Resolved ${rawSessionKey} → ${task.session_key}`);
|
|
1669
|
+
return task.session_key;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const taskId = this.generateId("tsk_");
|
|
1673
|
+
const agentId = channel.provider_agent_id || "main";
|
|
1674
|
+
const sessionKey = `agent:${agentId}:botschat:${userId}:adhoc`;
|
|
1675
|
+
await this.env.DB.prepare(
|
|
1676
|
+
"INSERT INTO tasks (id, channel_id, name, kind, session_key) VALUES (?, ?, 'Ad Hoc Chat', 'adhoc', ?)",
|
|
1677
|
+
)
|
|
1678
|
+
.bind(taskId, channel.id, sessionKey)
|
|
1679
|
+
.run();
|
|
1680
|
+
console.log(`[DO] Created adhoc task for channel ${channel.id}, resolved ${rawSessionKey} → ${sessionKey}`);
|
|
1681
|
+
return sessionKey;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1351
1684
|
/** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
|
|
1352
1685
|
private generateId(prefix = ""): string {
|
|
1353
1686
|
return generateIdUtil(prefix);
|
|
@@ -1371,7 +1704,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
1371
1704
|
|
|
1372
1705
|
// Find the task by cronJobId
|
|
1373
1706
|
const task = await this.env.DB.prepare(
|
|
1374
|
-
"SELECT id FROM tasks WHERE
|
|
1707
|
+
"SELECT id FROM tasks WHERE provider_job_id = ?",
|
|
1375
1708
|
)
|
|
1376
1709
|
.bind(cronJobId)
|
|
1377
1710
|
.first<{ id: string }>();
|