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
package/src/app/server.ts
CHANGED
|
@@ -13,7 +13,14 @@ import { ActiveRunsSnapshotEvent, OpenBotEvent, OpenBotState } from './types.js'
|
|
|
13
13
|
import { processService } from '../services/process.js';
|
|
14
14
|
import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
15
15
|
import { initPlugins } from '../services/plugins/registry.js';
|
|
16
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
17
|
+
import {
|
|
18
|
+
buildWorkspaceFileUrl,
|
|
19
|
+
getPublicBaseUrl,
|
|
20
|
+
openChannelFileStream,
|
|
21
|
+
} from '../plugins/storage/files.js';
|
|
16
22
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
23
|
+
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
17
24
|
|
|
18
25
|
type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
|
|
19
26
|
|
|
@@ -53,6 +60,10 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
53
60
|
|
|
54
61
|
initPlugins(pluginsDir);
|
|
55
62
|
|
|
63
|
+
// Pre-warm caches for agents and plugins to speed up first UI load
|
|
64
|
+
storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
|
|
65
|
+
storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
|
|
66
|
+
|
|
56
67
|
const getContext = (req: express.Request) => {
|
|
57
68
|
const channelId =
|
|
58
69
|
req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
|
|
@@ -71,7 +82,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
71
82
|
(req.body && req.body.responseType);
|
|
72
83
|
|
|
73
84
|
return {
|
|
74
|
-
channelId: (channelId || (threadId ? '
|
|
85
|
+
channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')) as string, // Default to uncategorized if none
|
|
75
86
|
threadId: threadId as string | undefined,
|
|
76
87
|
agentId: agentId as string | undefined,
|
|
77
88
|
runId: runId as string,
|
|
@@ -86,7 +97,16 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
86
97
|
|
|
87
98
|
const sendToClientKey = (clientKey: string, chunk: OpenBotEvent) => {
|
|
88
99
|
const threadClients = clients.get(clientKey);
|
|
89
|
-
if (!threadClients) return;
|
|
100
|
+
if (!threadClients || threadClients.length === 0) return;
|
|
101
|
+
|
|
102
|
+
// Auto-detect "read" state: if someone is listening, they just "read" this event.
|
|
103
|
+
if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
|
|
104
|
+
const parts = clientKey.split(':');
|
|
105
|
+
const channelId = parts[0];
|
|
106
|
+
const threadId = parts[1]; // undefined if no ":"
|
|
107
|
+
storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
threadClients.forEach((client) => {
|
|
91
111
|
if (!client.writableEnded) {
|
|
92
112
|
client.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
@@ -130,8 +150,37 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
130
150
|
};
|
|
131
151
|
};
|
|
132
152
|
|
|
153
|
+
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
154
|
+
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
155
|
+
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
156
|
+
// orphaned entries that keep a channel falsely "active". Purging by
|
|
157
|
+
// channel/thread guarantees the snapshot self-heals after a stop.
|
|
158
|
+
const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
|
|
159
|
+
const target = threadId || undefined;
|
|
160
|
+
for (const [key, run] of activeRuns) {
|
|
161
|
+
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
162
|
+
activeRuns.delete(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
133
167
|
app.use(cors());
|
|
134
|
-
|
|
168
|
+
|
|
169
|
+
const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
|
|
170
|
+
|
|
171
|
+
app.use((req, res, next) => {
|
|
172
|
+
const isWorkspaceUpload =
|
|
173
|
+
req.method === 'POST' &&
|
|
174
|
+
req.path === '/api/publish' &&
|
|
175
|
+
req.get('x-openbot-event-type') === 'action:storage:upload-file';
|
|
176
|
+
|
|
177
|
+
if (isWorkspaceUpload) {
|
|
178
|
+
express.raw({ type: () => true, limit: '100mb' })(req, res, next);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
express.json({ limit: '20mb' })(req, res, next);
|
|
183
|
+
});
|
|
135
184
|
|
|
136
185
|
app.get('/api/health', (req, res) => {
|
|
137
186
|
res.json({ status: 'ok', version: pkg.version });
|
|
@@ -161,6 +210,19 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
161
210
|
}
|
|
162
211
|
clients.get(clientKey)!.push(res);
|
|
163
212
|
|
|
213
|
+
// Auto-detect "read" state on connection: mark the latest event as seen.
|
|
214
|
+
if (channelId !== GLOBAL_CHANNEL_ID) {
|
|
215
|
+
storageService
|
|
216
|
+
.getEvents({ channelId, threadId })
|
|
217
|
+
.then((events) => {
|
|
218
|
+
const latestId = events[events.length - 1]?.id;
|
|
219
|
+
if (latestId) {
|
|
220
|
+
return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
|
|
164
226
|
if (channelId === GLOBAL_CHANNEL_ID) {
|
|
165
227
|
const snapshot = buildActiveRunsSnapshot();
|
|
166
228
|
|
|
@@ -192,6 +254,57 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
192
254
|
});
|
|
193
255
|
|
|
194
256
|
app.post('/api/publish', async (req, res) => {
|
|
257
|
+
if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
|
|
258
|
+
const channelId =
|
|
259
|
+
req.get('x-openbot-channel-id') ||
|
|
260
|
+
(typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
|
|
261
|
+
const filePath = req.get('x-openbot-file-path');
|
|
262
|
+
const overwrite = req.get('x-openbot-file-overwrite') === 'true';
|
|
263
|
+
|
|
264
|
+
if (!channelId?.trim()) {
|
|
265
|
+
res.status(400).json({ error: 'channelId is required' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!filePath?.trim()) {
|
|
269
|
+
res.status(400).json({ error: 'x-openbot-file-path header is required' });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
274
|
+
if (body.length === 0) {
|
|
275
|
+
res.status(400).json({ error: 'Request body is empty' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const result = await storageService.uploadChannelFile({
|
|
281
|
+
channelId: channelId.trim(),
|
|
282
|
+
path: filePath.trim(),
|
|
283
|
+
body,
|
|
284
|
+
overwrite,
|
|
285
|
+
});
|
|
286
|
+
const url = buildWorkspaceFileUrl({
|
|
287
|
+
baseUrl: resolvePublicBaseUrl(),
|
|
288
|
+
channelId: channelId.trim(),
|
|
289
|
+
filePath: result.path,
|
|
290
|
+
});
|
|
291
|
+
res.json({
|
|
292
|
+
type: 'action:storage:upload-file:result',
|
|
293
|
+
data: { success: true, ...result, url },
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
res.status(400).json({
|
|
297
|
+
type: 'action:storage:upload-file:result',
|
|
298
|
+
data: {
|
|
299
|
+
success: false,
|
|
300
|
+
path: filePath,
|
|
301
|
+
error: error instanceof Error ? error.message : 'Upload failed',
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
195
308
|
const parseResult = publishEventSchema.safeParse(req.body);
|
|
196
309
|
if (!parseResult.success) {
|
|
197
310
|
res.status(400).json({
|
|
@@ -210,6 +323,92 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
210
323
|
return;
|
|
211
324
|
}
|
|
212
325
|
|
|
326
|
+
if (event.type === 'action:storage:write-file') {
|
|
327
|
+
const data = (event.data ?? {}) as {
|
|
328
|
+
path?: string;
|
|
329
|
+
content?: string;
|
|
330
|
+
encoding?: 'utf8' | 'base64';
|
|
331
|
+
overwrite?: boolean;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (!data.path?.trim()) {
|
|
335
|
+
res.status(400).json({
|
|
336
|
+
type: 'action:storage:write-file:result',
|
|
337
|
+
data: { success: false, path: '', error: 'path is required' },
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (typeof data.content !== 'string') {
|
|
342
|
+
res.status(400).json({
|
|
343
|
+
type: 'action:storage:write-file:result',
|
|
344
|
+
data: { success: false, path: data.path, error: 'content is required' },
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const result = await storageService.writeChannelFile({
|
|
351
|
+
channelId,
|
|
352
|
+
path: data.path.trim(),
|
|
353
|
+
content: data.content,
|
|
354
|
+
encoding: data.encoding ?? 'utf8',
|
|
355
|
+
overwrite: data.overwrite ?? false,
|
|
356
|
+
});
|
|
357
|
+
const url = buildWorkspaceFileUrl({
|
|
358
|
+
baseUrl: resolvePublicBaseUrl(),
|
|
359
|
+
channelId,
|
|
360
|
+
filePath: result.path,
|
|
361
|
+
});
|
|
362
|
+
res.json({
|
|
363
|
+
type: 'action:storage:write-file:result',
|
|
364
|
+
data: { success: true, ...result, url },
|
|
365
|
+
});
|
|
366
|
+
} catch (error) {
|
|
367
|
+
res.status(400).json({
|
|
368
|
+
type: 'action:storage:write-file:result',
|
|
369
|
+
data: {
|
|
370
|
+
success: false,
|
|
371
|
+
path: data.path,
|
|
372
|
+
error: error instanceof Error ? error.message : 'Write failed',
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Stop request: cancel the in-flight run (and any delegated sub-agents in the
|
|
380
|
+
// same thread) instead of spinning up a new agent turn.
|
|
381
|
+
if (event.type === 'action:agent_run_stop') {
|
|
382
|
+
const data = (event.data ?? {}) as {
|
|
383
|
+
runId?: string;
|
|
384
|
+
agentId?: string;
|
|
385
|
+
channelId?: string;
|
|
386
|
+
threadId?: string;
|
|
387
|
+
reason?: string;
|
|
388
|
+
};
|
|
389
|
+
const targetChannelId = data.channelId || channelId;
|
|
390
|
+
const targetThreadId = data.threadId || threadId;
|
|
391
|
+
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
392
|
+
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
393
|
+
|
|
394
|
+
const stoppedEvent: OpenBotEvent = {
|
|
395
|
+
type: 'agent:run:stopped',
|
|
396
|
+
data: {
|
|
397
|
+
runId: data.runId || runId,
|
|
398
|
+
agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
|
|
399
|
+
channelId: targetChannelId,
|
|
400
|
+
threadId: targetThreadId,
|
|
401
|
+
reason: data.reason,
|
|
402
|
+
},
|
|
403
|
+
} as OpenBotEvent;
|
|
404
|
+
ensureEventId(stoppedEvent);
|
|
405
|
+
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
406
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
407
|
+
|
|
408
|
+
res.json({ success: stopped });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
213
412
|
const onEvent = async (chunk: OpenBotEvent, state?: OpenBotState) => {
|
|
214
413
|
const targetChannelId = state?.channelId || channelId;
|
|
215
414
|
const targetThreadId = state?.threadId || threadId;
|
|
@@ -229,6 +428,8 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
229
428
|
activeRuns.delete(
|
|
230
429
|
getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
|
|
231
430
|
);
|
|
431
|
+
} else if (chunk.type === 'agent:run:stopped') {
|
|
432
|
+
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
232
433
|
}
|
|
233
434
|
|
|
234
435
|
sendToClientKey(targetClientKey, chunk);
|
|
@@ -251,6 +452,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
251
452
|
event,
|
|
252
453
|
channelId,
|
|
253
454
|
threadId,
|
|
455
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
254
456
|
onEvent,
|
|
255
457
|
});
|
|
256
458
|
res.sendStatus(200);
|
|
@@ -277,6 +479,35 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
277
479
|
}
|
|
278
480
|
|
|
279
481
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
482
|
+
|
|
483
|
+
if (event.type === 'action:storage:serve-file') {
|
|
484
|
+
const filePath = (event.data as { path?: string })?.path;
|
|
485
|
+
if (!channelId?.trim()) {
|
|
486
|
+
res.status(400).json({ error: 'channelId is required' });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!filePath?.trim()) {
|
|
490
|
+
res.status(400).json({ error: 'path is required' });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const { abs, size, mimeType } = await storageService.getChannelFileStat({
|
|
496
|
+
channelId,
|
|
497
|
+
path: filePath.trim(),
|
|
498
|
+
});
|
|
499
|
+
res.setHeader('Content-Type', mimeType);
|
|
500
|
+
res.setHeader('Content-Length', String(size));
|
|
501
|
+
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
502
|
+
openChannelFileStream(abs).pipe(res);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
res.status(404).json({
|
|
505
|
+
error: error instanceof Error ? error.message : 'File not found',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
280
511
|
const events: OpenBotEvent[] = [];
|
|
281
512
|
|
|
282
513
|
const onEvent = async (chunk: OpenBotEvent) => {
|
|
@@ -293,6 +524,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
293
524
|
channelId,
|
|
294
525
|
threadId,
|
|
295
526
|
persistEvents: false,
|
|
527
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
296
528
|
onEvent,
|
|
297
529
|
});
|
|
298
530
|
res.json({ events });
|