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/dist/app/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ function checkNodeVersion() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
checkNodeVersion();
|
|
19
|
-
program.name('openbot').description('OpenBot CLI').version('0.4.
|
|
19
|
+
program.name('openbot').description('OpenBot CLI').version('0.4.3');
|
|
20
20
|
program
|
|
21
21
|
.command('start')
|
|
22
22
|
.description('Start the OpenBot harness')
|
package/dist/app/config.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
export const DEFAULT_BASE_DIR = '~/.openbot';
|
|
5
|
+
/** Default parent directory for per-channel working directories (user-facing workspace). */
|
|
6
|
+
export const DEFAULT_CHANNELS_WORKSPACE_DIR = '~/openbot';
|
|
5
7
|
export const DEFAULT_PLUGINS_DIR = 'plugins';
|
|
6
8
|
export const DEFAULT_AGENTS_DIR = 'agents';
|
|
7
9
|
export const DEFAULT_CHANNELS_DIR = 'channels';
|
|
@@ -12,6 +14,14 @@ export const DEFAULT_MARKETPLACE_REGISTRY_URL = 'https://raw.githubusercontent.c
|
|
|
12
14
|
export function resolvePath(p) {
|
|
13
15
|
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(p);
|
|
14
16
|
}
|
|
17
|
+
/** Default absolute cwd for a channel when none is provided at creation time. */
|
|
18
|
+
export function getDefaultChannelCwd(channelId) {
|
|
19
|
+
const id = channelId.trim();
|
|
20
|
+
if (!id) {
|
|
21
|
+
throw new Error('channelId is required');
|
|
22
|
+
}
|
|
23
|
+
return resolvePath(`${DEFAULT_CHANNELS_WORKSPACE_DIR}/${id}`);
|
|
24
|
+
}
|
|
15
25
|
export function loadConfig() {
|
|
16
26
|
const configPath = path.join(os.homedir(), '.openbot', CONFIG_FILE);
|
|
17
27
|
if (fs.existsSync(configPath)) {
|
package/dist/app/server.js
CHANGED
|
@@ -12,7 +12,10 @@ import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
|
12
12
|
import { processService } from '../services/process.js';
|
|
13
13
|
import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
14
14
|
import { initPlugins } from '../services/plugins/registry.js';
|
|
15
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
16
|
+
import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
|
|
15
17
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
18
|
+
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
16
19
|
export async function startServer(options = {}) {
|
|
17
20
|
const publishEventSchema = z
|
|
18
21
|
.object({
|
|
@@ -36,6 +39,9 @@ export async function startServer(options = {}) {
|
|
|
36
39
|
await fs.mkdir(agentsDir, { recursive: true });
|
|
37
40
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
38
41
|
initPlugins(pluginsDir);
|
|
42
|
+
// Pre-warm caches for agents and plugins to speed up first UI load
|
|
43
|
+
storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
|
|
44
|
+
storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
|
|
39
45
|
const getContext = (req) => {
|
|
40
46
|
const channelId = req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
|
|
41
47
|
const threadId = req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
|
|
@@ -48,7 +54,7 @@ export async function startServer(options = {}) {
|
|
|
48
54
|
req.query.responseType ||
|
|
49
55
|
(req.body && req.body.responseType);
|
|
50
56
|
return {
|
|
51
|
-
channelId: (channelId || (threadId ? '
|
|
57
|
+
channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')), // Default to uncategorized if none
|
|
52
58
|
threadId: threadId,
|
|
53
59
|
agentId: agentId,
|
|
54
60
|
runId: runId,
|
|
@@ -59,8 +65,15 @@ export async function startServer(options = {}) {
|
|
|
59
65
|
const getRunKey = (runId, agentId, channelId, threadId) => `${runId}:${agentId}:${channelId}:${threadId || ''}`;
|
|
60
66
|
const sendToClientKey = (clientKey, chunk) => {
|
|
61
67
|
const threadClients = clients.get(clientKey);
|
|
62
|
-
if (!threadClients)
|
|
68
|
+
if (!threadClients || threadClients.length === 0)
|
|
63
69
|
return;
|
|
70
|
+
// Auto-detect "read" state: if someone is listening, they just "read" this event.
|
|
71
|
+
if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
|
|
72
|
+
const parts = clientKey.split(':');
|
|
73
|
+
const channelId = parts[0];
|
|
74
|
+
const threadId = parts[1]; // undefined if no ":"
|
|
75
|
+
storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => { });
|
|
76
|
+
}
|
|
64
77
|
threadClients.forEach((client) => {
|
|
65
78
|
if (!client.writableEnded) {
|
|
66
79
|
client.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
@@ -103,8 +116,31 @@ export async function startServer(options = {}) {
|
|
|
103
116
|
data: { channels },
|
|
104
117
|
};
|
|
105
118
|
};
|
|
119
|
+
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
120
|
+
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
121
|
+
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
122
|
+
// orphaned entries that keep a channel falsely "active". Purging by
|
|
123
|
+
// channel/thread guarantees the snapshot self-heals after a stop.
|
|
124
|
+
const purgeActiveRunsForThread = (channelId, threadId) => {
|
|
125
|
+
const target = threadId || undefined;
|
|
126
|
+
for (const [key, run] of activeRuns) {
|
|
127
|
+
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
128
|
+
activeRuns.delete(key);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
106
132
|
app.use(cors());
|
|
107
|
-
|
|
133
|
+
const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
|
|
134
|
+
app.use((req, res, next) => {
|
|
135
|
+
const isWorkspaceUpload = req.method === 'POST' &&
|
|
136
|
+
req.path === '/api/publish' &&
|
|
137
|
+
req.get('x-openbot-event-type') === 'action:storage:upload-file';
|
|
138
|
+
if (isWorkspaceUpload) {
|
|
139
|
+
express.raw({ type: () => true, limit: '100mb' })(req, res, next);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
express.json({ limit: '20mb' })(req, res, next);
|
|
143
|
+
});
|
|
108
144
|
app.get('/api/health', (req, res) => {
|
|
109
145
|
res.json({ status: 'ok', version: pkg.version });
|
|
110
146
|
});
|
|
@@ -128,6 +164,18 @@ export async function startServer(options = {}) {
|
|
|
128
164
|
clients.set(clientKey, []);
|
|
129
165
|
}
|
|
130
166
|
clients.get(clientKey).push(res);
|
|
167
|
+
// Auto-detect "read" state on connection: mark the latest event as seen.
|
|
168
|
+
if (channelId !== GLOBAL_CHANNEL_ID) {
|
|
169
|
+
storageService
|
|
170
|
+
.getEvents({ channelId, threadId })
|
|
171
|
+
.then((events) => {
|
|
172
|
+
const latestId = events[events.length - 1]?.id;
|
|
173
|
+
if (latestId) {
|
|
174
|
+
return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.catch(() => { });
|
|
178
|
+
}
|
|
131
179
|
if (channelId === GLOBAL_CHANNEL_ID) {
|
|
132
180
|
const snapshot = buildActiveRunsSnapshot();
|
|
133
181
|
ensureEventId(snapshot);
|
|
@@ -155,6 +203,53 @@ export async function startServer(options = {}) {
|
|
|
155
203
|
});
|
|
156
204
|
});
|
|
157
205
|
app.post('/api/publish', async (req, res) => {
|
|
206
|
+
if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
|
|
207
|
+
const channelId = req.get('x-openbot-channel-id') ||
|
|
208
|
+
(typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
|
|
209
|
+
const filePath = req.get('x-openbot-file-path');
|
|
210
|
+
const overwrite = req.get('x-openbot-file-overwrite') === 'true';
|
|
211
|
+
if (!channelId?.trim()) {
|
|
212
|
+
res.status(400).json({ error: 'channelId is required' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!filePath?.trim()) {
|
|
216
|
+
res.status(400).json({ error: 'x-openbot-file-path header is required' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
220
|
+
if (body.length === 0) {
|
|
221
|
+
res.status(400).json({ error: 'Request body is empty' });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const result = await storageService.uploadChannelFile({
|
|
226
|
+
channelId: channelId.trim(),
|
|
227
|
+
path: filePath.trim(),
|
|
228
|
+
body,
|
|
229
|
+
overwrite,
|
|
230
|
+
});
|
|
231
|
+
const url = buildWorkspaceFileUrl({
|
|
232
|
+
baseUrl: resolvePublicBaseUrl(),
|
|
233
|
+
channelId: channelId.trim(),
|
|
234
|
+
filePath: result.path,
|
|
235
|
+
});
|
|
236
|
+
res.json({
|
|
237
|
+
type: 'action:storage:upload-file:result',
|
|
238
|
+
data: { success: true, ...result, url },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
res.status(400).json({
|
|
243
|
+
type: 'action:storage:upload-file:result',
|
|
244
|
+
data: {
|
|
245
|
+
success: false,
|
|
246
|
+
path: filePath,
|
|
247
|
+
error: error instanceof Error ? error.message : 'Upload failed',
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
158
253
|
const parseResult = publishEventSchema.safeParse(req.body);
|
|
159
254
|
if (!parseResult.success) {
|
|
160
255
|
res.status(400).json({
|
|
@@ -169,6 +264,76 @@ export async function startServer(options = {}) {
|
|
|
169
264
|
res.status(400).json({ error: 'channelId is required' });
|
|
170
265
|
return;
|
|
171
266
|
}
|
|
267
|
+
if (event.type === 'action:storage:write-file') {
|
|
268
|
+
const data = (event.data ?? {});
|
|
269
|
+
if (!data.path?.trim()) {
|
|
270
|
+
res.status(400).json({
|
|
271
|
+
type: 'action:storage:write-file:result',
|
|
272
|
+
data: { success: false, path: '', error: 'path is required' },
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (typeof data.content !== 'string') {
|
|
277
|
+
res.status(400).json({
|
|
278
|
+
type: 'action:storage:write-file:result',
|
|
279
|
+
data: { success: false, path: data.path, error: 'content is required' },
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const result = await storageService.writeChannelFile({
|
|
285
|
+
channelId,
|
|
286
|
+
path: data.path.trim(),
|
|
287
|
+
content: data.content,
|
|
288
|
+
encoding: data.encoding ?? 'utf8',
|
|
289
|
+
overwrite: data.overwrite ?? false,
|
|
290
|
+
});
|
|
291
|
+
const url = buildWorkspaceFileUrl({
|
|
292
|
+
baseUrl: resolvePublicBaseUrl(),
|
|
293
|
+
channelId,
|
|
294
|
+
filePath: result.path,
|
|
295
|
+
});
|
|
296
|
+
res.json({
|
|
297
|
+
type: 'action:storage:write-file:result',
|
|
298
|
+
data: { success: true, ...result, url },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
res.status(400).json({
|
|
303
|
+
type: 'action:storage:write-file:result',
|
|
304
|
+
data: {
|
|
305
|
+
success: false,
|
|
306
|
+
path: data.path,
|
|
307
|
+
error: error instanceof Error ? error.message : 'Write failed',
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Stop request: cancel the in-flight run (and any delegated sub-agents in the
|
|
314
|
+
// same thread) instead of spinning up a new agent turn.
|
|
315
|
+
if (event.type === 'action:agent_run_stop') {
|
|
316
|
+
const data = (event.data ?? {});
|
|
317
|
+
const targetChannelId = data.channelId || channelId;
|
|
318
|
+
const targetThreadId = data.threadId || threadId;
|
|
319
|
+
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
320
|
+
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
321
|
+
const stoppedEvent = {
|
|
322
|
+
type: 'agent:run:stopped',
|
|
323
|
+
data: {
|
|
324
|
+
runId: data.runId || runId,
|
|
325
|
+
agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
|
|
326
|
+
channelId: targetChannelId,
|
|
327
|
+
threadId: targetThreadId,
|
|
328
|
+
reason: data.reason,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
ensureEventId(stoppedEvent);
|
|
332
|
+
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
333
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
334
|
+
res.json({ success: stopped });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
172
337
|
const onEvent = async (chunk, state) => {
|
|
173
338
|
const targetChannelId = state?.channelId || channelId;
|
|
174
339
|
const targetThreadId = state?.threadId || threadId;
|
|
@@ -184,6 +349,9 @@ export async function startServer(options = {}) {
|
|
|
184
349
|
else if (chunk.type === 'agent:run:end') {
|
|
185
350
|
activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
|
|
186
351
|
}
|
|
352
|
+
else if (chunk.type === 'agent:run:stopped') {
|
|
353
|
+
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
354
|
+
}
|
|
187
355
|
sendToClientKey(targetClientKey, chunk);
|
|
188
356
|
if (chunk.type === 'agent:run:start' ||
|
|
189
357
|
chunk.type === 'agent:run:end' ||
|
|
@@ -199,6 +367,7 @@ export async function startServer(options = {}) {
|
|
|
199
367
|
event,
|
|
200
368
|
channelId,
|
|
201
369
|
threadId,
|
|
370
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
202
371
|
onEvent,
|
|
203
372
|
});
|
|
204
373
|
res.sendStatus(200);
|
|
@@ -225,6 +394,33 @@ export async function startServer(options = {}) {
|
|
|
225
394
|
return;
|
|
226
395
|
}
|
|
227
396
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
397
|
+
if (event.type === 'action:storage:serve-file') {
|
|
398
|
+
const filePath = event.data?.path;
|
|
399
|
+
if (!channelId?.trim()) {
|
|
400
|
+
res.status(400).json({ error: 'channelId is required' });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!filePath?.trim()) {
|
|
404
|
+
res.status(400).json({ error: 'path is required' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const { abs, size, mimeType } = await storageService.getChannelFileStat({
|
|
409
|
+
channelId,
|
|
410
|
+
path: filePath.trim(),
|
|
411
|
+
});
|
|
412
|
+
res.setHeader('Content-Type', mimeType);
|
|
413
|
+
res.setHeader('Content-Length', String(size));
|
|
414
|
+
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
415
|
+
openChannelFileStream(abs).pipe(res);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
res.status(404).json({
|
|
419
|
+
error: error instanceof Error ? error.message : 'File not found',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
228
424
|
const events = [];
|
|
229
425
|
const onEvent = async (chunk) => {
|
|
230
426
|
events.push(chunk);
|
|
@@ -238,6 +434,7 @@ export async function startServer(options = {}) {
|
|
|
238
434
|
channelId,
|
|
239
435
|
threadId,
|
|
240
436
|
persistEvents: false,
|
|
437
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
241
438
|
onEvent,
|
|
242
439
|
});
|
|
243
440
|
res.json({ events });
|
package/dist/harness/index.js
CHANGED
|
@@ -3,6 +3,9 @@ import { ensureEventId } from '../app/utils.js';
|
|
|
3
3
|
import { storageService } from '../plugins/storage/service.js';
|
|
4
4
|
import { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../app/agent-ids.js';
|
|
5
5
|
import { resolvePlugin } from '../services/plugins/registry.js';
|
|
6
|
+
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
7
|
+
import { loadConfig } from '../app/config.js';
|
|
8
|
+
import { getPublicBaseUrl } from '../plugins/storage/files.js';
|
|
6
9
|
export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
|
|
7
10
|
async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
|
|
8
11
|
ensureEventId(chunk);
|
|
@@ -30,6 +33,12 @@ async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onE
|
|
|
30
33
|
export async function runAgent(options) {
|
|
31
34
|
const { runId, agentId, event, channelId, threadId, onEvent } = options;
|
|
32
35
|
const persistEvents = options.persistEvents !== false;
|
|
36
|
+
let publicBaseUrl = options.publicBaseUrl;
|
|
37
|
+
if (!publicBaseUrl) {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const port = Number(config.port ?? process.env.PORT ?? 4132);
|
|
40
|
+
publicBaseUrl = getPublicBaseUrl(port, config.publicUrl);
|
|
41
|
+
}
|
|
33
42
|
const parentAgentId = event.meta?.parentAgentId;
|
|
34
43
|
const parentToolCallId = event.meta?.parentToolCallId;
|
|
35
44
|
const agentDetails = await storageService.getAgentDetails({ agentId });
|
|
@@ -40,6 +49,10 @@ export async function runAgent(options) {
|
|
|
40
49
|
threadId,
|
|
41
50
|
event,
|
|
42
51
|
});
|
|
52
|
+
// Shared per-thread abort signal so a stop request cancels this run and any
|
|
53
|
+
// delegated sub-agent runs (which execute in the same channel/thread).
|
|
54
|
+
const runKey = abortKey(channelId, threadId);
|
|
55
|
+
const abortSignal = abortRegistry.acquire(runKey);
|
|
43
56
|
await emitEvent({
|
|
44
57
|
type: 'agent:run:start',
|
|
45
58
|
data: { runId, agentId, channelId, threadId },
|
|
@@ -64,11 +77,15 @@ export async function runAgent(options) {
|
|
|
64
77
|
config: ref.config ?? {},
|
|
65
78
|
storage: storageService,
|
|
66
79
|
tools,
|
|
80
|
+
publicBaseUrl,
|
|
81
|
+
abortSignal,
|
|
67
82
|
}));
|
|
68
83
|
}
|
|
69
84
|
const runtime = builder.build();
|
|
70
85
|
const generator = runtime.run(event, { runId, state });
|
|
71
86
|
for await (const outputEvent of generator) {
|
|
87
|
+
if (abortSignal.aborted)
|
|
88
|
+
break;
|
|
72
89
|
await emitEvent(outputEvent, state, {
|
|
73
90
|
persistEvents,
|
|
74
91
|
channelId,
|
|
@@ -83,6 +100,7 @@ export async function runAgent(options) {
|
|
|
83
100
|
console.error(`[harness] Error running agent ${agentId}:`, error);
|
|
84
101
|
}
|
|
85
102
|
finally {
|
|
103
|
+
abortRegistry.release(runKey);
|
|
86
104
|
await emitEvent({
|
|
87
105
|
type: 'agent:run:end',
|
|
88
106
|
data: { runId, agentId, channelId, threadId },
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
/**
|
|
3
3
|
* `approval` — gates protected tool calls behind a UI confirmation widget.
|
|
4
4
|
*
|
|
5
|
-
* This is a simplified version that intercepts specified actions (default:
|
|
5
|
+
* This is a simplified version that intercepts specified actions (default: bash)
|
|
6
6
|
* and requires user approval before they are allowed to proceed.
|
|
7
7
|
*/
|
|
8
8
|
// In-memory tracking for pending approval IDs with TTL (shared across plugin instances)
|
|
@@ -12,21 +12,19 @@ export const approvalPlugin = {
|
|
|
12
12
|
id: 'approval',
|
|
13
13
|
name: 'Approval',
|
|
14
14
|
description: 'Gate protected tool calls behind a UI confirmation widget.',
|
|
15
|
-
factory: ({ config }) => (builder) => {
|
|
16
|
-
// Actions that require approval. Defaults to
|
|
17
|
-
const actionsToApprove = config.actions || ['action:
|
|
15
|
+
factory: ({ config, storage }) => (builder) => {
|
|
16
|
+
// Actions that require approval. Defaults to bash.
|
|
17
|
+
const actionsToApprove = config.actions || ['action:bash'];
|
|
18
18
|
for (const action of actionsToApprove) {
|
|
19
19
|
builder.intercept(action, (event, context) => {
|
|
20
20
|
// If already approved in this flow, let it pass to the actual handler
|
|
21
21
|
if (event.meta?.approvalStatus === 'approved')
|
|
22
22
|
return event;
|
|
23
23
|
// Otherwise, intercept and ask for approval via a UI widget
|
|
24
|
-
const displayData =
|
|
25
|
-
? `\`\`\`bash\n${event.data.command}\n\`\`\``
|
|
26
|
-
: `\`\`\`json\n${JSON.stringify(event.data, null, 2)}\n\`\`\``;
|
|
24
|
+
const displayData = JSON.stringify(event?.data) || '';
|
|
27
25
|
const widgetId = randomUUID();
|
|
28
26
|
pendingApprovals.set(widgetId, Date.now());
|
|
29
|
-
|
|
27
|
+
return {
|
|
30
28
|
type: 'client:ui:widget',
|
|
31
29
|
data: {
|
|
32
30
|
widgetId,
|
|
@@ -43,7 +41,7 @@ export const approvalPlugin = {
|
|
|
43
41
|
],
|
|
44
42
|
},
|
|
45
43
|
meta: { agentId: context.state.agentId, threadId: context.state.threadId },
|
|
46
|
-
}
|
|
44
|
+
};
|
|
47
45
|
});
|
|
48
46
|
}
|
|
49
47
|
// Handle the user's response from the UI widget
|
|
@@ -67,6 +65,7 @@ export const approvalPlugin = {
|
|
|
67
65
|
pendingApprovals.delete(widgetId);
|
|
68
66
|
const originalEvent = metadata.originalEvent;
|
|
69
67
|
const approved = actionId === 'approve';
|
|
68
|
+
const displayData = JSON.stringify(event?.data) || '';
|
|
70
69
|
// Yield a "responded" widget update to the UI
|
|
71
70
|
yield {
|
|
72
71
|
type: 'client:ui:widget',
|
|
@@ -74,7 +73,7 @@ export const approvalPlugin = {
|
|
|
74
73
|
widgetId,
|
|
75
74
|
kind: 'message',
|
|
76
75
|
title: `Action ${approved ? 'Approved' : 'Denied'}`,
|
|
77
|
-
body:
|
|
76
|
+
body: displayData,
|
|
78
77
|
state: approved ? 'submitted' : 'cancelled',
|
|
79
78
|
display: 'collapsed',
|
|
80
79
|
disabled: true,
|
|
@@ -93,16 +92,32 @@ export const approvalPlugin = {
|
|
|
93
92
|
};
|
|
94
93
|
}
|
|
95
94
|
else {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
// Manually store the original event with denied status so it's recorded in history
|
|
96
|
+
// but NOT re-emitted to the pipeline (to avoid actual execution).
|
|
97
|
+
if (storage) {
|
|
98
|
+
await storage.storeEvent({
|
|
99
|
+
channelId: context.state.channelId,
|
|
100
|
+
threadId: context.state.threadId,
|
|
101
|
+
event: {
|
|
102
|
+
...originalEvent,
|
|
103
|
+
meta: {
|
|
104
|
+
...(originalEvent.meta || {}),
|
|
105
|
+
approvalStatus: 'denied',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Emit a failure result event for the denied action to clear the pending tool batch
|
|
111
|
+
yield {
|
|
112
|
+
type: `${originalEvent.type}:result`,
|
|
113
|
+
data: {
|
|
114
|
+
success: false,
|
|
115
|
+
error: 'Action denied by user.',
|
|
116
|
+
stderr: 'Action denied by user.',
|
|
117
|
+
output: 'Action denied by user.',
|
|
118
|
+
},
|
|
119
|
+
meta: originalEvent.meta,
|
|
120
|
+
};
|
|
106
121
|
yield {
|
|
107
122
|
type: 'agent:output',
|
|
108
123
|
data: { content: `Action \`${originalEvent.type}\` was denied.` },
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { resolvePath } from '../../app/config.js';
|
|
5
|
+
const bashToolDefinitions = {
|
|
6
|
+
bash: {
|
|
7
|
+
description: 'Execute a bash command in a stateful session. The working directory and environment variables persist between calls. Use this for all system tasks, file operations, and running development servers.',
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
command: z.string().describe('The bash command to execute.'),
|
|
10
|
+
restart: z
|
|
11
|
+
.boolean()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Restart the bash session before running the command.'),
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
bash_stop: {
|
|
17
|
+
description: 'Stop the bash session for the current or specified channel.',
|
|
18
|
+
inputSchema: z.object({
|
|
19
|
+
channelId: z.string().optional().describe('The channel ID to stop the session for.'),
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
bash_list_sessions: {
|
|
23
|
+
description: 'List all active bash sessions.',
|
|
24
|
+
inputSchema: z.object({}),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const sessions = new Map();
|
|
28
|
+
const getSession = (channelId, initialCwd) => {
|
|
29
|
+
let session = sessions.get(channelId);
|
|
30
|
+
if (!session) {
|
|
31
|
+
const childProcess = spawn('bash', ['--login'], {
|
|
32
|
+
cwd: initialCwd,
|
|
33
|
+
env: { ...process.env, PS1: '' },
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
session = {
|
|
37
|
+
process: childProcess,
|
|
38
|
+
cwd: initialCwd,
|
|
39
|
+
lastActivity: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
sessions.set(channelId, session);
|
|
42
|
+
// Basic error handling for the process
|
|
43
|
+
childProcess.on('error', (err) => {
|
|
44
|
+
console.error(`[bash] Session error for channel ${channelId}:`, err);
|
|
45
|
+
sessions.delete(channelId);
|
|
46
|
+
});
|
|
47
|
+
childProcess.on('exit', () => {
|
|
48
|
+
sessions.delete(channelId);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return session;
|
|
52
|
+
};
|
|
53
|
+
const bashPluginRuntime = () => (builder) => {
|
|
54
|
+
builder.on('action:bash', async function* (event, context) {
|
|
55
|
+
const { command, restart } = event.data;
|
|
56
|
+
const channelId = context.state.channelId;
|
|
57
|
+
const initialCwd = resolvePath(context.state.channelDetails?.cwd || process.cwd());
|
|
58
|
+
if (restart) {
|
|
59
|
+
const oldSession = sessions.get(channelId);
|
|
60
|
+
if (oldSession) {
|
|
61
|
+
oldSession.process.kill();
|
|
62
|
+
sessions.delete(channelId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const session = getSession(channelId, initialCwd);
|
|
66
|
+
session.lastActivity = Date.now();
|
|
67
|
+
try {
|
|
68
|
+
const result = await new Promise((resolve) => {
|
|
69
|
+
let stdout = '';
|
|
70
|
+
let stderr = '';
|
|
71
|
+
let timedOut = false;
|
|
72
|
+
const sentinel = `__OPENBOT_BASH_DONE_${Math.random().toString(36).substring(7)}__`;
|
|
73
|
+
const timeoutMs = 60000; // 1 minute timeout for tool calls
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
timedOut = true;
|
|
76
|
+
// We don't kill the session on timeout, just return what we have
|
|
77
|
+
resolve({ exitCode: null, stdout, stderr, timedOut });
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
const onStdout = (data) => {
|
|
80
|
+
const str = data.toString();
|
|
81
|
+
if (str.includes(sentinel)) {
|
|
82
|
+
const parts = str.split(sentinel);
|
|
83
|
+
stdout += parts[0];
|
|
84
|
+
const exitCodeMatch = parts[1].match(/EXIT:(\d+)/);
|
|
85
|
+
const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
|
|
86
|
+
cleanup();
|
|
87
|
+
resolve({ exitCode, stdout, stderr, timedOut: false });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
stdout += str;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const onStderr = (data) => {
|
|
94
|
+
stderr += data.toString();
|
|
95
|
+
};
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
session.process.stdout?.removeListener('data', onStdout);
|
|
99
|
+
session.process.stderr?.removeListener('data', onStderr);
|
|
100
|
+
};
|
|
101
|
+
session.process.stdout?.on('data', onStdout);
|
|
102
|
+
session.process.stderr?.on('data', onStderr);
|
|
103
|
+
// Execute command and then echo the sentinel with exit code
|
|
104
|
+
session.process.stdin?.write(`${command}\necho "${sentinel}EXIT:$?"\n`);
|
|
105
|
+
});
|
|
106
|
+
yield {
|
|
107
|
+
type: 'action:bash:result',
|
|
108
|
+
data: {
|
|
109
|
+
success: result.exitCode === 0 && !result.timedOut,
|
|
110
|
+
exitCode: result.exitCode,
|
|
111
|
+
stdout: result.stdout.trim(),
|
|
112
|
+
stderr: result.stderr.trim(),
|
|
113
|
+
timedOut: result.timedOut,
|
|
114
|
+
output: result.stderr.trim() ? result.stderr.trim() : result.stdout.trim(),
|
|
115
|
+
},
|
|
116
|
+
meta: event.meta,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : 'Unknown bash error';
|
|
121
|
+
yield {
|
|
122
|
+
type: 'action:bash:result',
|
|
123
|
+
data: {
|
|
124
|
+
success: false,
|
|
125
|
+
exitCode: -1,
|
|
126
|
+
stdout: '',
|
|
127
|
+
stderr: message,
|
|
128
|
+
timedOut: false,
|
|
129
|
+
error: message,
|
|
130
|
+
output: message,
|
|
131
|
+
},
|
|
132
|
+
meta: event.meta,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// Add a tool to stop/kill the session
|
|
137
|
+
builder.on('action:bash_stop', async function* (event, context) {
|
|
138
|
+
const channelId = event.data?.channelId || context.state.channelId;
|
|
139
|
+
const session = sessions.get(channelId);
|
|
140
|
+
if (session) {
|
|
141
|
+
session.process.kill();
|
|
142
|
+
sessions.delete(channelId);
|
|
143
|
+
}
|
|
144
|
+
yield {
|
|
145
|
+
type: 'action:bash_stop:result',
|
|
146
|
+
data: { success: true, output: `Bash session for channel ${channelId} stopped.` },
|
|
147
|
+
meta: event.meta,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
// Add a tool to list all active sessions
|
|
151
|
+
builder.on('action:bash_list_sessions', async function* (event, context) {
|
|
152
|
+
const activeSessions = Array.from(sessions.entries()).map(([channelId, session]) => ({
|
|
153
|
+
channelId,
|
|
154
|
+
cwd: session.cwd,
|
|
155
|
+
lastActivity: session.lastActivity,
|
|
156
|
+
}));
|
|
157
|
+
yield {
|
|
158
|
+
type: 'client:ui:widget',
|
|
159
|
+
data: {
|
|
160
|
+
widgetId: randomUUID(),
|
|
161
|
+
kind: 'list',
|
|
162
|
+
title: 'Active Bash Sessions',
|
|
163
|
+
description: `Found ${activeSessions.length} active bash session${activeSessions.length === 1 ? '' : 's'}.`,
|
|
164
|
+
items: activeSessions.map((s) => ({
|
|
165
|
+
id: s.channelId,
|
|
166
|
+
label: s.channelId,
|
|
167
|
+
description: `CWD: ${s.cwd}`,
|
|
168
|
+
status: 'done',
|
|
169
|
+
metadata: {
|
|
170
|
+
cwd: s.cwd,
|
|
171
|
+
lastActivity: s.lastActivity,
|
|
172
|
+
},
|
|
173
|
+
})),
|
|
174
|
+
},
|
|
175
|
+
meta: event.meta,
|
|
176
|
+
};
|
|
177
|
+
yield {
|
|
178
|
+
type: 'action:bash_list_sessions:result',
|
|
179
|
+
data: {
|
|
180
|
+
success: true,
|
|
181
|
+
sessions: activeSessions,
|
|
182
|
+
output: JSON.stringify(activeSessions),
|
|
183
|
+
},
|
|
184
|
+
meta: event.meta,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
export const bashPlugin = {
|
|
189
|
+
id: 'bash',
|
|
190
|
+
name: 'Bash',
|
|
191
|
+
description: 'Stateful bash session for the channel.',
|
|
192
|
+
toolDefinitions: bashToolDefinitions,
|
|
193
|
+
factory: () => bashPluginRuntime(),
|
|
194
|
+
};
|
|
195
|
+
export default bashPlugin;
|