@contextableai/clawg-ui 0.2.8 → 0.3.0
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 +24 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.js +122 -0
- package/dist/src/channel.d.ts +8 -0
- package/dist/src/channel.js +29 -0
- package/dist/src/client-tools.d.ts +27 -0
- package/dist/src/client-tools.js +50 -0
- package/dist/src/gateway-secret.d.ts +9 -0
- package/dist/src/gateway-secret.js +17 -0
- package/dist/src/http-handler.d.ts +3 -0
- package/dist/src/http-handler.js +545 -0
- package/dist/src/tool-store.d.ts +21 -0
- package/dist/src/tool-store.js +109 -0
- package/package.json +13 -2
- package/CHANGELOG.md +0 -84
- package/clawgui.png +0 -0
- package/index.ts +0 -136
- package/src/channel.ts +0 -37
- package/src/client-tools.ts +0 -51
- package/src/gateway-secret.ts +0 -20
- package/src/http-handler.ts +0 -619
- package/src/tool-store.ts +0 -152
- package/test-device-setup.md +0 -28
- package/tsconfig.json +0 -12
package/src/http-handler.ts
DELETED
|
@@ -1,619 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { randomUUID, createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { EventType } from "@ag-ui/core";
|
|
4
|
-
import type { RunAgentInput, Message } from "@ag-ui/core";
|
|
5
|
-
import { EventEncoder } from "@ag-ui/encoder";
|
|
6
|
-
import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk";
|
|
7
|
-
import {
|
|
8
|
-
stashTools,
|
|
9
|
-
setWriter,
|
|
10
|
-
clearWriter,
|
|
11
|
-
markClientToolNames,
|
|
12
|
-
wasClientToolCalled,
|
|
13
|
-
clearClientToolCalled,
|
|
14
|
-
clearClientToolNames,
|
|
15
|
-
wasToolFiredInRun,
|
|
16
|
-
clearToolFiredInRun,
|
|
17
|
-
} from "./tool-store.js";
|
|
18
|
-
import { aguiChannelPlugin } from "./channel.js";
|
|
19
|
-
import { resolveGatewaySecret } from "./gateway-secret.js";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Lightweight HTTP helpers (no internal imports needed)
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
26
|
-
res.statusCode = status;
|
|
27
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
28
|
-
res.end(JSON.stringify(body));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function sendMethodNotAllowed(res: ServerResponse) {
|
|
32
|
-
res.setHeader("Allow", "POST");
|
|
33
|
-
res.statusCode = 405;
|
|
34
|
-
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
35
|
-
res.end("Method Not Allowed");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sendUnauthorized(res: ServerResponse) {
|
|
39
|
-
sendJson(res, 401, { error: { message: "Authentication required", type: "unauthorized" } });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
const chunks: Buffer[] = [];
|
|
45
|
-
let size = 0;
|
|
46
|
-
req.on("data", (chunk: Buffer) => {
|
|
47
|
-
size += chunk.length;
|
|
48
|
-
if (size > maxBytes) {
|
|
49
|
-
reject(new Error("Request body too large"));
|
|
50
|
-
req.destroy();
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
chunks.push(chunk);
|
|
54
|
-
});
|
|
55
|
-
req.on("end", () => {
|
|
56
|
-
try {
|
|
57
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
58
|
-
} catch {
|
|
59
|
-
reject(new Error("Invalid JSON body"));
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
req.on("error", reject);
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function getBearerToken(req: IncomingMessage): string | undefined {
|
|
67
|
-
const raw = req.headers.authorization?.trim() ?? "";
|
|
68
|
-
if (!raw.toLowerCase().startsWith("bearer ")) {
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
return raw.slice(7).trim() || undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// HMAC-signed device token utilities
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
function createDeviceToken(secret: string, deviceId: string): string {
|
|
79
|
-
const encodedId = Buffer.from(deviceId).toString("base64url");
|
|
80
|
-
const signature = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
|
|
81
|
-
return `${encodedId}.${signature}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function verifyDeviceToken(token: string, secret: string): string | null {
|
|
85
|
-
const dotIndex = token.indexOf(".");
|
|
86
|
-
if (dotIndex <= 0 || dotIndex >= token.length - 1) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const encodedId = token.slice(0, dotIndex);
|
|
91
|
-
const providedSig = token.slice(dotIndex + 1);
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const deviceId = Buffer.from(encodedId, "base64url").toString("utf-8");
|
|
95
|
-
|
|
96
|
-
// Validate it looks like a UUID
|
|
97
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(deviceId)) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const expectedSig = createHmac("sha256", secret).update(deviceId).digest("hex").slice(0, 32);
|
|
102
|
-
|
|
103
|
-
// Constant-time comparison
|
|
104
|
-
if (providedSig.length !== expectedSig.length) {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
const providedBuf = Buffer.from(providedSig);
|
|
108
|
-
const expectedBuf = Buffer.from(expectedSig);
|
|
109
|
-
if (!timingSafeEqual(providedBuf, expectedBuf)) {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return deviceId;
|
|
114
|
-
} catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
// Extract text from AG-UI messages
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
function extractTextContent(msg: Message): string {
|
|
124
|
-
if (typeof msg.content === "string") {
|
|
125
|
-
return msg.content;
|
|
126
|
-
}
|
|
127
|
-
return "";
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
// Build MsgContext-compatible body from AG-UI messages
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
function buildBodyFromMessages(messages: Message[]): {
|
|
135
|
-
body: string;
|
|
136
|
-
systemPrompt?: string;
|
|
137
|
-
} {
|
|
138
|
-
const systemParts: string[] = [];
|
|
139
|
-
const parts: string[] = [];
|
|
140
|
-
let lastUserBody = "";
|
|
141
|
-
let lastToolBody = "";
|
|
142
|
-
|
|
143
|
-
for (const msg of messages) {
|
|
144
|
-
const role = msg.role?.trim() ?? "";
|
|
145
|
-
const content = extractTextContent(msg).trim();
|
|
146
|
-
// Allow messages with no content (e.g., assistant with only toolCalls)
|
|
147
|
-
if (!role) {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
if (role === "system") {
|
|
151
|
-
if (content) systemParts.push(content);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (role === "user") {
|
|
155
|
-
lastUserBody = content;
|
|
156
|
-
if (content) parts.push(`User: ${content}`);
|
|
157
|
-
} else if (role === "assistant") {
|
|
158
|
-
if (content) parts.push(`Assistant: ${content}`);
|
|
159
|
-
} else if (role === "tool") {
|
|
160
|
-
lastToolBody = content;
|
|
161
|
-
if (content) parts.push(`Tool result: ${content}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// If there's only a single user message, use it directly (no envelope needed)
|
|
166
|
-
// If there's only a tool result (resuming after client tool), use it directly
|
|
167
|
-
const userMessages = messages.filter((m) => m.role === "user");
|
|
168
|
-
const toolMessages = messages.filter((m) => m.role === "tool");
|
|
169
|
-
let body: string;
|
|
170
|
-
if (userMessages.length === 1 && parts.length === 1) {
|
|
171
|
-
body = lastUserBody;
|
|
172
|
-
} else if (userMessages.length === 0 && toolMessages.length > 0 && parts.length === toolMessages.length) {
|
|
173
|
-
// Tool-result-only submission: format as tool result for agent context
|
|
174
|
-
body = `Tool result: ${lastToolBody}`;
|
|
175
|
-
} else {
|
|
176
|
-
body = parts.join("\n");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
body,
|
|
181
|
-
systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
// HTTP handler factory
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
export function createAguiHttpHandler(api: OpenClawPluginApi) {
|
|
190
|
-
const runtime: PluginRuntime = api.runtime;
|
|
191
|
-
|
|
192
|
-
// Resolve once at init so the per-request handler never touches env vars.
|
|
193
|
-
const gatewaySecret = resolveGatewaySecret(api);
|
|
194
|
-
|
|
195
|
-
return async function handleAguiRequest(
|
|
196
|
-
req: IncomingMessage,
|
|
197
|
-
res: ServerResponse,
|
|
198
|
-
): Promise<void> {
|
|
199
|
-
// POST-only
|
|
200
|
-
if (req.method !== "POST") {
|
|
201
|
-
sendMethodNotAllowed(res);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Verify gateway secret was resolved at startup
|
|
206
|
-
if (!gatewaySecret) {
|
|
207
|
-
sendJson(res, 500, {
|
|
208
|
-
error: { message: "Gateway not configured", type: "server_error" },
|
|
209
|
-
});
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
// Authentication: No auth (pairing initiation) or Device token
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
let deviceId: string;
|
|
217
|
-
|
|
218
|
-
const bearerToken = getBearerToken(req);
|
|
219
|
-
|
|
220
|
-
if (!bearerToken) {
|
|
221
|
-
// No auth header: initiate pairing
|
|
222
|
-
// Generate new device ID
|
|
223
|
-
deviceId = randomUUID();
|
|
224
|
-
|
|
225
|
-
// Add to pending via OpenClaw pairing API - returns a pairing code for approval
|
|
226
|
-
const { code: pairingCode } = await runtime.channel.pairing.upsertPairingRequest({
|
|
227
|
-
channel: "clawg-ui",
|
|
228
|
-
id: deviceId,
|
|
229
|
-
pairingAdapter: aguiChannelPlugin.pairing,
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// Rate limit reached - max pending requests exceeded
|
|
233
|
-
if (!pairingCode) {
|
|
234
|
-
sendJson(res, 429, {
|
|
235
|
-
error: {
|
|
236
|
-
type: "rate_limit",
|
|
237
|
-
message: "Too many pending pairing requests. Please wait for existing requests to expire (10 minutes) or ask the owner to approve/reject them.",
|
|
238
|
-
},
|
|
239
|
-
});
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Generate signed device token
|
|
244
|
-
const deviceToken = createDeviceToken(gatewaySecret, deviceId);
|
|
245
|
-
|
|
246
|
-
// Return pairing pending response with device token and pairing code
|
|
247
|
-
sendJson(res, 403, {
|
|
248
|
-
error: {
|
|
249
|
-
type: "pairing_pending",
|
|
250
|
-
message: "Device pending approval",
|
|
251
|
-
pairing: {
|
|
252
|
-
pairingCode,
|
|
253
|
-
token: deviceToken,
|
|
254
|
-
instructions: `Save this token for use as a Bearer token and ask the owner to approve: openclaw pairing approve clawg-ui ${pairingCode}`,
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Device token flow: verify HMAC signature, extract device ID
|
|
262
|
-
const extractedDeviceId = verifyDeviceToken(bearerToken, gatewaySecret);
|
|
263
|
-
if (!extractedDeviceId) {
|
|
264
|
-
sendUnauthorized(res);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
deviceId = extractedDeviceId;
|
|
268
|
-
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// Pairing check: verify device is approved
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
const storeAllowFrom = await runtime.channel.pairing
|
|
273
|
-
.readAllowFromStore("clawg-ui")
|
|
274
|
-
.catch(() => []);
|
|
275
|
-
const normalizedAllowFrom = storeAllowFrom.map((e) =>
|
|
276
|
-
e.replace(/^clawg-ui:/i, "").toLowerCase(),
|
|
277
|
-
);
|
|
278
|
-
const allowed = normalizedAllowFrom.includes(deviceId.toLowerCase());
|
|
279
|
-
|
|
280
|
-
if (!allowed) {
|
|
281
|
-
sendJson(res, 403, {
|
|
282
|
-
error: {
|
|
283
|
-
type: "pairing_pending",
|
|
284
|
-
message: "Device pending approval. Ask the owner to approve using the pairing code from your initial pairing response.",
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ---------------------------------------------------------------------------
|
|
291
|
-
// Device approved - proceed with request
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
|
|
294
|
-
// Parse body
|
|
295
|
-
let body: unknown;
|
|
296
|
-
try {
|
|
297
|
-
body = await readJsonBody(req, 1024 * 1024);
|
|
298
|
-
} catch (err) {
|
|
299
|
-
sendJson(res, 400, {
|
|
300
|
-
error: { message: String(err), type: "invalid_request_error" },
|
|
301
|
-
});
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const input = body as RunAgentInput;
|
|
306
|
-
const threadId = input.threadId || `clawg-ui-${randomUUID()}`;
|
|
307
|
-
const runId = input.runId || `clawg-ui-run-${randomUUID()}`;
|
|
308
|
-
|
|
309
|
-
// Validate messages
|
|
310
|
-
const messages: Message[] = Array.isArray(input.messages)
|
|
311
|
-
? input.messages
|
|
312
|
-
: [];
|
|
313
|
-
|
|
314
|
-
const hasUserMessage = messages.some((m) => m.role === "user");
|
|
315
|
-
const hasToolMessage = messages.some((m) => m.role === "tool");
|
|
316
|
-
if (!hasUserMessage && !hasToolMessage) {
|
|
317
|
-
sendJson(res, 400, {
|
|
318
|
-
error: {
|
|
319
|
-
message: "At least one user or tool message is required in `messages`.",
|
|
320
|
-
type: "invalid_request_error",
|
|
321
|
-
},
|
|
322
|
-
});
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Build body from messages
|
|
327
|
-
const { body: messageBody } = buildBodyFromMessages(messages);
|
|
328
|
-
if (!messageBody.trim()) {
|
|
329
|
-
sendJson(res, 400, {
|
|
330
|
-
error: {
|
|
331
|
-
message: "Could not extract a prompt from `messages`.",
|
|
332
|
-
type: "invalid_request_error",
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Resolve agent route
|
|
339
|
-
const cfg = runtime.config.loadConfig();
|
|
340
|
-
const route = runtime.channel.routing.resolveAgentRoute({
|
|
341
|
-
cfg,
|
|
342
|
-
channel: "clawg-ui",
|
|
343
|
-
peer: { kind: "dm", id: `clawg-ui-${threadId}` },
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Set up SSE via EventEncoder
|
|
347
|
-
const accept =
|
|
348
|
-
typeof req.headers.accept === "string"
|
|
349
|
-
? req.headers.accept
|
|
350
|
-
: "text/event-stream";
|
|
351
|
-
const encoder = new EventEncoder({ accept });
|
|
352
|
-
res.statusCode = 200;
|
|
353
|
-
res.setHeader("Content-Type", encoder.getContentType());
|
|
354
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
355
|
-
res.setHeader("Connection", "keep-alive");
|
|
356
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
357
|
-
res.flushHeaders?.();
|
|
358
|
-
|
|
359
|
-
let closed = false;
|
|
360
|
-
let currentMessageId = `msg-${randomUUID()}`;
|
|
361
|
-
let messageStarted = false;
|
|
362
|
-
let currentRunId = runId;
|
|
363
|
-
|
|
364
|
-
const writeEvent = (event: { type: EventType } & Record<string, unknown>) => {
|
|
365
|
-
if (closed) {
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
try {
|
|
369
|
-
res.write(encoder.encode(event as Parameters<typeof encoder.encode>[0]));
|
|
370
|
-
} catch {
|
|
371
|
-
// Client may have disconnected
|
|
372
|
-
closed = true;
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
// If a tool call was emitted in the current run, finish that run and start
|
|
377
|
-
// a fresh one for text messages. This keeps tool events and text events in
|
|
378
|
-
// separate runs per the AG-UI protocol.
|
|
379
|
-
const splitRunIfToolFired = () => {
|
|
380
|
-
if (!wasToolFiredInRun(sessionKey)) {
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
// Close any open text message before ending the run
|
|
384
|
-
if (messageStarted) {
|
|
385
|
-
writeEvent({
|
|
386
|
-
type: EventType.TEXT_MESSAGE_END,
|
|
387
|
-
messageId: currentMessageId,
|
|
388
|
-
runId: currentRunId,
|
|
389
|
-
});
|
|
390
|
-
messageStarted = false;
|
|
391
|
-
}
|
|
392
|
-
// End the tool run
|
|
393
|
-
writeEvent({
|
|
394
|
-
type: EventType.RUN_FINISHED,
|
|
395
|
-
threadId,
|
|
396
|
-
runId: currentRunId,
|
|
397
|
-
});
|
|
398
|
-
// Start a new run for text messages
|
|
399
|
-
currentRunId = `clawg-ui-run-${randomUUID()}`;
|
|
400
|
-
currentMessageId = `msg-${randomUUID()}`;
|
|
401
|
-
messageStarted = false;
|
|
402
|
-
clearToolFiredInRun(sessionKey);
|
|
403
|
-
writeEvent({
|
|
404
|
-
type: EventType.RUN_STARTED,
|
|
405
|
-
threadId,
|
|
406
|
-
runId: currentRunId,
|
|
407
|
-
});
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
// Handle client disconnect
|
|
411
|
-
req.on("close", () => {
|
|
412
|
-
closed = true;
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// Emit RUN_STARTED
|
|
416
|
-
writeEvent({
|
|
417
|
-
type: EventType.RUN_STARTED,
|
|
418
|
-
threadId,
|
|
419
|
-
runId,
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
// Build inbound context using the plugin runtime (same pattern as msteams)
|
|
423
|
-
const sessionKey = route.sessionKey;
|
|
424
|
-
|
|
425
|
-
// Stash client-provided tools so the plugin tool factory can pick them up
|
|
426
|
-
if (Array.isArray(input.tools) && input.tools.length > 0) {
|
|
427
|
-
stashTools(sessionKey, input.tools);
|
|
428
|
-
markClientToolNames(
|
|
429
|
-
sessionKey,
|
|
430
|
-
input.tools.map((t: { name: string }) => t.name),
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Register SSE writer so before/after_tool_call hooks can emit AG-UI events
|
|
435
|
-
setWriter(sessionKey, writeEvent, currentMessageId);
|
|
436
|
-
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
437
|
-
agentId: route.agentId,
|
|
438
|
-
});
|
|
439
|
-
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
440
|
-
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
|
441
|
-
storePath,
|
|
442
|
-
sessionKey,
|
|
443
|
-
});
|
|
444
|
-
const envelopedBody = runtime.channel.reply.formatAgentEnvelope({
|
|
445
|
-
channel: "AG-UI",
|
|
446
|
-
from: "User",
|
|
447
|
-
timestamp: new Date(),
|
|
448
|
-
previousTimestamp,
|
|
449
|
-
envelope: envelopeOptions,
|
|
450
|
-
body: messageBody,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
454
|
-
Body: envelopedBody,
|
|
455
|
-
RawBody: messageBody,
|
|
456
|
-
CommandBody: messageBody,
|
|
457
|
-
From: `clawg-ui:${deviceId}`,
|
|
458
|
-
To: "clawg-ui",
|
|
459
|
-
SessionKey: sessionKey,
|
|
460
|
-
ChatType: "direct",
|
|
461
|
-
ConversationLabel: "AG-UI",
|
|
462
|
-
SenderName: "AG-UI Client",
|
|
463
|
-
SenderId: deviceId,
|
|
464
|
-
Provider: "clawg-ui" as const,
|
|
465
|
-
Surface: "clawg-ui" as const,
|
|
466
|
-
MessageSid: runId,
|
|
467
|
-
Timestamp: Date.now(),
|
|
468
|
-
WasMentioned: true,
|
|
469
|
-
CommandAuthorized: true,
|
|
470
|
-
OriginatingChannel: "clawg-ui" as const,
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
// Record inbound session
|
|
474
|
-
await runtime.channel.session.recordInboundSession({
|
|
475
|
-
storePath,
|
|
476
|
-
sessionKey,
|
|
477
|
-
ctx: ctxPayload,
|
|
478
|
-
onRecordError: () => {},
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
// Create reply dispatcher — translates reply payloads into AG-UI SSE events
|
|
482
|
-
const abortController = new AbortController();
|
|
483
|
-
req.on("close", () => {
|
|
484
|
-
abortController.abort();
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
const dispatcher = {
|
|
488
|
-
sendToolResult: (_payload: { text?: string }) => {
|
|
489
|
-
// Tool call events are emitted by before/after_tool_call hooks
|
|
490
|
-
return !closed;
|
|
491
|
-
},
|
|
492
|
-
sendBlockReply: (payload: { text?: string }) => {
|
|
493
|
-
if (closed || wasClientToolCalled(sessionKey)) {
|
|
494
|
-
return false;
|
|
495
|
-
}
|
|
496
|
-
const text = payload.text?.trim();
|
|
497
|
-
if (!text) {
|
|
498
|
-
return false;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
splitRunIfToolFired();
|
|
502
|
-
|
|
503
|
-
if (!messageStarted) {
|
|
504
|
-
messageStarted = true;
|
|
505
|
-
writeEvent({
|
|
506
|
-
type: EventType.TEXT_MESSAGE_START,
|
|
507
|
-
messageId: currentMessageId,
|
|
508
|
-
runId: currentRunId,
|
|
509
|
-
role: "assistant",
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
|
|
514
|
-
writeEvent({
|
|
515
|
-
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
516
|
-
messageId: currentMessageId,
|
|
517
|
-
runId: currentRunId,
|
|
518
|
-
delta: text + "\n\n",
|
|
519
|
-
});
|
|
520
|
-
return true;
|
|
521
|
-
},
|
|
522
|
-
sendFinalReply: (payload: { text?: string }) => {
|
|
523
|
-
if (closed) {
|
|
524
|
-
return false;
|
|
525
|
-
}
|
|
526
|
-
const text = wasClientToolCalled(sessionKey) ? "" : payload.text?.trim();
|
|
527
|
-
|
|
528
|
-
if (text) {
|
|
529
|
-
splitRunIfToolFired();
|
|
530
|
-
|
|
531
|
-
if (!messageStarted) {
|
|
532
|
-
messageStarted = true;
|
|
533
|
-
writeEvent({
|
|
534
|
-
type: EventType.TEXT_MESSAGE_START,
|
|
535
|
-
messageId: currentMessageId,
|
|
536
|
-
runId: currentRunId,
|
|
537
|
-
role: "assistant",
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
// Join chunks with \n\n (breakPreference: paragraph uses double-newline joiner)
|
|
541
|
-
writeEvent({
|
|
542
|
-
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
543
|
-
messageId: currentMessageId,
|
|
544
|
-
runId: currentRunId,
|
|
545
|
-
delta: text + "\n\n",
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
// End the message and run
|
|
549
|
-
if (messageStarted) {
|
|
550
|
-
writeEvent({
|
|
551
|
-
type: EventType.TEXT_MESSAGE_END,
|
|
552
|
-
messageId: currentMessageId,
|
|
553
|
-
runId: currentRunId,
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
writeEvent({
|
|
557
|
-
type: EventType.RUN_FINISHED,
|
|
558
|
-
threadId,
|
|
559
|
-
runId: currentRunId,
|
|
560
|
-
});
|
|
561
|
-
closed = true;
|
|
562
|
-
res.end();
|
|
563
|
-
return true;
|
|
564
|
-
},
|
|
565
|
-
waitForIdle: () => Promise.resolve(),
|
|
566
|
-
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
// Dispatch the inbound message — this triggers the agent run
|
|
570
|
-
try {
|
|
571
|
-
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
572
|
-
ctx: ctxPayload,
|
|
573
|
-
cfg,
|
|
574
|
-
dispatcher,
|
|
575
|
-
replyOptions: {
|
|
576
|
-
runId,
|
|
577
|
-
abortSignal: abortController.signal,
|
|
578
|
-
disableBlockStreaming: false,
|
|
579
|
-
onAgentRunStart: () => {},
|
|
580
|
-
onToolResult: () => {
|
|
581
|
-
// Tool call events are emitted by before/after_tool_call hooks
|
|
582
|
-
},
|
|
583
|
-
},
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// If the dispatcher's final reply didn't close the stream, close it now
|
|
587
|
-
if (!closed) {
|
|
588
|
-
if (messageStarted) {
|
|
589
|
-
writeEvent({
|
|
590
|
-
type: EventType.TEXT_MESSAGE_END,
|
|
591
|
-
messageId: currentMessageId,
|
|
592
|
-
runId: currentRunId,
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
writeEvent({
|
|
596
|
-
type: EventType.RUN_FINISHED,
|
|
597
|
-
threadId,
|
|
598
|
-
runId: currentRunId,
|
|
599
|
-
});
|
|
600
|
-
closed = true;
|
|
601
|
-
res.end();
|
|
602
|
-
}
|
|
603
|
-
} catch (err) {
|
|
604
|
-
if (!closed) {
|
|
605
|
-
writeEvent({
|
|
606
|
-
type: EventType.RUN_ERROR,
|
|
607
|
-
message: String(err),
|
|
608
|
-
});
|
|
609
|
-
closed = true;
|
|
610
|
-
res.end();
|
|
611
|
-
}
|
|
612
|
-
} finally {
|
|
613
|
-
clearWriter(sessionKey);
|
|
614
|
-
clearClientToolCalled(sessionKey);
|
|
615
|
-
clearClientToolNames(sessionKey);
|
|
616
|
-
clearToolFiredInRun(sessionKey);
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
}
|