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.
@@ -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 { ActiveRunsSnapshotEvent, OpenBotEvent, OpenBotState } from './types.js';
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
- DEFAULT_UNCATEGORIZED_SPEC,
28
- UNCATEGORIZED_CHANNEL_ID,
29
- } from './channel-ids.js';
30
-
31
- type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
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 activeRuns = new Map<
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.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
74
- storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
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.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => {});
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 buildActiveRunsSnapshot = (): ActiveRunsSnapshotEvent => {
137
- const byBucket = new Map<string, Bucket>();
138
- for (const run of activeRuns.values()) {
139
- const threadId = run.threadId || undefined;
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 = buildActiveRunsSnapshot();
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 target = threadId || undefined;
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
- ensureEventId(endEvent);
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 (req.path === '/api/health' || req.method === 'OPTIONS') {
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 = buildActiveRunsSnapshot();
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
- persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
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 = async (chunk: OpenBotEvent, state?: OpenBotState) => {
533
- const targetChannelId = state?.channelId || channelId;
534
- const targetThreadId = state?.threadId || threadId;
535
- const targetClientKey = getClientKey(targetChannelId, targetThreadId);
536
-
537
- if (chunk.type === 'agent:run:start') {
538
- activeRuns.set(
539
- getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
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
- channelId,
604
- threadId,
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
- if (isUnknownAgent) {
626
- res.status(400).json({ error: message });
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.status(500).json({ error: 'Failed to process publish event' });
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 = buildActiveRunsSnapshot();
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
- channelId,
696
- threadId,
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: string;
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: string;
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: string;
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
+ }