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