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.
- 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 +6 -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 +215 -59
- 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 +7 -7
- 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 +7 -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 +282 -59
- 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,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
|
-
|
|
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
|
-
|
|
430
|
-
return
|
|
466
|
+
getLastReadMap: async (): Promise<LastReadMap> => {
|
|
467
|
+
return readLastReadMap();
|
|
431
468
|
},
|
|
432
469
|
|
|
433
|
-
|
|
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
|
|
443
|
-
map[channelId] =
|
|
444
|
-
|
|
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
|
|
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
|
|
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:
|
|
486
|
-
updatedAt:
|
|
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
|
-
|
|
547
|
-
(
|
|
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\
|
|
604
|
+
`# ${normalizedChannelId}\n\n`,
|
|
555
605
|
);
|
|
556
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
},
|
|
1392
1483
|
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
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;
|