openbot 0.4.0 → 0.4.2

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.
Files changed (50) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/config.js +10 -0
  3. package/dist/app/server.js +200 -3
  4. package/dist/harness/index.js +18 -0
  5. package/dist/plugins/approval/index.js +35 -20
  6. package/dist/plugins/bash/index.js +195 -0
  7. package/dist/plugins/delegation/index.js +4 -2
  8. package/dist/plugins/openbot/context.js +54 -9
  9. package/dist/plugins/openbot/history.js +47 -1
  10. package/dist/plugins/openbot/index.js +43 -3
  11. package/dist/plugins/openbot/runtime.js +91 -27
  12. package/dist/plugins/openbot/system-prompt.js +21 -1
  13. package/dist/plugins/plugin-manager/index.js +87 -3
  14. package/dist/plugins/shell/index.js +2 -1
  15. package/dist/plugins/storage/files.js +67 -0
  16. package/dist/plugins/storage/index.js +184 -7
  17. package/dist/plugins/storage/service.js +201 -44
  18. package/dist/plugins/ui/index.js +109 -150
  19. package/dist/services/abort.js +43 -0
  20. package/dist/services/plugins/registry.js +5 -3
  21. package/dist/services/plugins/service.js +66 -11
  22. package/docs/agents.md +5 -8
  23. package/docs/architecture.md +1 -1
  24. package/docs/plugins.md +28 -7
  25. package/docs/templates/AGENT.example.md +4 -4
  26. package/package.json +1 -1
  27. package/src/app/cli.ts +1 -1
  28. package/src/app/config.ts +13 -0
  29. package/src/app/server.ts +235 -3
  30. package/src/app/types.ts +284 -14
  31. package/src/harness/index.ts +21 -0
  32. package/src/plugins/approval/index.ts +37 -20
  33. package/src/plugins/bash/index.ts +232 -0
  34. package/src/plugins/delegation/index.ts +5 -2
  35. package/src/plugins/openbot/context.ts +58 -9
  36. package/src/plugins/openbot/history.ts +52 -1
  37. package/src/plugins/openbot/index.ts +45 -3
  38. package/src/plugins/openbot/runtime.ts +121 -27
  39. package/src/plugins/openbot/system-prompt.ts +21 -1
  40. package/src/plugins/plugin-manager/index.ts +105 -3
  41. package/src/plugins/storage/files.ts +81 -0
  42. package/src/plugins/storage/index.ts +198 -8
  43. package/src/plugins/storage/service.ts +267 -44
  44. package/src/plugins/ui/index.ts +123 -0
  45. package/src/services/abort.ts +46 -0
  46. package/src/services/plugins/domain.ts +34 -1
  47. package/src/services/plugins/registry.ts +5 -3
  48. package/src/services/plugins/service.ts +136 -45
  49. package/src/services/plugins/types.ts +5 -1
  50. package/src/plugins/shell/index.ts +0 -123
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_AGENTS_DIR,
5
5
  DEFAULT_BASE_DIR,
6
6
  DEFAULT_CHANNELS_DIR,
7
+ getDefaultChannelCwd,
7
8
  loadConfig,
8
9
  resolvePath,
9
10
  StoredVariable,
@@ -26,11 +27,15 @@ import {
26
27
  } from '../../services/plugins/domain.js';
27
28
  import type { PluginRef } from '../../services/plugins/types.js';
28
29
  import { openbotPlugin } from '../openbot/index.js';
29
- import { OPENBOT_SYSTEM_PROMPT } from '../openbot/system-prompt.js';
30
30
  import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
31
31
  import { OpenBotEvent, OpenBotState } from '../../app/types.js';
32
32
  import { processService } from '../../services/process.js';
33
33
  import { memoryService } from '../memory/service.js';
34
+ import {
35
+ guessMimeType,
36
+ resolveChannelFile,
37
+ statChannelFile,
38
+ } from './files.js';
34
39
 
35
40
  const resolveBaseDir = () => {
36
41
  const config = loadConfig();
@@ -110,15 +115,18 @@ const getConversationDir = (channelId: string, threadId?: string) => {
110
115
  const SYSTEM_AGENT_ID = ORCHESTRATOR_AGENT_ID;
111
116
 
112
117
  const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
113
- { id: 'openbot', config: { model: 'openai/gpt-5.4-mini' } },
114
- { id: 'shell' },
115
- { id: 'approval' },
116
- { id: 'memory' },
117
- { id: 'delegation' },
118
- { id: 'storage' },
118
+ {
119
+ id: 'openbot',
120
+ config: {
121
+ model: 'openai/gpt-5.4-mini',
122
+ approval: {
123
+ actions: ['action:bash', 'action:create_channel', 'action:delete_channel'],
124
+ },
125
+ },
126
+ },
119
127
  ];
120
128
 
121
- /** No `openbot` / `shell` — storage-side effects and infra plugins only. */
129
+ /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
122
130
  const STATE_DEFAULT_PLUGINS: PluginRef[] = [
123
131
  { id: 'storage' },
124
132
  { id: 'plugin-manager' },
@@ -134,7 +142,7 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
134
142
  image: getBundledSystemAgentImage(),
135
143
  description:
136
144
  'First-party orchestration agent for OpenBot.',
137
- instructions: OPENBOT_SYSTEM_PROMPT,
145
+ instructions: '',
138
146
  plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
139
147
  pluginRefs: SYSTEM_DEFAULT_PLUGINS,
140
148
  createdAt: new Date(),
@@ -232,6 +240,25 @@ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
232
240
  const getLastReadFilePath = () =>
233
241
  path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
234
242
 
243
+ /** Sentinel key for channel-root events (no threadId). */
244
+ export const ROOT_THREAD_KEY = '__root__';
245
+
246
+ export type LastReadMap = Record<string, Record<string, string>>;
247
+
248
+ const readLastReadMap = async (): Promise<LastReadMap> => {
249
+ const raw = await readJsonFile<Record<string, any>>(getLastReadFilePath(), {});
250
+ const map: LastReadMap = {};
251
+ for (const [channelId, value] of Object.entries(raw)) {
252
+ if (typeof value === 'string') {
253
+ // Migrate old format
254
+ map[channelId] = { [ROOT_THREAD_KEY]: value };
255
+ } else if (value && typeof value === 'object') {
256
+ map[channelId] = value as Record<string, string>;
257
+ }
258
+ }
259
+ return map;
260
+ };
261
+
235
262
  const THREAD_TITLE_MAX_LENGTH = 80;
236
263
 
237
264
  const buildThreadTitleFromEvent = (event: OpenBotEvent): string | undefined => {
@@ -257,9 +284,12 @@ const buildThreadTitleFromEvent = (event: OpenBotEvent): string | undefined => {
257
284
 
258
285
  const readJsonFile = async <T>(filePath: string, fallback: T): Promise<T> => {
259
286
  try {
260
- return JSON.parse(await fs.readFile(filePath, 'utf-8')) as T;
287
+ const content = (await fs.readFile(filePath, 'utf-8')).trim();
288
+ if (!content) return fallback;
289
+ return JSON.parse(content) as T;
261
290
  } catch (e: unknown) {
262
291
  if ((e as { code?: string })?.code === 'ENOENT') return fallback;
292
+ if (e instanceof SyntaxError) return fallback;
263
293
  throw e;
264
294
  }
265
295
  };
@@ -426,22 +456,27 @@ const serializePluginRefs = (refs: PluginRef[]): unknown[] =>
426
456
  refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
427
457
 
428
458
  export const storageService = {
429
- getLastReadByChannel: async (): Promise<Record<string, string>> => {
430
- return readJsonFile(getLastReadFilePath(), {});
459
+ getLastReadMap: async (): Promise<LastReadMap> => {
460
+ return readLastReadMap();
431
461
  },
432
462
 
433
- setLastReadForChannel: async ({
463
+ setLastRead: async ({
434
464
  channelId,
465
+ threadId,
435
466
  lastReadEventId,
436
467
  }: {
437
468
  channelId: string;
469
+ threadId?: string;
438
470
  lastReadEventId: string;
439
471
  }): Promise<void> => {
440
472
  const p = getLastReadFilePath();
441
473
  await fs.mkdir(path.dirname(p), { recursive: true });
442
- const map = await readJsonFile<Record<string, string>>(p, {});
443
- map[channelId] = lastReadEventId;
444
- await fs.writeFile(p, JSON.stringify(map, null, 2), 'utf-8');
474
+ const map = await readLastReadMap();
475
+ if (!map[channelId]) map[channelId] = {};
476
+ map[channelId][threadId || ROOT_THREAD_KEY] = lastReadEventId;
477
+ const tmp = `${p}.tmp`;
478
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
479
+ await fs.rename(tmp, p);
445
480
  },
446
481
 
447
482
  getChannels: async (): Promise<Channel[]> => {
@@ -455,11 +490,12 @@ export const storageService = {
455
490
  const channelNames = (await fs.readdir(channelsDir)).filter(
456
491
  (name) => !name.startsWith('.') && name !== '_meta',
457
492
  );
458
- const lastReadByChannel = await storageService.getLastReadByChannel();
493
+ const lastReadMap = await storageService.getLastReadMap();
459
494
 
460
495
  const channels = await Promise.all(
461
496
  channelNames.map(async (name) => {
462
497
  const channelDir = getConversationDir(name);
498
+ const stats = await fs.stat(channelDir);
463
499
  const statePath = path.join(channelDir, 'state.json');
464
500
  let cwd: string | undefined;
465
501
  let displayName = name;
@@ -482,24 +518,28 @@ export const storageService = {
482
518
  description: '',
483
519
  cwd,
484
520
  participants,
485
- createdAt: new Date(),
486
- updatedAt: new Date(),
521
+ createdAt: stats.birthtime,
522
+ updatedAt: stats.mtime,
487
523
  };
488
- const rid = lastReadByChannel[name];
489
- try {
490
- const events = await storageService.getEvents({ channelId: name });
491
- const latestId = events[events.length - 1]?.id;
492
- channel.hasUnseenMessages = !!(latestId && latestId !== rid);
493
- } catch {
494
- channel.hasUnseenMessages = false;
495
- }
496
524
 
525
+ const channelLastRead = lastReadMap[name] || {};
526
+
497
527
  try {
528
+ // Check root unread
529
+ const rootEvents = await storageService.getEvents({ channelId: name });
530
+ const rootLatestId = rootEvents[rootEvents.length - 1]?.id;
531
+ const rootUnseen = !!(rootLatestId && rootLatestId !== channelLastRead[ROOT_THREAD_KEY]);
532
+
533
+ // Check threads unread
498
534
  const allThreads = await storageService.getThreads({ channelId: name });
499
535
  channel.recentThreads = allThreads
500
536
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
501
537
  .slice(0, 5);
538
+
539
+ const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
540
+ channel.hasUnseenMessages = rootUnseen || threadsUnseen;
502
541
  } catch {
542
+ channel.hasUnseenMessages = false;
503
543
  channel.recentThreads = [];
504
544
  }
505
545
 
@@ -507,7 +547,7 @@ export const storageService = {
507
547
  }),
508
548
  );
509
549
 
510
- return channels;
550
+ return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
511
551
  },
512
552
  createChannel: async ({
513
553
  channelId,
@@ -539,22 +579,63 @@ export const storageService = {
539
579
  }
540
580
  }
541
581
 
542
- const finalState = {
582
+ const finalState: Record<string, unknown> = {
543
583
  ...(initialState || {}),
544
584
  };
545
585
 
546
- if (cwd) {
547
- (finalState as Record<string, unknown>).cwd = cwd;
548
- }
586
+ const rawCwd =
587
+ (typeof cwd === 'string' && cwd.trim()) ||
588
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
589
+ getDefaultChannelCwd(normalizedChannelId);
549
590
 
591
+ const resolvedCwd = resolvePath(rawCwd);
592
+ finalState.cwd = resolvedCwd;
593
+ await fs.mkdir(resolvedCwd, { recursive: true });
550
594
  await fs.mkdir(channelDir, { recursive: true });
551
595
  await fs.writeFile(
552
596
  specPath,
553
597
  spec?.trim() ||
554
- `# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`,
598
+ `# ${normalizedChannelId}\n\n`,
555
599
  );
556
600
  await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
557
601
  },
602
+ deleteChannel: async ({ channelId }: { channelId: string }): Promise<void> => {
603
+ const normalizedChannelId = channelId.trim();
604
+ if (!normalizedChannelId) {
605
+ throw new Error('channelId is required');
606
+ }
607
+ if (normalizedChannelId === '_meta') {
608
+ throw new Error('Cannot delete reserved channel path');
609
+ }
610
+
611
+ const channelDir = getConversationDir(normalizedChannelId);
612
+ try {
613
+ await fs.access(channelDir);
614
+ } catch (error: unknown) {
615
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
616
+ const err = new Error(`Channel "${normalizedChannelId}" does not exist.`);
617
+ (err as Error & { code?: string }).code = 'CHANNEL_NOT_FOUND';
618
+ throw err;
619
+ }
620
+ throw error;
621
+ }
622
+
623
+ await fs.rm(channelDir, { recursive: true, force: true });
624
+
625
+ try {
626
+ const lastReadPath = getLastReadFilePath();
627
+ const map = await readJsonFile<Record<string, string>>(lastReadPath, {});
628
+ if (normalizedChannelId in map) {
629
+ delete map[normalizedChannelId];
630
+ await fs.mkdir(path.dirname(lastReadPath), { recursive: true });
631
+ const tmp = `${lastReadPath}.tmp`;
632
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
633
+ await fs.rename(tmp, lastReadPath);
634
+ }
635
+ } catch {
636
+ // ignore last-read cleanup failures
637
+ }
638
+ },
558
639
  createThread: async ({
559
640
  channelId,
560
641
  threadId,
@@ -605,6 +686,8 @@ export const storageService = {
605
686
  return [];
606
687
  }
607
688
 
689
+ const lastReadMap = await storageService.getLastReadMap();
690
+ const channelLastRead = lastReadMap[channelId] || {};
608
691
  const threadNames = (await fs.readdir(threadsDir)).filter((name) => !name.startsWith('.'));
609
692
 
610
693
  const threads = await Promise.all(
@@ -631,12 +714,22 @@ export const storageService = {
631
714
  }
632
715
  }
633
716
 
717
+ let hasUnseen = false;
718
+ try {
719
+ const events = await storageService.getEvents({ channelId, threadId: name });
720
+ const latestId = events[events.length - 1]?.id;
721
+ hasUnseen = !!(latestId && latestId !== channelLastRead[name]);
722
+ } catch {
723
+ // ignore
724
+ }
725
+
634
726
  return {
635
727
  id: name,
636
728
  name: threadDisplayName,
637
729
  channelId,
638
730
  createdAt: stats.birthtime,
639
731
  updatedAt: stats.mtime,
732
+ hasUnseenMessages: hasUnseen,
640
733
  };
641
734
  }),
642
735
  );
@@ -1357,12 +1450,9 @@ export const storageService = {
1357
1450
  throw new Error('Channel has no CWD configured');
1358
1451
  }
1359
1452
 
1360
- const resolvedBase = path.resolve(baseCwd);
1361
- const targetDir = path.resolve(resolvedBase, subPath);
1362
-
1363
- if (!targetDir.startsWith(resolvedBase)) {
1364
- throw new Error('Access denied: directory escape');
1365
- }
1453
+ const targetDir = subPath
1454
+ ? resolveChannelFile(baseCwd, subPath)
1455
+ : resolvePath(baseCwd);
1366
1456
 
1367
1457
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1368
1458
  return entries
@@ -1387,14 +1477,142 @@ export const storageService = {
1387
1477
  throw new Error('Channel has no CWD configured');
1388
1478
  }
1389
1479
 
1390
- const resolvedBase = path.resolve(baseCwd);
1391
- const targetFile = path.resolve(resolvedBase, filePath);
1480
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1481
+ return fs.readFile(targetFile, 'utf-8');
1482
+ },
1483
+
1484
+ readChannelFile: async ({
1485
+ channelId,
1486
+ path: filePath,
1487
+ encoding = 'utf8',
1488
+ }: {
1489
+ channelId: string;
1490
+ path: string;
1491
+ encoding?: 'utf8' | 'base64';
1492
+ }): Promise<{ content: string; mimeType: string; size: number }> => {
1493
+ const details = await storageService.getChannelDetails({ channelId });
1494
+ const baseCwd = details.cwd;
1392
1495
 
1393
- if (!targetFile.startsWith(resolvedBase)) {
1394
- throw new Error('Access denied: directory escape');
1496
+ if (!baseCwd) {
1497
+ throw new Error('Channel has no CWD configured');
1395
1498
  }
1396
1499
 
1397
- return fs.readFile(targetFile, 'utf-8');
1500
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1501
+ const buf = await fs.readFile(targetFile);
1502
+ const content =
1503
+ encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1504
+
1505
+ return {
1506
+ content,
1507
+ mimeType: guessMimeType(targetFile),
1508
+ size: buf.length,
1509
+ };
1510
+ },
1511
+
1512
+ writeChannelFile: async ({
1513
+ channelId,
1514
+ path: filePath,
1515
+ content,
1516
+ encoding = 'utf8',
1517
+ overwrite = false,
1518
+ }: {
1519
+ channelId: string;
1520
+ path: string;
1521
+ content: string;
1522
+ encoding?: 'utf8' | 'base64';
1523
+ overwrite?: boolean;
1524
+ }): Promise<{ path: string; size: number; mimeType: string }> => {
1525
+ const details = await storageService.getChannelDetails({ channelId });
1526
+ const baseCwd = details.cwd;
1527
+
1528
+ if (!baseCwd) {
1529
+ throw new Error('Channel has no CWD configured');
1530
+ }
1531
+
1532
+ const abs = resolveChannelFile(baseCwd, filePath);
1533
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1534
+
1535
+ if (!overwrite) {
1536
+ try {
1537
+ await fs.access(abs);
1538
+ throw new Error('File already exists');
1539
+ } catch (error: unknown) {
1540
+ const code = (error as NodeJS.ErrnoException)?.code;
1541
+ if (code !== 'ENOENT') {
1542
+ throw error;
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ const buf =
1548
+ encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
1549
+ await fs.writeFile(abs, buf);
1550
+
1551
+ return {
1552
+ path: filePath,
1553
+ size: buf.length,
1554
+ mimeType: guessMimeType(abs),
1555
+ };
1556
+ },
1557
+
1558
+ uploadChannelFile: async ({
1559
+ channelId,
1560
+ path: filePath,
1561
+ body,
1562
+ overwrite = false,
1563
+ }: {
1564
+ channelId: string;
1565
+ path: string;
1566
+ body: Buffer;
1567
+ overwrite?: boolean;
1568
+ }): Promise<{ path: string; size: number; mimeType: string }> => {
1569
+ const details = await storageService.getChannelDetails({ channelId });
1570
+ const baseCwd = details.cwd;
1571
+
1572
+ if (!baseCwd) {
1573
+ throw new Error('Channel has no CWD configured');
1574
+ }
1575
+
1576
+ const abs = resolveChannelFile(baseCwd, filePath);
1577
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1578
+
1579
+ if (!overwrite) {
1580
+ try {
1581
+ await fs.access(abs);
1582
+ throw new Error('File already exists');
1583
+ } catch (error: unknown) {
1584
+ const code = (error as NodeJS.ErrnoException)?.code;
1585
+ if (code !== 'ENOENT') {
1586
+ throw error;
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ await fs.writeFile(abs, body);
1592
+
1593
+ return {
1594
+ path: filePath,
1595
+ size: body.length,
1596
+ mimeType: guessMimeType(abs),
1597
+ };
1598
+ },
1599
+
1600
+ getChannelFileStat: async ({
1601
+ channelId,
1602
+ path: filePath,
1603
+ }: {
1604
+ channelId: string;
1605
+ path: string;
1606
+ }): Promise<{ abs: string; size: number; mimeType: string }> => {
1607
+ const details = await storageService.getChannelDetails({ channelId });
1608
+ const baseCwd = details.cwd;
1609
+
1610
+ if (!baseCwd) {
1611
+ throw new Error('Channel has no CWD configured');
1612
+ }
1613
+
1614
+ const { abs, size } = await statChannelFile(baseCwd, filePath);
1615
+ return { abs, size, mimeType: guessMimeType(abs) };
1398
1616
  },
1399
1617
 
1400
1618
  appendMemory: memoryService.appendMemory,
@@ -1443,12 +1661,17 @@ export const storageService = {
1443
1661
  }
1444
1662
  }
1445
1663
 
1664
+ const threadState = (threadDetails?.state as Record<string, unknown>) || {};
1665
+
1446
1666
  return {
1447
1667
  runId,
1448
1668
  agentId,
1449
1669
  channelId,
1450
1670
  threadId,
1451
1671
  triggerEvent: event,
1672
+ pendingToolCallIds: Array.isArray(threadState.pendingToolCallIds)
1673
+ ? (threadState.pendingToolCallIds as string[])
1674
+ : undefined,
1452
1675
  agentDetails: {
1453
1676
  id: agentDetails.id,
1454
1677
  name: agentDetails.name,
@@ -0,0 +1,123 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import type { Plugin } from '../../services/plugins/types.js';
4
+ import { OpenBotEvent, RenderWidgetEvent, UIWidgetResponseEvent } from '../../app/types.js';
5
+
6
+ /**
7
+ * `ui` — provides a tool for the agent to render interactive UI widgets.
8
+ *
9
+ * The model can choose which widget to render (form, choice, list, message)
10
+ * depending on the situation.
11
+ */
12
+
13
+ export const uiPlugin: Plugin = {
14
+ id: 'ui',
15
+ name: 'UI',
16
+ description: 'Render interactive UI widgets to interact with the user.',
17
+ toolDefinitions: {
18
+ render_widget: {
19
+ description: 'Render a UI widget to the user. Use "form" for data collection, "choice" for simple selection, "list" for displaying items, and "message" for simple notifications with actions. When using form widge to unquire user, do not provide complex forms with many fields, always try to make it simple to keep the experience smooth and straightforward.',
20
+ inputSchema: z.object({
21
+ kind: z.enum(['message', 'choice', 'form', 'list']).describe('The type of widget to render.'),
22
+ title: z.string().describe('The title of the widget.'),
23
+ description: z.string().optional().describe('A description or body text.'),
24
+ fields: z.array(z.object({
25
+ id: z.string().describe('Unique ID for the field.'),
26
+ label: z.string().describe('Label shown to the user.'),
27
+ type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
28
+ description: z.string().optional(),
29
+ placeholder: z.string().optional(),
30
+ required: z.boolean().optional(),
31
+ options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
32
+ defaultValue: z.any().optional()
33
+ })).optional().describe('Required for kind="form". List of form fields.'),
34
+ actions: z.array(z.object({
35
+ id: z.string(),
36
+ label: z.string(),
37
+ variant: z.enum(['primary', 'secondary', 'danger']).optional(),
38
+ })).optional().describe('Buttons or actions available on the widget.'),
39
+ items: z.array(z.object({
40
+ id: z.string(),
41
+ label: z.string(),
42
+ description: z.string().optional(),
43
+ status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
44
+ metadata: z.record(z.string(), z.any()).optional()
45
+ })).optional().describe('Required for kind="list". List of items to display.'),
46
+ submitLabel: z.string().optional().describe('Label for the primary action button (e.g. "Submit", "Save").')
47
+ })
48
+ }
49
+ },
50
+ factory: () => (builder) => {
51
+ // Handle the tool call from the agent
52
+ builder.on('action:render_widget', async function* (event, context) {
53
+ const widgetEvent = event as RenderWidgetEvent;
54
+ const toolCallId = widgetEvent.meta?.toolCallId;
55
+ const threadId = widgetEvent.meta?.threadId || context.state.threadId;
56
+
57
+ if (!toolCallId) return;
58
+
59
+ const widgetId = randomUUID();
60
+
61
+ // Emit the UI widget event to the client
62
+ yield {
63
+ type: 'client:ui:widget',
64
+ data: {
65
+ ...widgetEvent.data,
66
+ widgetId,
67
+ metadata: {
68
+ type: 'ui:request',
69
+ originalEvent: widgetEvent
70
+ }
71
+ },
72
+ meta: { agentId: context.state.agentId, threadId }
73
+ } as OpenBotEvent;
74
+ });
75
+
76
+ // Handle the user's response from the UI widget
77
+ builder.on('client:ui:widget:response', async function* (event, context) {
78
+ const responseEvent = event as UIWidgetResponseEvent;
79
+ const { widgetId, actionId, values, metadata } = responseEvent.data;
80
+ if (metadata?.type !== 'ui:request') return;
81
+
82
+ const originalEvent = metadata.originalEvent as RenderWidgetEvent;
83
+ const toolCallId = originalEvent?.meta?.toolCallId;
84
+ const threadId = originalEvent?.meta?.threadId || context.state.threadId;
85
+
86
+ if (!toolCallId) return;
87
+
88
+ // Yield a "submitted" widget update to the UI to collapse/disable it
89
+ yield {
90
+ type: 'client:ui:widget',
91
+ data: {
92
+ widgetId,
93
+ title: originalEvent.data.title,
94
+ kind: originalEvent.data.kind as any,
95
+ state: 'submitted',
96
+ body: "Thank you for your response. We will process it and get back to you soon.",
97
+ display: 'collapsed',
98
+ disabled: true,
99
+ actions: [], // Clear actions to disable buttons in UI
100
+ },
101
+ meta: { agentId: context.state.agentId, threadId },
102
+ } as OpenBotEvent;
103
+
104
+ // Emit the tool result event so the agent runtime can resume
105
+ yield {
106
+ type: 'action:render_widget:result',
107
+ data: {
108
+ success: true,
109
+ actionId,
110
+ values,
111
+ output: JSON.stringify(values)
112
+ },
113
+ meta: {
114
+ agentId: context.state.agentId,
115
+ threadId,
116
+ toolCallId
117
+ }
118
+ } as any as OpenBotEvent;
119
+ });
120
+ },
121
+ };
122
+
123
+ export default uiPlugin;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tracks in-flight agent runs so they can be cancelled.
3
+ *
4
+ * Runs are grouped by `channelId:threadId`. Delegated sub-agents run in the
5
+ * same channel/thread as their parent, so aborting that key stops the whole
6
+ * chain (parent + any delegated runs) in one shot.
7
+ */
8
+
9
+ export const abortKey = (channelId: string, threadId?: string): string =>
10
+ `${channelId}:${threadId || ''}`;
11
+
12
+ class AbortRegistry {
13
+ private entries = new Map<string, { controller: AbortController; refs: number }>();
14
+
15
+ /** Register interest in a run. Returns a shared signal for the key. */
16
+ acquire(key: string): AbortSignal {
17
+ let entry = this.entries.get(key);
18
+ if (!entry) {
19
+ entry = { controller: new AbortController(), refs: 0 };
20
+ this.entries.set(key, entry);
21
+ }
22
+ entry.refs += 1;
23
+ return entry.controller.signal;
24
+ }
25
+
26
+ /** Release interest. Removes the entry once no runs reference it. */
27
+ release(key: string): void {
28
+ const entry = this.entries.get(key);
29
+ if (!entry) return;
30
+ entry.refs -= 1;
31
+ if (entry.refs <= 0) {
32
+ this.entries.delete(key);
33
+ }
34
+ }
35
+
36
+ /** Abort all runs for the key. Returns true if something was active. */
37
+ abort(key: string): boolean {
38
+ const entry = this.entries.get(key);
39
+ if (!entry) return false;
40
+ entry.controller.abort();
41
+ this.entries.delete(key);
42
+ return true;
43
+ }
44
+ }
45
+
46
+ export const abortRegistry = new AbortRegistry();