openbot 0.5.9 → 0.5.10
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/active-runs.js +70 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/lifecycle-events.js +27 -0
- package/dist/app/run-event-handler.js +37 -0
- package/dist/app/server.js +127 -123
- package/dist/app/slack-webhook.js +81 -0
- package/dist/app/webhook-routing.js +52 -0
- package/dist/harness/index.js +11 -14
- package/dist/plugins/slack-webhook/index.js +51 -0
- package/dist/plugins/storage/index.js +72 -14
- package/dist/plugins/storage/service.js +7 -1
- package/docs/plugins.md +27 -0
- package/package.json +2 -2
- package/src/app/active-runs.ts +95 -0
- package/src/app/cli.ts +1 -1
- package/src/app/lifecycle-events.ts +46 -0
- package/src/app/run-event-handler.ts +55 -0
- package/src/app/server.ts +147 -154
- package/src/app/types.ts +31 -4
- package/src/app/webhook-routing.ts +70 -0
- package/src/harness/index.ts +10 -19
- package/src/plugins/storage/index.ts +432 -372
- package/src/plugins/storage/service.ts +9 -3
- package/src/services/plugins/types.ts +0 -2
package/src/app/server.ts
CHANGED
|
@@ -10,7 +10,10 @@ const pkg = require('../../package.json');
|
|
|
10
10
|
import { generateId } from 'melony';
|
|
11
11
|
import { getBaseDir, loadConfig } from '../app/config.js';
|
|
12
12
|
import { isCloudMode } from './cloud-mode.js';
|
|
13
|
-
import {
|
|
13
|
+
import { OpenBotEvent } from './types.js';
|
|
14
|
+
import { ActiveRunsTracker } from './active-runs.js';
|
|
15
|
+
import { broadcastLifecycleEvent } from './lifecycle-events.js';
|
|
16
|
+
import { createRunEventHandler } from './run-event-handler.js';
|
|
14
17
|
import { processService } from '../services/process.js';
|
|
15
18
|
import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
16
19
|
import { initPlugins } from '../services/plugins/registry.js';
|
|
@@ -23,12 +26,14 @@ import {
|
|
|
23
26
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
24
27
|
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
25
28
|
import { resolvePublishTargetAgentId, resolveRespondingAgentId } from './responding-agent.js';
|
|
29
|
+
import { DEFAULT_UNCATEGORIZED_SPEC, UNCATEGORIZED_CHANNEL_ID } from './channel-ids.js';
|
|
26
30
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type
|
|
31
|
+
pickWebhookHeaders,
|
|
32
|
+
resolveWebhookAgentId,
|
|
33
|
+
respondAgentDispatchError,
|
|
34
|
+
sendWebhookHttpResponse,
|
|
35
|
+
type WebhookHttpResponse,
|
|
36
|
+
} from './webhook-routing.js';
|
|
32
37
|
|
|
33
38
|
export interface ServerOptions {
|
|
34
39
|
port?: number;
|
|
@@ -56,10 +61,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
56
61
|
const app = express();
|
|
57
62
|
const clients: Map<string, express.Response[]> = new Map();
|
|
58
63
|
const GLOBAL_CHANNEL_ID = '__global__';
|
|
59
|
-
const
|
|
60
|
-
string,
|
|
61
|
-
{ runId: string; channelId: string; threadId?: string; agentId: string }
|
|
62
|
-
>();
|
|
64
|
+
const activeRunsTracker = new ActiveRunsTracker();
|
|
63
65
|
|
|
64
66
|
const agentsDir = path.join(openBotDir, 'agents');
|
|
65
67
|
const pluginsDir = path.join(openBotDir, 'plugins');
|
|
@@ -70,14 +72,16 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
70
72
|
initPlugins(pluginsDir);
|
|
71
73
|
|
|
72
74
|
// Pre-warm caches for agents and plugins to speed up first UI load
|
|
73
|
-
storageService
|
|
74
|
-
|
|
75
|
+
storageService
|
|
76
|
+
.getAgents()
|
|
77
|
+
.catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
|
|
78
|
+
storageService
|
|
79
|
+
.getPlugins()
|
|
80
|
+
.catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
|
|
75
81
|
|
|
76
82
|
const getRawChannelId = (req: express.Request): string | undefined => {
|
|
77
83
|
const raw =
|
|
78
|
-
req.get('x-openbot-channel-id') ||
|
|
79
|
-
req.query.channelId ||
|
|
80
|
-
(req.body && req.body.channelId);
|
|
84
|
+
req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
|
|
81
85
|
if (typeof raw !== 'string') return undefined;
|
|
82
86
|
const trimmed = raw.trim();
|
|
83
87
|
return trimmed || undefined;
|
|
@@ -111,8 +115,6 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
111
115
|
|
|
112
116
|
const getClientKey = (channelId: string, threadId?: string) =>
|
|
113
117
|
threadId ? `${channelId}:${threadId}` : channelId;
|
|
114
|
-
const getRunKey = (runId: string, agentId: string, channelId: string, threadId?: string) =>
|
|
115
|
-
`${runId}:${agentId}:${channelId}:${threadId || ''}`;
|
|
116
118
|
|
|
117
119
|
const sendToClientKey = (clientKey: string, chunk: OpenBotEvent) => {
|
|
118
120
|
const threadClients = clients.get(clientKey);
|
|
@@ -123,7 +125,9 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
123
125
|
const parts = clientKey.split(':');
|
|
124
126
|
const channelId = parts[0];
|
|
125
127
|
const threadId = parts[1]; // undefined if no ":"
|
|
126
|
-
storageService
|
|
128
|
+
storageService
|
|
129
|
+
.setLastRead({ channelId, threadId, lastReadEventId: chunk.id })
|
|
130
|
+
.catch(() => { });
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
threadClients.forEach((client) => {
|
|
@@ -133,70 +137,18 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
133
137
|
});
|
|
134
138
|
};
|
|
135
139
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const key = JSON.stringify([run.channelId, threadId ?? null]);
|
|
141
|
-
let bucket = byBucket.get(key);
|
|
142
|
-
if (!bucket) {
|
|
143
|
-
bucket = { channelId: run.channelId, threadId, activeCount: 0, agentIds: new Set<string>() };
|
|
144
|
-
byBucket.set(key, bucket);
|
|
145
|
-
}
|
|
146
|
-
bucket.activeCount += 1;
|
|
147
|
-
bucket.agentIds.add(run.agentId);
|
|
148
|
-
}
|
|
149
|
-
const channels = Array.from(byBucket.values())
|
|
150
|
-
.sort((a, b) => {
|
|
151
|
-
const c = a.channelId.localeCompare(b.channelId);
|
|
152
|
-
if (c !== 0) return c;
|
|
153
|
-
return (a.threadId ?? '').localeCompare(b.threadId ?? '');
|
|
154
|
-
})
|
|
155
|
-
.map(({ channelId, threadId, activeCount, agentIds }) => {
|
|
156
|
-
const row: ActiveRunsSnapshotEvent['data']['channels'][number] = {
|
|
157
|
-
channelId,
|
|
158
|
-
activeCount,
|
|
159
|
-
agentIds: Array.from(agentIds),
|
|
160
|
-
};
|
|
161
|
-
if (threadId !== undefined) {
|
|
162
|
-
row.threadId = threadId;
|
|
163
|
-
}
|
|
164
|
-
return row;
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
167
|
-
type: 'agent:active-runs:snapshot',
|
|
168
|
-
data: { channels },
|
|
169
|
-
};
|
|
140
|
+
const lifecycleDeps = {
|
|
141
|
+
sendToClientKey,
|
|
142
|
+
getClientKey,
|
|
143
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
170
144
|
};
|
|
171
145
|
|
|
172
146
|
const broadcastActiveRunsSnapshot = (): void => {
|
|
173
|
-
const snapshot =
|
|
147
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
174
148
|
ensureEventId(snapshot);
|
|
175
149
|
sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
|
|
176
150
|
};
|
|
177
151
|
|
|
178
|
-
const persistLifecycleEvent = (
|
|
179
|
-
event: OpenBotEvent,
|
|
180
|
-
targetChannelId: string,
|
|
181
|
-
targetThreadId?: string,
|
|
182
|
-
): void => {
|
|
183
|
-
ensureEventId(event);
|
|
184
|
-
storageService
|
|
185
|
-
.storeEvent({
|
|
186
|
-
channelId: targetChannelId,
|
|
187
|
-
threadId: targetThreadId,
|
|
188
|
-
event,
|
|
189
|
-
})
|
|
190
|
-
.catch((error) => {
|
|
191
|
-
console.error('[server] Failed to persist lifecycle event', {
|
|
192
|
-
type: event.type,
|
|
193
|
-
channelId: targetChannelId,
|
|
194
|
-
threadId: targetThreadId,
|
|
195
|
-
error,
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
};
|
|
199
|
-
|
|
200
152
|
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
201
153
|
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
202
154
|
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
@@ -204,22 +156,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
204
156
|
// `agent:run:end` for each purged run and refresh the global snapshot so
|
|
205
157
|
// clients stay in sync even when the parent harness stops yielding.
|
|
206
158
|
const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
|
|
207
|
-
const
|
|
208
|
-
const removed: Array<{
|
|
209
|
-
runId: string;
|
|
210
|
-
channelId: string;
|
|
211
|
-
threadId?: string;
|
|
212
|
-
agentId: string;
|
|
213
|
-
}> = [];
|
|
214
|
-
|
|
215
|
-
for (const [key, run] of activeRuns) {
|
|
216
|
-
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
217
|
-
removed.push(run);
|
|
218
|
-
activeRuns.delete(key);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
for (const run of removed) {
|
|
159
|
+
for (const run of activeRunsTracker.purgeForThread(channelId, threadId)) {
|
|
223
160
|
const endEvent: OpenBotEvent = {
|
|
224
161
|
type: 'agent:run:end',
|
|
225
162
|
data: {
|
|
@@ -229,13 +166,15 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
229
166
|
threadId: run.threadId,
|
|
230
167
|
},
|
|
231
168
|
} as OpenBotEvent;
|
|
232
|
-
|
|
233
|
-
persistLifecycleEvent(endEvent, run.channelId, run.threadId);
|
|
234
|
-
sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
|
|
235
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
|
|
169
|
+
broadcastLifecycleEvent(endEvent, run.channelId, run.threadId, lifecycleDeps);
|
|
236
170
|
}
|
|
237
171
|
};
|
|
238
172
|
|
|
173
|
+
const handleRunStopped = (channelId: string, threadId?: string): void => {
|
|
174
|
+
purgeActiveRunsForThread(channelId, threadId);
|
|
175
|
+
broadcastActiveRunsSnapshot();
|
|
176
|
+
};
|
|
177
|
+
|
|
239
178
|
// Support for Chrome's Private Network Access (PNA)
|
|
240
179
|
// https://developer.chrome.com/blog/private-network-access-preflight/
|
|
241
180
|
app.use((req, res, next) => {
|
|
@@ -254,7 +193,11 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
254
193
|
const gatewayToken = process.env.OPENBOT_GATEWAY_TOKEN?.trim();
|
|
255
194
|
if (gatewayToken) {
|
|
256
195
|
app.use((req, res, next) => {
|
|
257
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
req.path === '/api/health' ||
|
|
198
|
+
req.method === 'OPTIONS' ||
|
|
199
|
+
req.path.startsWith('/api/webhooks/')
|
|
200
|
+
) {
|
|
258
201
|
next();
|
|
259
202
|
return;
|
|
260
203
|
}
|
|
@@ -275,9 +218,10 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
275
218
|
req.method === 'POST' &&
|
|
276
219
|
req.path === '/api/publish' &&
|
|
277
220
|
req.get('x-openbot-event-type') === 'action:storage:upload-file';
|
|
221
|
+
const isWebhook = req.method === 'POST' && req.path.startsWith('/api/webhooks/');
|
|
278
222
|
|
|
279
|
-
if (isWorkspaceUpload) {
|
|
280
|
-
express.raw({ type: () => true, limit: '100mb' })(req, res, next);
|
|
223
|
+
if (isWorkspaceUpload || isWebhook) {
|
|
224
|
+
express.raw({ type: () => true, limit: isWebhook ? '5mb' : '100mb' })(req, res, next);
|
|
281
225
|
return;
|
|
282
226
|
}
|
|
283
227
|
|
|
@@ -322,11 +266,11 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
322
266
|
return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
|
|
323
267
|
}
|
|
324
268
|
})
|
|
325
|
-
.catch(() => {});
|
|
269
|
+
.catch(() => { });
|
|
326
270
|
}
|
|
327
271
|
|
|
328
272
|
if (channelId === GLOBAL_CHANNEL_ID) {
|
|
329
|
-
const snapshot =
|
|
273
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
330
274
|
|
|
331
275
|
ensureEventId(snapshot);
|
|
332
276
|
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
@@ -521,48 +465,21 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
521
465
|
},
|
|
522
466
|
} as OpenBotEvent;
|
|
523
467
|
ensureEventId(stoppedEvent);
|
|
524
|
-
|
|
525
|
-
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
526
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
468
|
+
broadcastLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId, lifecycleDeps);
|
|
527
469
|
|
|
528
470
|
res.json({ success: stopped });
|
|
529
471
|
return;
|
|
530
472
|
}
|
|
531
473
|
|
|
532
|
-
const onEvent =
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
runId: chunk.data.runId,
|
|
542
|
-
channelId: chunk.data.channelId,
|
|
543
|
-
threadId: chunk.data.threadId,
|
|
544
|
-
agentId: chunk.data.agentId,
|
|
545
|
-
},
|
|
546
|
-
);
|
|
547
|
-
} else if (chunk.type === 'agent:run:end') {
|
|
548
|
-
activeRuns.delete(
|
|
549
|
-
getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
|
|
550
|
-
);
|
|
551
|
-
} else if (chunk.type === 'agent:run:stopped') {
|
|
552
|
-
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
553
|
-
broadcastActiveRunsSnapshot();
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
sendToClientKey(targetClientKey, chunk);
|
|
557
|
-
|
|
558
|
-
if (
|
|
559
|
-
chunk.type === 'agent:run:start' ||
|
|
560
|
-
chunk.type === 'agent:run:end' ||
|
|
561
|
-
chunk.type === 'agent:run:stopped'
|
|
562
|
-
) {
|
|
563
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, chunk);
|
|
564
|
-
}
|
|
565
|
-
};
|
|
474
|
+
const onEvent = createRunEventHandler({
|
|
475
|
+
defaultChannelId: channelId,
|
|
476
|
+
defaultThreadId: threadId,
|
|
477
|
+
activeRuns: activeRunsTracker,
|
|
478
|
+
sendToClientKey,
|
|
479
|
+
getClientKey,
|
|
480
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
481
|
+
onRunStopped: handleRunStopped,
|
|
482
|
+
});
|
|
566
483
|
|
|
567
484
|
try {
|
|
568
485
|
ensureEventId(event);
|
|
@@ -599,21 +516,15 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
599
516
|
await runAgent({
|
|
600
517
|
runId,
|
|
601
518
|
agentId: target.agentId,
|
|
602
|
-
event
|
|
603
|
-
|
|
604
|
-
|
|
519
|
+
event: {
|
|
520
|
+
...event,
|
|
521
|
+
meta: { ...event.meta, channelId, threadId },
|
|
522
|
+
},
|
|
605
523
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
606
524
|
onEvent,
|
|
607
525
|
});
|
|
608
526
|
res.sendStatus(200);
|
|
609
527
|
} catch (error) {
|
|
610
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
611
|
-
const isUnknownAgent =
|
|
612
|
-
(error instanceof Error &&
|
|
613
|
-
((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
|
|
614
|
-
error.message.includes('does not exist'))) ||
|
|
615
|
-
message.includes('does not exist');
|
|
616
|
-
|
|
617
528
|
console.error('[publish] Failed to dispatch event', {
|
|
618
529
|
runId,
|
|
619
530
|
channelId,
|
|
@@ -622,12 +533,93 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
622
533
|
error,
|
|
623
534
|
});
|
|
624
535
|
|
|
625
|
-
|
|
626
|
-
|
|
536
|
+
respondAgentDispatchError(res, error, 'Failed to process publish event');
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const WEBHOOK_TIMEOUT_MS = Number(process.env.OPENBOT_WEBHOOK_TIMEOUT_MS ?? 30_000);
|
|
541
|
+
|
|
542
|
+
app.post('/api/webhooks/:provider', async (req, res) => {
|
|
543
|
+
const provider = req.params.provider?.trim();
|
|
544
|
+
if (!provider || !/^[a-z0-9][a-z0-9_-]*$/i.test(provider)) {
|
|
545
|
+
res.status(400).json({ error: 'Invalid webhook provider' });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
550
|
+
const runId = `run_${generateId()}`;
|
|
551
|
+
const agentId = await resolveWebhookAgentId(provider);
|
|
552
|
+
|
|
553
|
+
let httpResponse: WebhookHttpResponse | null = null;
|
|
554
|
+
|
|
555
|
+
const onEvent = createRunEventHandler({
|
|
556
|
+
activeRuns: activeRunsTracker,
|
|
557
|
+
sendToClientKey,
|
|
558
|
+
getClientKey,
|
|
559
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
560
|
+
onRunStopped: handleRunStopped,
|
|
561
|
+
onWebhookHttpResponse: (response) => {
|
|
562
|
+
httpResponse = response;
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const event: OpenBotEvent = {
|
|
567
|
+
type: 'action:webhook',
|
|
568
|
+
data: {
|
|
569
|
+
provider,
|
|
570
|
+
rawBody: rawBody.toString('base64'),
|
|
571
|
+
headers: pickWebhookHeaders(req),
|
|
572
|
+
query: req.query as Record<string, unknown>,
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
ensureEventId(event);
|
|
578
|
+
|
|
579
|
+
console.log('runAgent', {
|
|
580
|
+
runId,
|
|
581
|
+
agentId,
|
|
582
|
+
event,
|
|
583
|
+
persistEvents: true,
|
|
584
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
585
|
+
onEvent,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const runPromise = runAgent({
|
|
589
|
+
runId,
|
|
590
|
+
agentId,
|
|
591
|
+
event,
|
|
592
|
+
persistEvents: true,
|
|
593
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
594
|
+
onEvent,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
598
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
599
|
+
timeoutId = setTimeout(() => reject(new Error('webhook_timeout')), WEBHOOK_TIMEOUT_MS);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
await Promise.race([runPromise, timeoutPromise]);
|
|
604
|
+
} finally {
|
|
605
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
sendWebhookHttpResponse(res, httpResponse ?? { status: 200, body: {} });
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const isTimeout = error instanceof Error && error.message === 'webhook_timeout';
|
|
611
|
+
console.error('[webhook] Failed to process inbound webhook', {
|
|
612
|
+
provider,
|
|
613
|
+
agentId,
|
|
614
|
+
error,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
if (isTimeout) {
|
|
618
|
+
res.status(504).json({ error: 'Webhook handler timed out' });
|
|
627
619
|
return;
|
|
628
620
|
}
|
|
629
621
|
|
|
630
|
-
res
|
|
622
|
+
respondAgentDispatchError(res, error, 'Failed to process webhook');
|
|
631
623
|
}
|
|
632
624
|
});
|
|
633
625
|
|
|
@@ -645,7 +637,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
645
637
|
|
|
646
638
|
// In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
|
|
647
639
|
if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
|
|
648
|
-
const snapshot =
|
|
640
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
649
641
|
ensureEventId(snapshot);
|
|
650
642
|
res.json({ events: [snapshot] });
|
|
651
643
|
return;
|
|
@@ -691,9 +683,10 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
691
683
|
await runAgent({
|
|
692
684
|
runId,
|
|
693
685
|
agentId: agentId || STATE_AGENT_ID,
|
|
694
|
-
event
|
|
695
|
-
|
|
696
|
-
|
|
686
|
+
event: {
|
|
687
|
+
...event,
|
|
688
|
+
meta: { ...event.meta, channelId, threadId },
|
|
689
|
+
},
|
|
697
690
|
persistEvents: false,
|
|
698
691
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
699
692
|
onEvent,
|
package/src/app/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { TodoList } from '../services/todo/types.js';
|
|
|
14
14
|
export interface OpenBotState {
|
|
15
15
|
agentId: string;
|
|
16
16
|
runId: string;
|
|
17
|
-
channelId
|
|
17
|
+
channelId?: string;
|
|
18
18
|
threadId?: string;
|
|
19
19
|
agentDetails?: AgentDetails;
|
|
20
20
|
channelDetails?: ChannelDetails;
|
|
@@ -148,6 +148,31 @@ export type GetActiveRunsEvent = BaseEvent & {
|
|
|
148
148
|
type: 'action:storage:get-active-runs';
|
|
149
149
|
};
|
|
150
150
|
|
|
151
|
+
/** Inbound third-party webhook forwarded from `POST /api/webhooks/:provider`. */
|
|
152
|
+
export type WebhookEvent = BaseEvent & {
|
|
153
|
+
type: 'action:webhook';
|
|
154
|
+
data: {
|
|
155
|
+
provider: string;
|
|
156
|
+
/** Request body encoded as base64 so plugins can verify HMACs on the raw bytes. */
|
|
157
|
+
rawBody: string;
|
|
158
|
+
headers: Record<string, string | string[] | undefined>;
|
|
159
|
+
query: Record<string, unknown>;
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Plugin yields this to control the HTTP response for a webhook request
|
|
165
|
+
* (e.g. Slack URL verification, signature failures, provider-specific acks).
|
|
166
|
+
*/
|
|
167
|
+
export type WebhookHttpResponseEvent = BaseEvent & {
|
|
168
|
+
type: 'action:webhook:http-response';
|
|
169
|
+
data: {
|
|
170
|
+
status: number;
|
|
171
|
+
body?: unknown;
|
|
172
|
+
headers?: Record<string, string>;
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
151
176
|
export type GetAgentDetailsEvent = BaseEvent & {
|
|
152
177
|
type: 'action:storage:get-agent-details';
|
|
153
178
|
data: {
|
|
@@ -481,7 +506,7 @@ export type AgentRunStartEvent = BaseEvent & {
|
|
|
481
506
|
data: {
|
|
482
507
|
runId: string;
|
|
483
508
|
agentId: string;
|
|
484
|
-
channelId
|
|
509
|
+
channelId?: string;
|
|
485
510
|
threadId?: string;
|
|
486
511
|
};
|
|
487
512
|
};
|
|
@@ -491,7 +516,7 @@ export type AgentRunEndEvent = BaseEvent & {
|
|
|
491
516
|
data: {
|
|
492
517
|
runId: string;
|
|
493
518
|
agentId: string;
|
|
494
|
-
channelId
|
|
519
|
+
channelId?: string;
|
|
495
520
|
threadId?: string;
|
|
496
521
|
};
|
|
497
522
|
};
|
|
@@ -1255,4 +1280,6 @@ export type OpenBotEvent =
|
|
|
1255
1280
|
| DelegateTaskEvent
|
|
1256
1281
|
| DelegateTaskResultEvent
|
|
1257
1282
|
| RenderWidgetEvent
|
|
1258
|
-
| RenderWidgetResultEvent
|
|
1283
|
+
| RenderWidgetResultEvent
|
|
1284
|
+
| WebhookEvent
|
|
1285
|
+
| WebhookHttpResponseEvent;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import { ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
3
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
4
|
+
|
|
5
|
+
/** Default agent when no `agents/<provider>/AGENT.md` exists. */
|
|
6
|
+
export const WEBHOOK_AGENT_FALLBACK_ID = ORCHESTRATOR_AGENT_ID;
|
|
7
|
+
|
|
8
|
+
export const isAgentNotFound = (error: unknown): boolean =>
|
|
9
|
+
error instanceof Error &&
|
|
10
|
+
((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
|
|
11
|
+
error.message.includes('does not exist'));
|
|
12
|
+
|
|
13
|
+
export function respondAgentDispatchError(
|
|
14
|
+
res: Response,
|
|
15
|
+
error: unknown,
|
|
16
|
+
fallbackMessage: string,
|
|
17
|
+
): void {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
if (isAgentNotFound(error) || message.includes('does not exist')) {
|
|
20
|
+
res.status(400).json({ error: message });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
res.status(500).json({ error: fallbackMessage });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Routes `POST /api/webhooks/:provider` to agent `<provider>` when that agent exists,
|
|
28
|
+
* otherwise falls back to {@link WEBHOOK_AGENT_FALLBACK_ID}.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveWebhookAgentId(provider: string): Promise<string> {
|
|
31
|
+
try {
|
|
32
|
+
await storageService.getAgentDetails({ agentId: provider });
|
|
33
|
+
return provider;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (isAgentNotFound(error)) {
|
|
36
|
+
return WEBHOOK_AGENT_FALLBACK_ID;
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Copy request headers for plugin-side signature verification. */
|
|
43
|
+
export function pickWebhookHeaders(req: Request): Record<string, string | string[] | undefined> {
|
|
44
|
+
return { ...req.headers };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type WebhookHttpResponse = {
|
|
48
|
+
status: number;
|
|
49
|
+
body?: unknown;
|
|
50
|
+
headers?: Record<string, string>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function sendWebhookHttpResponse(res: Response, out: WebhookHttpResponse): void {
|
|
54
|
+
if (out.headers) {
|
|
55
|
+
for (const [key, value] of Object.entries(out.headers)) {
|
|
56
|
+
res.setHeader(key, value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const status = out.status;
|
|
61
|
+
if (out.body === undefined) {
|
|
62
|
+
res.sendStatus(status);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (typeof out.body === 'string') {
|
|
66
|
+
res.status(status).send(out.body);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.status(status).json(out.body);
|
|
70
|
+
}
|
package/src/harness/index.ts
CHANGED
|
@@ -17,8 +17,6 @@ export interface RunAgentOptions {
|
|
|
17
17
|
runId: string;
|
|
18
18
|
agentId: string;
|
|
19
19
|
event: OpenBotEvent;
|
|
20
|
-
channelId: string;
|
|
21
|
-
threadId?: string;
|
|
22
20
|
persistEvents?: boolean;
|
|
23
21
|
/** Resolved public base URL for the server. */
|
|
24
22
|
publicBaseUrl?: string;
|
|
@@ -30,15 +28,11 @@ async function emitEvent(
|
|
|
30
28
|
state: OpenBotState | undefined,
|
|
31
29
|
{
|
|
32
30
|
persistEvents,
|
|
33
|
-
channelId,
|
|
34
|
-
threadId,
|
|
35
31
|
onEvent,
|
|
36
32
|
parentAgentId,
|
|
37
33
|
parentToolCallId,
|
|
38
34
|
}: {
|
|
39
35
|
persistEvents: boolean;
|
|
40
|
-
channelId: string;
|
|
41
|
-
threadId?: string;
|
|
42
36
|
onEvent: RunAgentOptions['onEvent'];
|
|
43
37
|
parentAgentId?: string;
|
|
44
38
|
parentToolCallId?: string;
|
|
@@ -55,10 +49,11 @@ async function emitEvent(
|
|
|
55
49
|
};
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
|
|
52
|
+
const channelId = state?.channelId;
|
|
53
|
+
if (persistEvents && channelId) {
|
|
59
54
|
await storageService.storeEvent({
|
|
60
|
-
channelId
|
|
61
|
-
threadId: state?.threadId
|
|
55
|
+
channelId,
|
|
56
|
+
threadId: state?.threadId,
|
|
62
57
|
event: chunk,
|
|
63
58
|
});
|
|
64
59
|
}
|
|
@@ -71,7 +66,7 @@ async function emitEvent(
|
|
|
71
66
|
* Fire and forget.
|
|
72
67
|
*/
|
|
73
68
|
export async function runAgent(options: RunAgentOptions): Promise<void> {
|
|
74
|
-
const { runId, agentId, event,
|
|
69
|
+
const { runId, agentId, event, onEvent } = options;
|
|
75
70
|
const persistEvents = options.persistEvents !== false;
|
|
76
71
|
|
|
77
72
|
let publicBaseUrl = options.publicBaseUrl;
|
|
@@ -89,23 +84,21 @@ export async function runAgent(options: RunAgentOptions): Promise<void> {
|
|
|
89
84
|
const state = await storageService.getOpenBotState({
|
|
90
85
|
runId,
|
|
91
86
|
agentId,
|
|
92
|
-
channelId,
|
|
93
|
-
threadId,
|
|
94
87
|
event,
|
|
95
88
|
});
|
|
96
89
|
|
|
97
90
|
// Shared per-thread abort signal so a stop request cancels this run and any
|
|
98
91
|
// delegated sub-agent runs (which execute in the same channel/thread).
|
|
99
|
-
const runKey = abortKey(channelId, threadId);
|
|
92
|
+
const runKey = state.channelId ? abortKey(state.channelId, state.threadId) : runId;
|
|
100
93
|
const abortSignal = abortRegistry.acquire(runKey);
|
|
101
94
|
|
|
102
95
|
await emitEvent(
|
|
103
96
|
{
|
|
104
97
|
type: 'agent:run:start',
|
|
105
|
-
data: { runId, agentId, channelId, threadId },
|
|
98
|
+
data: { runId, agentId, channelId: state.channelId, threadId: state.threadId },
|
|
106
99
|
} as OpenBotEvent,
|
|
107
100
|
state,
|
|
108
|
-
{ persistEvents,
|
|
101
|
+
{ persistEvents, onEvent, parentAgentId, parentToolCallId },
|
|
109
102
|
);
|
|
110
103
|
|
|
111
104
|
try {
|
|
@@ -155,8 +148,6 @@ export async function runAgent(options: RunAgentOptions): Promise<void> {
|
|
|
155
148
|
if (abortSignal.aborted) break;
|
|
156
149
|
await emitEvent(outputEvent, state, {
|
|
157
150
|
persistEvents,
|
|
158
|
-
channelId,
|
|
159
|
-
threadId,
|
|
160
151
|
onEvent,
|
|
161
152
|
parentAgentId,
|
|
162
153
|
parentToolCallId,
|
|
@@ -169,10 +160,10 @@ export async function runAgent(options: RunAgentOptions): Promise<void> {
|
|
|
169
160
|
await emitEvent(
|
|
170
161
|
{
|
|
171
162
|
type: 'agent:run:end',
|
|
172
|
-
data: { runId, agentId, channelId, threadId },
|
|
163
|
+
data: { runId, agentId, channelId: state.channelId, threadId: state.threadId },
|
|
173
164
|
} as OpenBotEvent,
|
|
174
165
|
state,
|
|
175
|
-
{ persistEvents,
|
|
166
|
+
{ persistEvents, onEvent, parentAgentId, parentToolCallId },
|
|
176
167
|
);
|
|
177
168
|
}
|
|
178
169
|
}
|