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
|
@@ -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
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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` / `
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
return
|
|
459
|
+
getLastReadMap: async (): Promise<LastReadMap> => {
|
|
460
|
+
return readLastReadMap();
|
|
431
461
|
},
|
|
432
462
|
|
|
433
|
-
|
|
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
|
|
443
|
-
map[channelId] =
|
|
444
|
-
|
|
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
|
|
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:
|
|
486
|
-
updatedAt:
|
|
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
|
-
|
|
547
|
-
(
|
|
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\
|
|
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
|
|
1361
|
-
|
|
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
|
|
1391
|
-
|
|
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 (!
|
|
1394
|
-
throw new Error('
|
|
1496
|
+
if (!baseCwd) {
|
|
1497
|
+
throw new Error('Channel has no CWD configured');
|
|
1395
1498
|
}
|
|
1396
1499
|
|
|
1397
|
-
|
|
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();
|