@contextableai/clawg-ui 0.2.9 → 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.
@@ -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
+ }