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
@@ -1,5 +1,5 @@
1
1
  import { ORCHESTRATOR_AGENT_ID, STATE_AGENT_ID } from '../../app/agent-ids.js';
2
- import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../../app/config.js';
2
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, getDefaultChannelCwd, loadConfig, resolvePath, VARIABLES_FILE, } from '../../app/config.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import path from 'node:path';
@@ -7,10 +7,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import crypto from 'node:crypto';
8
8
  import matter from 'gray-matter';
9
9
  import { openbotPlugin } from '../openbot/index.js';
10
- import { OPENBOT_SYSTEM_PROMPT } from '../openbot/system-prompt.js';
11
10
  import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
12
11
  import { processService } from '../../services/process.js';
13
12
  import { memoryService } from '../memory/service.js';
13
+ import { guessMimeType, resolveChannelFile, statChannelFile, } from './files.js';
14
14
  const resolveBaseDir = () => {
15
15
  const config = loadConfig();
16
16
  return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
@@ -80,14 +80,17 @@ const getConversationDir = (channelId, threadId) => {
80
80
  /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
81
81
  const SYSTEM_AGENT_ID = ORCHESTRATOR_AGENT_ID;
82
82
  const SYSTEM_DEFAULT_PLUGINS = [
83
- { id: 'openbot', config: { model: 'openai/gpt-5.4-mini' } },
84
- { id: 'shell' },
85
- { id: 'approval' },
86
- { id: 'memory' },
87
- { id: 'delegation' },
88
- { id: 'storage' },
83
+ {
84
+ id: 'openbot',
85
+ config: {
86
+ model: 'openai/gpt-5.4-mini',
87
+ approval: {
88
+ actions: ['action:bash', 'action:create_channel', 'action:delete_channel'],
89
+ },
90
+ },
91
+ },
89
92
  ];
90
- /** No `openbot` / `shell` — storage-side effects and infra plugins only. */
93
+ /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
91
94
  const STATE_DEFAULT_PLUGINS = [
92
95
  { id: 'storage' },
93
96
  { id: 'plugin-manager' },
@@ -99,7 +102,7 @@ function getSystemAgentDetails(overrides) {
99
102
  name: 'OpenBot',
100
103
  image: getBundledSystemAgentImage(),
101
104
  description: 'First-party orchestration agent for OpenBot.',
102
- instructions: OPENBOT_SYSTEM_PROMPT,
105
+ instructions: '',
103
106
  plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
104
107
  pluginRefs: SYSTEM_DEFAULT_PLUGINS,
105
108
  createdAt: new Date(),
@@ -179,6 +182,22 @@ const assertAgentIdFormat = (agentId) => {
179
182
  };
180
183
  const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
181
184
  const getLastReadFilePath = () => path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
185
+ /** Sentinel key for channel-root events (no threadId). */
186
+ export const ROOT_THREAD_KEY = '__root__';
187
+ const readLastReadMap = async () => {
188
+ const raw = await readJsonFile(getLastReadFilePath(), {});
189
+ const map = {};
190
+ for (const [channelId, value] of Object.entries(raw)) {
191
+ if (typeof value === 'string') {
192
+ // Migrate old format
193
+ map[channelId] = { [ROOT_THREAD_KEY]: value };
194
+ }
195
+ else if (value && typeof value === 'object') {
196
+ map[channelId] = value;
197
+ }
198
+ }
199
+ return map;
200
+ };
182
201
  const THREAD_TITLE_MAX_LENGTH = 80;
183
202
  const buildThreadTitleFromEvent = (event) => {
184
203
  let rawContent = '';
@@ -197,11 +216,16 @@ const buildThreadTitleFromEvent = (event) => {
197
216
  };
198
217
  const readJsonFile = async (filePath, fallback) => {
199
218
  try {
200
- return JSON.parse(await fs.readFile(filePath, 'utf-8'));
219
+ const content = (await fs.readFile(filePath, 'utf-8')).trim();
220
+ if (!content)
221
+ return fallback;
222
+ return JSON.parse(content);
201
223
  }
202
224
  catch (e) {
203
225
  if (e?.code === 'ENOENT')
204
226
  return fallback;
227
+ if (e instanceof SyntaxError)
228
+ return fallback;
205
229
  throw e;
206
230
  }
207
231
  };
@@ -352,15 +376,19 @@ const parsePluginRefs = (raw) => {
352
376
  };
353
377
  const serializePluginRefs = (refs) => refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
354
378
  export const storageService = {
355
- getLastReadByChannel: async () => {
356
- return readJsonFile(getLastReadFilePath(), {});
379
+ getLastReadMap: async () => {
380
+ return readLastReadMap();
357
381
  },
358
- setLastReadForChannel: async ({ channelId, lastReadEventId, }) => {
382
+ setLastRead: async ({ channelId, threadId, lastReadEventId, }) => {
359
383
  const p = getLastReadFilePath();
360
384
  await fs.mkdir(path.dirname(p), { recursive: true });
361
- const map = await readJsonFile(p, {});
362
- map[channelId] = lastReadEventId;
363
- await fs.writeFile(p, JSON.stringify(map, null, 2), 'utf-8');
385
+ const map = await readLastReadMap();
386
+ if (!map[channelId])
387
+ map[channelId] = {};
388
+ map[channelId][threadId || ROOT_THREAD_KEY] = lastReadEventId;
389
+ const tmp = `${p}.tmp`;
390
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
391
+ await fs.rename(tmp, p);
364
392
  },
365
393
  getChannels: async () => {
366
394
  const channelsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR);
@@ -371,9 +399,10 @@ export const storageService = {
371
399
  await fs.mkdir(channelsDir, { recursive: true });
372
400
  }
373
401
  const channelNames = (await fs.readdir(channelsDir)).filter((name) => !name.startsWith('.') && name !== '_meta');
374
- const lastReadByChannel = await storageService.getLastReadByChannel();
402
+ const lastReadMap = await storageService.getLastReadMap();
375
403
  const channels = await Promise.all(channelNames.map(async (name) => {
376
404
  const channelDir = getConversationDir(name);
405
+ const stats = await fs.stat(channelDir);
377
406
  const statePath = path.join(channelDir, 'state.json');
378
407
  let cwd;
379
408
  let displayName = name;
@@ -395,30 +424,30 @@ export const storageService = {
395
424
  description: '',
396
425
  cwd,
397
426
  participants,
398
- createdAt: new Date(),
399
- updatedAt: new Date(),
427
+ createdAt: stats.birthtime,
428
+ updatedAt: stats.mtime,
400
429
  };
401
- const rid = lastReadByChannel[name];
402
- try {
403
- const events = await storageService.getEvents({ channelId: name });
404
- const latestId = events[events.length - 1]?.id;
405
- channel.hasUnseenMessages = !!(latestId && latestId !== rid);
406
- }
407
- catch {
408
- channel.hasUnseenMessages = false;
409
- }
430
+ const channelLastRead = lastReadMap[name] || {};
410
431
  try {
432
+ // Check root unread
433
+ const rootEvents = await storageService.getEvents({ channelId: name });
434
+ const rootLatestId = rootEvents[rootEvents.length - 1]?.id;
435
+ const rootUnseen = !!(rootLatestId && rootLatestId !== channelLastRead[ROOT_THREAD_KEY]);
436
+ // Check threads unread
411
437
  const allThreads = await storageService.getThreads({ channelId: name });
412
438
  channel.recentThreads = allThreads
413
439
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
414
440
  .slice(0, 5);
441
+ const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
442
+ channel.hasUnseenMessages = rootUnseen || threadsUnseen;
415
443
  }
416
444
  catch {
445
+ channel.hasUnseenMessages = false;
417
446
  channel.recentThreads = [];
418
447
  }
419
448
  return channel;
420
449
  }));
421
- return channels;
450
+ return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
422
451
  },
423
452
  createChannel: async ({ channelId, spec, initialState, cwd, }) => {
424
453
  const normalizedChannelId = channelId.trim();
@@ -441,14 +470,53 @@ export const storageService = {
441
470
  const finalState = {
442
471
  ...(initialState || {}),
443
472
  };
444
- if (cwd) {
445
- finalState.cwd = cwd;
446
- }
473
+ const rawCwd = (typeof cwd === 'string' && cwd.trim()) ||
474
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
475
+ getDefaultChannelCwd(normalizedChannelId);
476
+ const resolvedCwd = resolvePath(rawCwd);
477
+ finalState.cwd = resolvedCwd;
478
+ await fs.mkdir(resolvedCwd, { recursive: true });
447
479
  await fs.mkdir(channelDir, { recursive: true });
448
480
  await fs.writeFile(specPath, spec?.trim() ||
449
- `# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`);
481
+ `# ${normalizedChannelId}\n\n`);
450
482
  await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
451
483
  },
484
+ deleteChannel: async ({ channelId }) => {
485
+ const normalizedChannelId = channelId.trim();
486
+ if (!normalizedChannelId) {
487
+ throw new Error('channelId is required');
488
+ }
489
+ if (normalizedChannelId === '_meta') {
490
+ throw new Error('Cannot delete reserved channel path');
491
+ }
492
+ const channelDir = getConversationDir(normalizedChannelId);
493
+ try {
494
+ await fs.access(channelDir);
495
+ }
496
+ catch (error) {
497
+ if (error?.code === 'ENOENT') {
498
+ const err = new Error(`Channel "${normalizedChannelId}" does not exist.`);
499
+ err.code = 'CHANNEL_NOT_FOUND';
500
+ throw err;
501
+ }
502
+ throw error;
503
+ }
504
+ await fs.rm(channelDir, { recursive: true, force: true });
505
+ try {
506
+ const lastReadPath = getLastReadFilePath();
507
+ const map = await readJsonFile(lastReadPath, {});
508
+ if (normalizedChannelId in map) {
509
+ delete map[normalizedChannelId];
510
+ await fs.mkdir(path.dirname(lastReadPath), { recursive: true });
511
+ const tmp = `${lastReadPath}.tmp`;
512
+ await fs.writeFile(tmp, JSON.stringify(map, null, 2), 'utf-8');
513
+ await fs.rename(tmp, lastReadPath);
514
+ }
515
+ }
516
+ catch {
517
+ // ignore last-read cleanup failures
518
+ }
519
+ },
452
520
  createThread: async ({ channelId, threadId, threadTitle, initialState, }) => {
453
521
  const normalizedChannelId = channelId.trim();
454
522
  const normalizedThreadId = threadId.trim();
@@ -483,6 +551,8 @@ export const storageService = {
483
551
  catch {
484
552
  return [];
485
553
  }
554
+ const lastReadMap = await storageService.getLastReadMap();
555
+ const channelLastRead = lastReadMap[channelId] || {};
486
556
  const threadNames = (await fs.readdir(threadsDir)).filter((name) => !name.startsWith('.'));
487
557
  const threads = await Promise.all(threadNames.map(async (name) => {
488
558
  const threadPath = path.join(threadsDir, name);
@@ -502,12 +572,22 @@ export const storageService = {
502
572
  console.error(`Failed to read thread state for channel ${channelId} thread ${name}`, error);
503
573
  }
504
574
  }
575
+ let hasUnseen = false;
576
+ try {
577
+ const events = await storageService.getEvents({ channelId, threadId: name });
578
+ const latestId = events[events.length - 1]?.id;
579
+ hasUnseen = !!(latestId && latestId !== channelLastRead[name]);
580
+ }
581
+ catch {
582
+ // ignore
583
+ }
505
584
  return {
506
585
  id: name,
507
586
  name: threadDisplayName,
508
587
  channelId,
509
588
  createdAt: stats.birthtime,
510
589
  updatedAt: stats.mtime,
590
+ hasUnseenMessages: hasUnseen,
511
591
  };
512
592
  }));
513
593
  return threads;
@@ -1074,11 +1154,9 @@ export const storageService = {
1074
1154
  if (!baseCwd) {
1075
1155
  throw new Error('Channel has no CWD configured');
1076
1156
  }
1077
- const resolvedBase = path.resolve(baseCwd);
1078
- const targetDir = path.resolve(resolvedBase, subPath);
1079
- if (!targetDir.startsWith(resolvedBase)) {
1080
- throw new Error('Access denied: directory escape');
1081
- }
1157
+ const targetDir = subPath
1158
+ ? resolveChannelFile(baseCwd, subPath)
1159
+ : resolvePath(baseCwd);
1082
1160
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1083
1161
  return entries
1084
1162
  .filter((e) => !e.name.startsWith('.'))
@@ -1093,13 +1171,88 @@ export const storageService = {
1093
1171
  if (!baseCwd) {
1094
1172
  throw new Error('Channel has no CWD configured');
1095
1173
  }
1096
- const resolvedBase = path.resolve(baseCwd);
1097
- const targetFile = path.resolve(resolvedBase, filePath);
1098
- if (!targetFile.startsWith(resolvedBase)) {
1099
- throw new Error('Access denied: directory escape');
1100
- }
1174
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1101
1175
  return fs.readFile(targetFile, 'utf-8');
1102
1176
  },
1177
+ readChannelFile: async ({ channelId, path: filePath, encoding = 'utf8', }) => {
1178
+ const details = await storageService.getChannelDetails({ channelId });
1179
+ const baseCwd = details.cwd;
1180
+ if (!baseCwd) {
1181
+ throw new Error('Channel has no CWD configured');
1182
+ }
1183
+ const targetFile = resolveChannelFile(baseCwd, filePath);
1184
+ const buf = await fs.readFile(targetFile);
1185
+ const content = encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1186
+ return {
1187
+ content,
1188
+ mimeType: guessMimeType(targetFile),
1189
+ size: buf.length,
1190
+ };
1191
+ },
1192
+ writeChannelFile: async ({ channelId, path: filePath, content, encoding = 'utf8', overwrite = false, }) => {
1193
+ const details = await storageService.getChannelDetails({ channelId });
1194
+ const baseCwd = details.cwd;
1195
+ if (!baseCwd) {
1196
+ throw new Error('Channel has no CWD configured');
1197
+ }
1198
+ const abs = resolveChannelFile(baseCwd, filePath);
1199
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1200
+ if (!overwrite) {
1201
+ try {
1202
+ await fs.access(abs);
1203
+ throw new Error('File already exists');
1204
+ }
1205
+ catch (error) {
1206
+ const code = error?.code;
1207
+ if (code !== 'ENOENT') {
1208
+ throw error;
1209
+ }
1210
+ }
1211
+ }
1212
+ const buf = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
1213
+ await fs.writeFile(abs, buf);
1214
+ return {
1215
+ path: filePath,
1216
+ size: buf.length,
1217
+ mimeType: guessMimeType(abs),
1218
+ };
1219
+ },
1220
+ uploadChannelFile: async ({ channelId, path: filePath, body, overwrite = false, }) => {
1221
+ const details = await storageService.getChannelDetails({ channelId });
1222
+ const baseCwd = details.cwd;
1223
+ if (!baseCwd) {
1224
+ throw new Error('Channel has no CWD configured');
1225
+ }
1226
+ const abs = resolveChannelFile(baseCwd, filePath);
1227
+ await fs.mkdir(path.dirname(abs), { recursive: true });
1228
+ if (!overwrite) {
1229
+ try {
1230
+ await fs.access(abs);
1231
+ throw new Error('File already exists');
1232
+ }
1233
+ catch (error) {
1234
+ const code = error?.code;
1235
+ if (code !== 'ENOENT') {
1236
+ throw error;
1237
+ }
1238
+ }
1239
+ }
1240
+ await fs.writeFile(abs, body);
1241
+ return {
1242
+ path: filePath,
1243
+ size: body.length,
1244
+ mimeType: guessMimeType(abs),
1245
+ };
1246
+ },
1247
+ getChannelFileStat: async ({ channelId, path: filePath, }) => {
1248
+ const details = await storageService.getChannelDetails({ channelId });
1249
+ const baseCwd = details.cwd;
1250
+ if (!baseCwd) {
1251
+ throw new Error('Channel has no CWD configured');
1252
+ }
1253
+ const { abs, size } = await statChannelFile(baseCwd, filePath);
1254
+ return { abs, size, mimeType: guessMimeType(abs) };
1255
+ },
1103
1256
  appendMemory: memoryService.appendMemory,
1104
1257
  listMemories: memoryService.listMemories,
1105
1258
  deleteMemory: memoryService.deleteMemory,
@@ -1135,12 +1288,16 @@ export const storageService = {
1135
1288
  console.warn(`[storage] Failed to load thread details for channel ${channelId} thread: ${threadId}`, error);
1136
1289
  }
1137
1290
  }
1291
+ const threadState = threadDetails?.state || {};
1138
1292
  return {
1139
1293
  runId,
1140
1294
  agentId,
1141
1295
  channelId,
1142
1296
  threadId,
1143
1297
  triggerEvent: event,
1298
+ pendingToolCallIds: Array.isArray(threadState.pendingToolCallIds)
1299
+ ? threadState.pendingToolCallIds
1300
+ : undefined,
1144
1301
  agentDetails: {
1145
1302
  id: agentDetails.id,
1146
1303
  name: agentDetails.name,
@@ -1,154 +1,113 @@
1
- import z from 'zod';
2
- const actionSchema = z.object({
3
- id: z.string().describe('Stable action ID returned by client:ui:widget:response.'),
4
- label: z.string().describe('Human-readable button label.'),
5
- value: z.unknown().optional().describe('Optional machine-readable value for this action.'),
6
- variant: z.enum(['primary', 'secondary', 'danger']).optional(),
7
- disabled: z.boolean().optional(),
8
- });
9
- const optionSchema = z.object({
10
- label: z.string(),
11
- value: z.string(),
12
- });
13
- const fieldSchema = z.object({
14
- id: z.string().describe('Stable field ID used as the submitted value key.'),
15
- label: z.string(),
16
- type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
17
- description: z.string().optional(),
18
- placeholder: z.string().optional(),
19
- required: z.boolean().optional(),
20
- options: z.array(optionSchema).optional(),
21
- defaultValue: z.unknown().optional(),
22
- });
23
- const listItemSchema = z.object({
24
- id: z.string(),
25
- label: z.string(),
26
- description: z.string().optional(),
27
- status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
28
- metadata: z.record(z.string(), z.unknown()).optional(),
29
- });
30
- const widgetBaseSchema = {
31
- widgetId: z.string().optional().describe('Stable widget ID. Defaults from toolCallId.'),
32
- title: z.string().optional(),
33
- description: z.string().optional(),
34
- body: z.string().optional(),
35
- state: z.enum(['open', 'submitted', 'cancelled', 'error']).optional(),
36
- metadata: z.record(z.string(), z.unknown()).optional(),
37
- };
38
- const renderWidgetSchema = z.union([
39
- z.object({
40
- ...widgetBaseSchema,
41
- kind: z.literal('message'),
42
- actions: z.array(actionSchema).optional(),
43
- }),
44
- z.object({
45
- ...widgetBaseSchema,
46
- kind: z.literal('choice'),
47
- actions: z.array(actionSchema).min(1),
48
- }),
49
- z.object({
50
- ...widgetBaseSchema,
51
- kind: z.literal('form'),
52
- fields: z.array(fieldSchema).optional(),
53
- submitLabel: z.string().optional(),
54
- actions: z.array(actionSchema).optional(),
55
- props: z.record(z.string(), z.unknown()).optional(),
56
- }),
57
- z.object({
58
- ...widgetBaseSchema,
59
- kind: z.literal('list'),
60
- items: z.array(listItemSchema).optional(),
61
- actions: z.array(actionSchema).optional(),
62
- }),
63
- z.object({
64
- kind: z.literal('approval').describe('Legacy preset. Prefer choice or list.'),
65
- widgetId: z.string().optional(),
66
- title: z.string().optional(),
67
- props: z.record(z.string(), z.unknown()).optional(),
68
- metadata: z.record(z.string(), z.unknown()).optional(),
69
- }),
70
- ]);
71
- const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
72
- const readString = (value) => typeof value === 'string' && value.trim() ? value : undefined;
73
- const asFields = (value) => Array.isArray(value) ? value : undefined;
74
- const asListItems = (value) => Array.isArray(value) ? value : undefined;
75
- const createWidgetId = (data, toolCallId) => {
76
- if ('widgetId' in data && data.widgetId)
77
- return data.widgetId;
78
- if (toolCallId)
79
- return `widget_${toolCallId}`;
80
- return `widget_${Date.now()}`;
81
- };
82
- const normalizeWidget = (data, state, toolCallId) => {
83
- const widgetId = createWidgetId(data, toolCallId);
84
- if (data.kind === 'approval') {
85
- const props = data.props || {};
86
- return {
87
- widgetId,
88
- kind: 'choice',
89
- title: data.title || 'Approval Required',
90
- body: readString(props.message) ||
91
- readString(props.summary) ||
92
- 'Please approve or deny this action.',
93
- metadata: {
94
- ...(data.metadata || {}),
95
- legacyKind: 'approval',
96
- actionId: props.actionId,
97
- },
98
- actions: [
99
- { id: 'approve', label: 'Approve', value: props.actionId || 'approve', variant: 'primary' },
100
- { id: 'deny', label: 'Deny', value: props.actionId || 'deny', variant: 'danger' },
101
- ],
102
- };
103
- }
104
- if (data.kind === 'form') {
105
- const propsSource = data.props;
106
- const props = isRecord(propsSource) ? propsSource : {};
107
- return {
108
- widgetId,
109
- kind: 'form',
110
- title: data.title || 'Details Required',
111
- description: data.description,
112
- body: data.body,
113
- state: data.state,
114
- metadata: data.metadata,
115
- fields: data.fields || asFields(props.schema) || [],
116
- submitLabel: data.submitLabel || readString(props.submitLabel),
117
- actions: data.actions,
118
- };
119
- }
120
- if (data.kind === 'list') {
121
- return { ...data, widgetId, title: data.title || 'Task List', items: data.items || [] };
122
- }
123
- if (data.kind === 'choice') {
124
- return { ...data, widgetId, title: data.title || 'Choose an Option' };
125
- }
126
- if (data.kind === 'message') {
127
- return { ...data, widgetId, title: data.title || 'Message' };
128
- }
129
- throw new Error(`Unsupported UI widget kind: ${data.kind || 'unknown'}`);
130
- };
131
- const uiToolDefinitions = {
132
- render_ui_widget: {
133
- description: 'Render a small server-driven UI widget in the conversation. Prefer primitive kinds: message, choice, form, or list. Legacy preset approval is accepted.',
134
- inputSchema: renderWidgetSchema,
135
- },
136
- };
137
- const uiPluginRuntime = () => (builder) => {
138
- builder.on('action:render_ui_widget', async function* (event, context) {
139
- const widget = normalizeWidget(event.data, context.state, event.meta?.toolCallId);
140
- yield {
141
- type: 'client:ui:widget',
142
- data: widget,
143
- meta: event.meta,
144
- };
145
- });
146
- };
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ /**
4
+ * `ui` — provides a tool for the agent to render interactive UI widgets.
5
+ *
6
+ * The model can choose which widget to render (form, choice, list, message)
7
+ * depending on the situation.
8
+ */
147
9
  export const uiPlugin = {
148
10
  id: 'ui',
149
- name: 'UI Widgets',
150
- description: 'Render server-driven UI widgets (messages, choices, forms, lists) in the conversation.',
151
- toolDefinitions: uiToolDefinitions,
152
- factory: () => uiPluginRuntime(),
11
+ name: 'UI',
12
+ description: 'Render interactive UI widgets to interact with the user.',
13
+ toolDefinitions: {
14
+ render_widget: {
15
+ 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.',
16
+ inputSchema: z.object({
17
+ kind: z.enum(['message', 'choice', 'form', 'list']).describe('The type of widget to render.'),
18
+ title: z.string().describe('The title of the widget.'),
19
+ description: z.string().optional().describe('A description or body text.'),
20
+ fields: z.array(z.object({
21
+ id: z.string().describe('Unique ID for the field.'),
22
+ label: z.string().describe('Label shown to the user.'),
23
+ type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
24
+ description: z.string().optional(),
25
+ placeholder: z.string().optional(),
26
+ required: z.boolean().optional(),
27
+ options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
28
+ defaultValue: z.any().optional()
29
+ })).optional().describe('Required for kind="form". List of form fields.'),
30
+ actions: z.array(z.object({
31
+ id: z.string(),
32
+ label: z.string(),
33
+ variant: z.enum(['primary', 'secondary', 'danger']).optional(),
34
+ })).optional().describe('Buttons or actions available on the widget.'),
35
+ items: z.array(z.object({
36
+ id: z.string(),
37
+ label: z.string(),
38
+ description: z.string().optional(),
39
+ status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
40
+ metadata: z.record(z.string(), z.any()).optional()
41
+ })).optional().describe('Required for kind="list". List of items to display.'),
42
+ submitLabel: z.string().optional().describe('Label for the primary action button (e.g. "Submit", "Save").')
43
+ })
44
+ }
45
+ },
46
+ factory: () => (builder) => {
47
+ // Handle the tool call from the agent
48
+ builder.on('action:render_widget', async function* (event, context) {
49
+ const widgetEvent = event;
50
+ const toolCallId = widgetEvent.meta?.toolCallId;
51
+ const threadId = widgetEvent.meta?.threadId || context.state.threadId;
52
+ if (!toolCallId)
53
+ return;
54
+ const widgetId = randomUUID();
55
+ // Emit the UI widget event to the client
56
+ yield {
57
+ type: 'client:ui:widget',
58
+ data: {
59
+ ...widgetEvent.data,
60
+ widgetId,
61
+ metadata: {
62
+ type: 'ui:request',
63
+ originalEvent: widgetEvent
64
+ }
65
+ },
66
+ meta: { agentId: context.state.agentId, threadId }
67
+ };
68
+ });
69
+ // Handle the user's response from the UI widget
70
+ builder.on('client:ui:widget:response', async function* (event, context) {
71
+ const responseEvent = event;
72
+ const { widgetId, actionId, values, metadata } = responseEvent.data;
73
+ if (metadata?.type !== 'ui:request')
74
+ return;
75
+ const originalEvent = metadata.originalEvent;
76
+ const toolCallId = originalEvent?.meta?.toolCallId;
77
+ const threadId = originalEvent?.meta?.threadId || context.state.threadId;
78
+ if (!toolCallId)
79
+ return;
80
+ // Yield a "submitted" widget update to the UI to collapse/disable it
81
+ yield {
82
+ type: 'client:ui:widget',
83
+ data: {
84
+ widgetId,
85
+ title: originalEvent.data.title,
86
+ kind: originalEvent.data.kind,
87
+ state: 'submitted',
88
+ body: "Thank you for your response. We will process it and get back to you soon.",
89
+ display: 'collapsed',
90
+ disabled: true,
91
+ actions: [], // Clear actions to disable buttons in UI
92
+ },
93
+ meta: { agentId: context.state.agentId, threadId },
94
+ };
95
+ // Emit the tool result event so the agent runtime can resume
96
+ yield {
97
+ type: 'action:render_widget:result',
98
+ data: {
99
+ success: true,
100
+ actionId,
101
+ values,
102
+ output: JSON.stringify(values)
103
+ },
104
+ meta: {
105
+ agentId: context.state.agentId,
106
+ threadId,
107
+ toolCallId
108
+ }
109
+ };
110
+ });
111
+ },
153
112
  };
154
113
  export default uiPlugin;