openbot 0.4.0 → 0.4.3

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 +6 -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 +215 -59
  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 +7 -7
  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 +7 -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 +282 -59
  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,13 +284,23 @@ 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
  };
266
296
 
297
+ const writeJsonFileAtomically = async (filePath: string, data: unknown): Promise<void> => {
298
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
299
+ const tmp = `${filePath}.tmp`;
300
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
301
+ await fs.rename(tmp, filePath);
302
+ };
303
+
267
304
  const toVariablesRecord = (raw: unknown): Record<string, string> => {
268
305
  if (!raw || typeof raw !== 'object') {
269
306
  return {};
@@ -426,22 +463,27 @@ const serializePluginRefs = (refs: PluginRef[]): unknown[] =>
426
463
  refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
427
464
 
428
465
  export const storageService = {
429
- getLastReadByChannel: async (): Promise<Record<string, string>> => {
430
- return readJsonFile(getLastReadFilePath(), {});
466
+ getLastReadMap: async (): Promise<LastReadMap> => {
467
+ return readLastReadMap();
431
468
  },
432
469
 
433
- setLastReadForChannel: async ({
470
+ setLastRead: async ({
434
471
  channelId,
472
+ threadId,
435
473
  lastReadEventId,
436
474
  }: {
437
475
  channelId: string;
476
+ threadId?: string;
438
477
  lastReadEventId: string;
439
478
  }): Promise<void> => {
440
479
  const p = getLastReadFilePath();
441
480
  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');
481
+ const map = await readLastReadMap();
482
+ if (!map[channelId]) map[channelId] = {};
483
+ map[channelId][threadId || ROOT_THREAD_KEY] = lastReadEventId;
484
+ const tmp = `${p}.tmp`;
485
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
486
+ await fs.rename(tmp, p);
445
487
  },
446
488
 
447
489
  getChannels: async (): Promise<Channel[]> => {
@@ -455,19 +497,19 @@ export const storageService = {
455
497
  const channelNames = (await fs.readdir(channelsDir)).filter(
456
498
  (name) => !name.startsWith('.') && name !== '_meta',
457
499
  );
458
- const lastReadByChannel = await storageService.getLastReadByChannel();
500
+ const lastReadMap = await storageService.getLastReadMap();
459
501
 
460
502
  const channels = await Promise.all(
461
503
  channelNames.map(async (name) => {
462
504
  const channelDir = getConversationDir(name);
505
+ const stats = await fs.stat(channelDir);
463
506
  const statePath = path.join(channelDir, 'state.json');
464
507
  let cwd: string | undefined;
465
508
  let displayName = name;
466
509
  let participants: string[] = [];
467
510
 
468
511
  try {
469
- const stateContent = await fs.readFile(statePath, 'utf-8');
470
- const parsed = JSON.parse(stateContent);
512
+ const parsed = await readJsonFile(statePath, {});
471
513
  const fields = readChannelStateFileFields(parsed);
472
514
  cwd = fields.cwd;
473
515
  displayName = fields.name ?? name;
@@ -482,24 +524,28 @@ export const storageService = {
482
524
  description: '',
483
525
  cwd,
484
526
  participants,
485
- createdAt: new Date(),
486
- updatedAt: new Date(),
527
+ createdAt: stats.birthtime,
528
+ updatedAt: stats.mtime,
487
529
  };
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
530
 
531
+ const channelLastRead = lastReadMap[name] || {};
532
+
497
533
  try {
534
+ // Check root unread
535
+ const rootEvents = await storageService.getEvents({ channelId: name });
536
+ const rootLatestId = rootEvents[rootEvents.length - 1]?.id;
537
+ const rootUnseen = !!(rootLatestId && rootLatestId !== channelLastRead[ROOT_THREAD_KEY]);
538
+
539
+ // Check threads unread
498
540
  const allThreads = await storageService.getThreads({ channelId: name });
499
541
  channel.recentThreads = allThreads
500
542
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
501
543
  .slice(0, 5);
544
+
545
+ const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
546
+ channel.hasUnseenMessages = rootUnseen || threadsUnseen;
502
547
  } catch {
548
+ channel.hasUnseenMessages = false;
503
549
  channel.recentThreads = [];
504
550
  }
505
551
 
@@ -507,7 +553,7 @@ export const storageService = {
507
553
  }),
508
554
  );
509
555
 
510
- return channels;
556
+ return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
511
557
  },
512
558
  createChannel: async ({
513
559
  channelId,
@@ -539,21 +585,62 @@ export const storageService = {
539
585
  }
540
586
  }
541
587
 
542
- const finalState = {
588
+ const finalState: Record<string, unknown> = {
543
589
  ...(initialState || {}),
544
590
  };
545
591
 
546
- if (cwd) {
547
- (finalState as Record<string, unknown>).cwd = cwd;
548
- }
592
+ const rawCwd =
593
+ (typeof cwd === 'string' && cwd.trim()) ||
594
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
595
+ getDefaultChannelCwd(normalizedChannelId);
549
596
 
597
+ const resolvedCwd = resolvePath(rawCwd);
598
+ finalState.cwd = resolvedCwd;
599
+ await fs.mkdir(resolvedCwd, { recursive: true });
550
600
  await fs.mkdir(channelDir, { recursive: true });
551
601
  await fs.writeFile(
552
602
  specPath,
553
603
  spec?.trim() ||
554
- `# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`,
604
+ `# ${normalizedChannelId}\n\n`,
555
605
  );
556
- await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
606
+ await writeJsonFileAtomically(statePath, finalState);
607
+ },
608
+ deleteChannel: async ({ channelId }: { channelId: string }): Promise<void> => {
609
+ const normalizedChannelId = channelId.trim();
610
+ if (!normalizedChannelId) {
611
+ throw new Error('channelId is required');
612
+ }
613
+ if (normalizedChannelId === '_meta') {
614
+ throw new Error('Cannot delete reserved channel path');
615
+ }
616
+
617
+ const channelDir = getConversationDir(normalizedChannelId);
618
+ try {
619
+ await fs.access(channelDir);
620
+ } catch (error: unknown) {
621
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
622
+ const err = new Error(`Channel "${normalizedChannelId}" does not exist.`);
623
+ (err as Error & { code?: string }).code = 'CHANNEL_NOT_FOUND';
624
+ throw err;
625
+ }
626
+ throw error;
627
+ }
628
+
629
+ await fs.rm(channelDir, { recursive: true, force: true });
630
+
631
+ try {
632
+ const lastReadPath = getLastReadFilePath();
633
+ const map = await readJsonFile<Record<string, string>>(lastReadPath, {});
634
+ if (normalizedChannelId in map) {
635
+ delete map[normalizedChannelId];
636
+ await fs.mkdir(path.dirname(lastReadPath), { recursive: true });
637
+ const tmp = `${lastReadPath}.tmp`;
638
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
639
+ await fs.rename(tmp, lastReadPath);
640
+ }
641
+ } catch {
642
+ // ignore last-read cleanup failures
643
+ }
557
644
  },
558
645
  createThread: async ({
559
646
  channelId,
@@ -592,8 +679,7 @@ export const storageService = {
592
679
  baseState.name = threadTitle.trim();
593
680
  }
594
681
 
595
- await fs.mkdir(threadDir, { recursive: true });
596
- await fs.writeFile(statePath, JSON.stringify(baseState, null, 2));
682
+ await writeJsonFileAtomically(statePath, baseState);
597
683
  },
598
684
  getThreads: async ({ channelId }: { channelId: string }): Promise<Thread[]> => {
599
685
  const threadsDir = resolvePath(
@@ -605,6 +691,8 @@ export const storageService = {
605
691
  return [];
606
692
  }
607
693
 
694
+ const lastReadMap = await storageService.getLastReadMap();
695
+ const channelLastRead = lastReadMap[channelId] || {};
608
696
  const threadNames = (await fs.readdir(threadsDir)).filter((name) => !name.startsWith('.'));
609
697
 
610
698
  const threads = await Promise.all(
@@ -615,8 +703,7 @@ export const storageService = {
615
703
  let threadDisplayName = name;
616
704
 
617
705
  try {
618
- const threadStateRaw = await fs.readFile(threadStatePath, 'utf-8');
619
- const threadState = JSON.parse(threadStateRaw) as Record<string, unknown>;
706
+ const threadState = await readJsonFile<Record<string, unknown>>(threadStatePath, {});
620
707
  const threadName =
621
708
  typeof threadState.name === 'string' ? threadState.name.trim() : '';
622
709
  if (threadName) {
@@ -631,12 +718,22 @@ export const storageService = {
631
718
  }
632
719
  }
633
720
 
721
+ let hasUnseen = false;
722
+ try {
723
+ const events = await storageService.getEvents({ channelId, threadId: name });
724
+ const latestId = events[events.length - 1]?.id;
725
+ hasUnseen = !!(latestId && latestId !== channelLastRead[name]);
726
+ } catch {
727
+ // ignore
728
+ }
729
+
634
730
  return {
635
731
  id: name,
636
732
  name: threadDisplayName,
637
733
  channelId,
638
734
  createdAt: stats.birthtime,
639
735
  updatedAt: stats.mtime,
736
+ hasUnseenMessages: hasUnseen,
640
737
  };
641
738
  }),
642
739
  );
@@ -655,8 +752,7 @@ export const storageService = {
655
752
 
656
753
  let state: unknown = {};
657
754
  try {
658
- const stateContent = await fs.readFile(statePath, 'utf-8');
659
- state = JSON.parse(stateContent);
755
+ state = await readJsonFile(statePath, {});
660
756
  } catch (error: unknown) {
661
757
  if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
662
758
  console.error(
@@ -694,8 +790,7 @@ export const storageService = {
694
790
 
695
791
  let state: unknown = {};
696
792
  try {
697
- const stateContent = await fs.readFile(statePath, 'utf-8');
698
- state = JSON.parse(stateContent);
793
+ state = await readJsonFile(statePath, {});
699
794
  } catch (error: unknown) {
700
795
  if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
701
796
  console.error(`Failed to read state file for channel ${channelId}`, error);
@@ -738,8 +833,7 @@ export const storageService = {
738
833
  ...(patch as Record<string, unknown>),
739
834
  };
740
835
 
741
- await fs.mkdir(channelDir, { recursive: true });
742
- await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
836
+ await writeJsonFileAtomically(statePath, newState);
743
837
  } catch (error) {
744
838
  console.error(`Failed to patch channel state for channel ${channelId}`, error);
745
839
  throw error;
@@ -766,8 +860,7 @@ export const storageService = {
766
860
  ...(patch as Record<string, unknown>),
767
861
  };
768
862
 
769
- await fs.mkdir(threadDir, { recursive: true });
770
- await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
863
+ await writeJsonFileAtomically(statePath, newState);
771
864
  } catch (error) {
772
865
  console.error(
773
866
  `Failed to patch thread state for channel ${channelId} thread ${threadId}`,
@@ -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
+ },
1392
1483
 
1393
- if (!targetFile.startsWith(resolvedBase)) {
1394
- throw new Error('Access denied: directory escape');
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;
1495
+
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;