openbot 0.5.8 → 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/CLAUDE.md +1 -1
- 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 +18 -15
- package/dist/harness/merge-plugin-config.js +26 -0
- package/dist/plugins/slack-webhook/index.js +51 -0
- package/dist/plugins/storage/index.js +72 -14
- package/dist/plugins/storage/service.js +11 -6
- package/dist/services/plugins/enrich-openbot-plugin.js +38 -0
- package/dist/services/plugins/host.js +0 -3
- package/dist/services/plugins/model-registry.js +1 -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 +33 -4
- package/src/app/webhook-routing.ts +70 -0
- package/src/harness/index.ts +19 -20
- package/src/harness/merge-plugin-config.ts +35 -0
- package/src/plugins/storage/index.ts +432 -372
- package/src/plugins/storage/service.ts +13 -13
- package/src/services/plugins/enrich-openbot-plugin.ts +41 -0
- package/src/services/plugins/host.ts +0 -6
- package/src/services/plugins/types.ts +0 -8
- package/src/services/plugins/model-registry.ts +0 -178
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ActiveRunsTracker } from './active-runs.js';
|
|
2
|
+
import { OpenBotEvent, OpenBotState } from './types.js';
|
|
3
|
+
import type { WebhookHttpResponse } from './webhook-routing.js';
|
|
4
|
+
|
|
5
|
+
const LIFECYCLE_RUN_TYPES = new Set(['agent:run:start', 'agent:run:end', 'agent:run:stopped']);
|
|
6
|
+
|
|
7
|
+
export type RunEventHandlerDeps = {
|
|
8
|
+
defaultChannelId?: string;
|
|
9
|
+
defaultThreadId?: string;
|
|
10
|
+
activeRuns: ActiveRunsTracker;
|
|
11
|
+
sendToClientKey: (clientKey: string, event: OpenBotEvent) => void;
|
|
12
|
+
getClientKey: (channelId: string, threadId?: string) => string;
|
|
13
|
+
globalChannelId: string;
|
|
14
|
+
onRunStopped?: (channelId: string, threadId?: string) => void;
|
|
15
|
+
onWebhookHttpResponse?: (response: WebhookHttpResponse) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function createRunEventHandler(deps: RunEventHandlerDeps) {
|
|
19
|
+
return async (chunk: OpenBotEvent, state?: OpenBotState): Promise<void> => {
|
|
20
|
+
if (chunk.type === 'action:webhook:http-response' && deps.onWebhookHttpResponse) {
|
|
21
|
+
deps.onWebhookHttpResponse(chunk.data as WebhookHttpResponse);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const targetChannelId = state?.channelId || deps.defaultChannelId;
|
|
26
|
+
const targetThreadId = state?.threadId || deps.defaultThreadId;
|
|
27
|
+
|
|
28
|
+
if (chunk.type === 'agent:run:start' && chunk.data.channelId) {
|
|
29
|
+
deps.activeRuns.trackStart({
|
|
30
|
+
runId: chunk.data.runId,
|
|
31
|
+
channelId: chunk.data.channelId,
|
|
32
|
+
threadId: chunk.data.threadId,
|
|
33
|
+
agentId: chunk.data.agentId,
|
|
34
|
+
});
|
|
35
|
+
} else if (chunk.type === 'agent:run:end' && chunk.data.channelId) {
|
|
36
|
+
deps.activeRuns.trackEnd({
|
|
37
|
+
runId: chunk.data.runId,
|
|
38
|
+
channelId: chunk.data.channelId,
|
|
39
|
+
threadId: chunk.data.threadId,
|
|
40
|
+
agentId: chunk.data.agentId,
|
|
41
|
+
});
|
|
42
|
+
} else if (chunk.type === 'agent:run:stopped' && chunk.data.channelId) {
|
|
43
|
+
deps.onRunStopped?.(chunk.data.channelId, chunk.data.threadId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (targetChannelId) {
|
|
47
|
+
const targetClientKey = deps.getClientKey(targetChannelId, targetThreadId);
|
|
48
|
+
deps.sendToClientKey(targetClientKey, chunk);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (LIFECYCLE_RUN_TYPES.has(chunk.type)) {
|
|
52
|
+
deps.sendToClientKey(deps.globalChannelId, chunk);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
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;
|
|
@@ -42,6 +42,8 @@ export type AgentInvokeEvent = BaseEvent & {
|
|
|
42
42
|
role?: 'user' | 'assistant' | 'system';
|
|
43
43
|
content: string;
|
|
44
44
|
agentId?: string;
|
|
45
|
+
/** Per-run config overrides: flat fields (e.g. model) or nested by plugin id. */
|
|
46
|
+
config?: Record<string, unknown>;
|
|
45
47
|
};
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -146,6 +148,31 @@ export type GetActiveRunsEvent = BaseEvent & {
|
|
|
146
148
|
type: 'action:storage:get-active-runs';
|
|
147
149
|
};
|
|
148
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
|
+
|
|
149
176
|
export type GetAgentDetailsEvent = BaseEvent & {
|
|
150
177
|
type: 'action:storage:get-agent-details';
|
|
151
178
|
data: {
|
|
@@ -479,7 +506,7 @@ export type AgentRunStartEvent = BaseEvent & {
|
|
|
479
506
|
data: {
|
|
480
507
|
runId: string;
|
|
481
508
|
agentId: string;
|
|
482
|
-
channelId
|
|
509
|
+
channelId?: string;
|
|
483
510
|
threadId?: string;
|
|
484
511
|
};
|
|
485
512
|
};
|
|
@@ -489,7 +516,7 @@ export type AgentRunEndEvent = BaseEvent & {
|
|
|
489
516
|
data: {
|
|
490
517
|
runId: string;
|
|
491
518
|
agentId: string;
|
|
492
|
-
channelId
|
|
519
|
+
channelId?: string;
|
|
493
520
|
threadId?: string;
|
|
494
521
|
};
|
|
495
522
|
};
|
|
@@ -1253,4 +1280,6 @@ export type OpenBotEvent =
|
|
|
1253
1280
|
| DelegateTaskEvent
|
|
1254
1281
|
| DelegateTaskResultEvent
|
|
1255
1282
|
| RenderWidgetEvent
|
|
1256
|
-
| 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
|
+
}
|