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.
- package/dist/app/cli.js +1 -1
- package/dist/app/config.js +10 -0
- package/dist/app/server.js +200 -3
- package/dist/harness/index.js +18 -0
- package/dist/plugins/approval/index.js +35 -20
- package/dist/plugins/bash/index.js +195 -0
- package/dist/plugins/delegation/index.js +4 -2
- package/dist/plugins/openbot/context.js +54 -9
- package/dist/plugins/openbot/history.js +47 -1
- package/dist/plugins/openbot/index.js +43 -3
- package/dist/plugins/openbot/runtime.js +91 -27
- package/dist/plugins/openbot/system-prompt.js +21 -1
- package/dist/plugins/plugin-manager/index.js +87 -3
- package/dist/plugins/shell/index.js +2 -1
- package/dist/plugins/storage/files.js +67 -0
- package/dist/plugins/storage/index.js +184 -7
- package/dist/plugins/storage/service.js +201 -44
- package/dist/plugins/ui/index.js +109 -150
- package/dist/services/abort.js +43 -0
- package/dist/services/plugins/registry.js +5 -3
- package/dist/services/plugins/service.js +66 -11
- package/docs/agents.md +5 -8
- package/docs/architecture.md +1 -1
- package/docs/plugins.md +28 -7
- package/docs/templates/AGENT.example.md +4 -4
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/config.ts +13 -0
- package/src/app/server.ts +235 -3
- package/src/app/types.ts +284 -14
- package/src/harness/index.ts +21 -0
- package/src/plugins/approval/index.ts +37 -20
- package/src/plugins/bash/index.ts +232 -0
- package/src/plugins/delegation/index.ts +5 -2
- package/src/plugins/openbot/context.ts +58 -9
- package/src/plugins/openbot/history.ts +52 -1
- package/src/plugins/openbot/index.ts +45 -3
- package/src/plugins/openbot/runtime.ts +121 -27
- package/src/plugins/openbot/system-prompt.ts +21 -1
- package/src/plugins/plugin-manager/index.ts +105 -3
- package/src/plugins/storage/files.ts +81 -0
- package/src/plugins/storage/index.ts +198 -8
- package/src/plugins/storage/service.ts +267 -44
- package/src/plugins/ui/index.ts +123 -0
- package/src/services/abort.ts +46 -0
- package/src/services/plugins/domain.ts +34 -1
- package/src/services/plugins/registry.ts +5 -3
- package/src/services/plugins/service.ts +136 -45
- package/src/services/plugins/types.ts +5 -1
- 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
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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` / `
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
return
|
|
379
|
+
getLastReadMap: async () => {
|
|
380
|
+
return readLastReadMap();
|
|
357
381
|
},
|
|
358
|
-
|
|
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
|
|
362
|
-
map[channelId]
|
|
363
|
-
|
|
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
|
|
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:
|
|
399
|
-
updatedAt:
|
|
427
|
+
createdAt: stats.birthtime,
|
|
428
|
+
updatedAt: stats.mtime,
|
|
400
429
|
};
|
|
401
|
-
const
|
|
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
|
-
|
|
445
|
-
finalState.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\
|
|
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
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
|
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,
|
package/dist/plugins/ui/index.js
CHANGED
|
@@ -1,154 +1,113 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
150
|
-
description: 'Render
|
|
151
|
-
toolDefinitions:
|
|
152
|
-
|
|
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;
|