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.
@@ -9,7 +9,7 @@ import { getPublicBaseUrl } from '../plugins/storage/files.js';
9
9
  import { createPluginHost } from '../services/plugins/host.js';
10
10
  import { getInvokeConfigOverrides, mergePluginConfig } from './merge-plugin-config.js';
11
11
  export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
12
- async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId, }) {
12
+ async function emitEvent(chunk, state, { persistEvents, onEvent, parentAgentId, parentToolCallId, }) {
13
13
  ensureEventId(chunk);
14
14
  // Enrich event with parent metadata if not already present
15
15
  if (parentAgentId || parentToolCallId) {
@@ -19,10 +19,11 @@ async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onE
19
19
  parentToolCallId: chunk.meta?.parentToolCallId || parentToolCallId,
20
20
  };
21
21
  }
22
- if (persistEvents) {
22
+ const channelId = state?.channelId;
23
+ if (persistEvents && channelId) {
23
24
  await storageService.storeEvent({
24
- channelId: state?.channelId || channelId,
25
- threadId: state?.threadId || threadId,
25
+ channelId,
26
+ threadId: state?.threadId,
26
27
  event: chunk,
27
28
  });
28
29
  }
@@ -33,7 +34,7 @@ async function emitEvent(chunk, state, { persistEvents, channelId, threadId, onE
33
34
  * Fire and forget.
34
35
  */
35
36
  export async function runAgent(options) {
36
- const { runId, agentId, event, channelId, threadId, onEvent } = options;
37
+ const { runId, agentId, event, onEvent } = options;
37
38
  const persistEvents = options.persistEvents !== false;
38
39
  let publicBaseUrl = options.publicBaseUrl;
39
40
  if (!publicBaseUrl) {
@@ -47,18 +48,16 @@ export async function runAgent(options) {
47
48
  const state = await storageService.getOpenBotState({
48
49
  runId,
49
50
  agentId,
50
- channelId,
51
- threadId,
52
51
  event,
53
52
  });
54
53
  // Shared per-thread abort signal so a stop request cancels this run and any
55
54
  // delegated sub-agent runs (which execute in the same channel/thread).
56
- const runKey = abortKey(channelId, threadId);
55
+ const runKey = state.channelId ? abortKey(state.channelId, state.threadId) : runId;
57
56
  const abortSignal = abortRegistry.acquire(runKey);
58
57
  await emitEvent({
59
58
  type: 'agent:run:start',
60
- data: { runId, agentId, channelId, threadId },
61
- }, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
59
+ data: { runId, agentId, channelId: state.channelId, threadId: state.threadId },
60
+ }, state, { persistEvents, onEvent, parentAgentId, parentToolCallId });
62
61
  try {
63
62
  const pluginRefs = agentDetails.pluginRefs ?? [];
64
63
  const tools = {};
@@ -97,8 +96,6 @@ export async function runAgent(options) {
97
96
  break;
98
97
  await emitEvent(outputEvent, state, {
99
98
  persistEvents,
100
- channelId,
101
- threadId,
102
99
  onEvent,
103
100
  parentAgentId,
104
101
  parentToolCallId,
@@ -112,7 +109,7 @@ export async function runAgent(options) {
112
109
  abortRegistry.release(runKey);
113
110
  await emitEvent({
114
111
  type: 'agent:run:end',
115
- data: { runId, agentId, channelId, threadId },
116
- }, state, { persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId });
112
+ data: { runId, agentId, channelId: state.channelId, threadId: state.threadId },
113
+ }, state, { persistEvents, onEvent, parentAgentId, parentToolCallId });
117
114
  }
118
115
  }
@@ -0,0 +1,51 @@
1
+ import { parseSlackWebhookPayload, SLACK_WEBHOOK_PROVIDER } from '../../app/slack-webhook.js';
2
+ /**
3
+ * Handles `action:webhook` for the Slack Events API: URL verification challenges and
4
+ * `message` events transformed into `agent:invoke` turns.
5
+ */
6
+ export const slackWebhookPlugin = {
7
+ id: 'slack-webhook',
8
+ name: 'Slack webhook',
9
+ description: 'Parses inbound Slack Events API webhooks and forwards user messages to the runtime.',
10
+ factory: ({ agentId }) => (builder) => {
11
+ builder.on('action:webhook', async function* (event) {
12
+ if (event.data?.provider !== SLACK_WEBHOOK_PROVIDER)
13
+ return;
14
+ const rawBody = Buffer.from(event.data.rawBody, 'base64');
15
+ const parsed = parseSlackWebhookPayload(rawBody);
16
+ if (!parsed)
17
+ return;
18
+ if (parsed.kind === 'url_verification') {
19
+ yield {
20
+ type: 'action:webhook:http-response',
21
+ data: {
22
+ status: 200,
23
+ body: { challenge: parsed.challenge },
24
+ },
25
+ };
26
+ return;
27
+ }
28
+ if (parsed.kind === 'ignored') {
29
+ yield {
30
+ type: 'action:webhook:http-response',
31
+ data: { status: 200, body: { ok: true, ignored: parsed.reason } },
32
+ };
33
+ return;
34
+ }
35
+ const userLabel = parsed.userId ? `<@${parsed.userId}>` : 'Slack user';
36
+ yield {
37
+ type: 'agent:invoke',
38
+ data: {
39
+ role: 'user',
40
+ content: `${userLabel}: ${parsed.text}`,
41
+ },
42
+ meta: {
43
+ agentId,
44
+ slackChannelId: parsed.slackChannelId,
45
+ slackThreadTs: parsed.threadTs,
46
+ slackUserId: parsed.userId,
47
+ },
48
+ };
49
+ });
50
+ },
51
+ };
@@ -43,7 +43,8 @@ export const storagePlugin = {
43
43
  });
44
44
  builder.on('agent:usage', async function* (event, context) {
45
45
  const { usage } = event.data;
46
- if (!context.state.threadId)
46
+ const channelId = context.state.channelId;
47
+ if (!channelId || !context.state.threadId)
47
48
  return;
48
49
  const currentState = context.state.threadDetails?.state || {};
49
50
  const currentUsage = currentState.usage || {
@@ -57,12 +58,12 @@ export const storagePlugin = {
57
58
  totalTokens: (currentUsage.totalTokens || 0) + usage.totalTokens,
58
59
  };
59
60
  await storage.patchThreadState({
60
- channelId: context.state.channelId,
61
+ channelId,
61
62
  threadId: context.state.threadId,
62
63
  state: { usage: nextUsage },
63
64
  });
64
65
  context.state.threadDetails = await storage.getThreadDetails({
65
- channelId: context.state.channelId,
66
+ channelId,
66
67
  threadId: context.state.threadId,
67
68
  });
68
69
  });
@@ -76,9 +77,17 @@ export const storagePlugin = {
76
77
  });
77
78
  builder.on('action:storage:set-last-read', async function* (event, context) {
78
79
  const { channelId, threadId, lastReadEventId } = event.data;
80
+ const resolvedChannelId = channelId || context.state.channelId;
81
+ if (!resolvedChannelId) {
82
+ yield {
83
+ type: 'action:storage:set-last-read-result',
84
+ data: { success: false, error: 'channelId is required' },
85
+ };
86
+ return;
87
+ }
79
88
  try {
80
89
  await storage.setLastRead({
81
- channelId: channelId || context.state.channelId,
90
+ channelId: resolvedChannelId,
82
91
  threadId: threadId || context.state.threadId,
83
92
  lastReadEventId,
84
93
  });
@@ -95,15 +104,29 @@ export const storagePlugin = {
95
104
  }
96
105
  });
97
106
  builder.on('action:storage:get-channel-details', async function* (_, state) {
98
- const channelDetails = await storage.getChannelDetails({
99
- channelId: state.state.channelId,
100
- });
107
+ const channelId = state.state.channelId;
108
+ if (!channelId) {
109
+ yield {
110
+ type: 'action:storage:get-channel-details-result',
111
+ data: { channelDetails: null, error: 'channelId is required' },
112
+ };
113
+ return;
114
+ }
115
+ const channelDetails = await storage.getChannelDetails({ channelId });
101
116
  yield { type: 'action:storage:get-channel-details-result', data: { channelDetails } };
102
117
  });
103
118
  builder.on('action:storage:get-thread-details', async function* (_, state) {
119
+ const channelId = state.state.channelId;
104
120
  const threadId = state.state.threadId;
121
+ if (!channelId) {
122
+ yield {
123
+ type: 'action:storage:get-thread-details-result',
124
+ data: { threadDetails: null, error: 'channelId is required' },
125
+ };
126
+ return;
127
+ }
105
128
  const threadDetails = threadId
106
- ? await storage.getThreadDetails({ channelId: state.state.channelId, threadId })
129
+ ? await storage.getThreadDetails({ channelId, threadId })
107
130
  : null;
108
131
  yield { type: 'action:storage:get-thread-details-result', data: { threadDetails } };
109
132
  });
@@ -210,7 +233,12 @@ export const storagePlugin = {
210
233
  }
211
234
  });
212
235
  builder.on('action:storage:get-events', async function* (_, state) {
213
- const events = await storage.getEvents(state.state);
236
+ const channelId = state.state.channelId;
237
+ if (!channelId) {
238
+ yield { type: 'action:storage:get-events-result', data: { events: [] } };
239
+ return;
240
+ }
241
+ const events = await storage.getEvents({ channelId, threadId: state.state.threadId });
214
242
  yield { type: 'action:storage:get-events-result', data: { events } };
215
243
  });
216
244
  builder.on('action:storage:get-variables', async function* () {
@@ -261,9 +289,14 @@ export const storagePlugin = {
261
289
  }
262
290
  });
263
291
  builder.on('action:storage:patch-channel-state', async function* (event, state) {
292
+ const channelId = state.state.channelId;
293
+ if (!channelId) {
294
+ yield { type: 'action:storage:patch-channel-state-result', data: { success: false } };
295
+ return;
296
+ }
264
297
  try {
265
298
  await storage.patchChannelState({
266
- channelId: state.state.channelId,
299
+ channelId,
267
300
  state: event.data.state,
268
301
  });
269
302
  yield { type: 'action:storage:patch-channel-state-result', data: { success: true } };
@@ -273,12 +306,13 @@ export const storagePlugin = {
273
306
  }
274
307
  });
275
308
  builder.on('action:storage:patch-thread-state', async function* (event, state) {
309
+ const channelId = state.state.channelId;
276
310
  try {
277
- if (!state.state.threadId) {
278
- throw new Error('Missing threadId in state for patch-thread-state');
311
+ if (!channelId || !state.state.threadId) {
312
+ throw new Error('Missing channelId or threadId in state for patch-thread-state');
279
313
  }
280
314
  await storage.patchThreadState({
281
- channelId: state.state.channelId,
315
+ channelId,
282
316
  threadId: state.state.threadId,
283
317
  state: event.data.state,
284
318
  });
@@ -291,6 +325,13 @@ export const storagePlugin = {
291
325
  builder.on('action:storage:list-files', async function* (event, context) {
292
326
  const channelId = context.state.channelId;
293
327
  const subPath = event.data?.path || '';
328
+ if (!channelId) {
329
+ yield {
330
+ type: 'action:storage:list-files:result',
331
+ data: { success: false, files: [], error: 'channelId is required' },
332
+ };
333
+ return;
334
+ }
294
335
  try {
295
336
  const files = await storage.listFiles({ channelId, path: subPath });
296
337
  yield {
@@ -321,6 +362,13 @@ export const storagePlugin = {
321
362
  };
322
363
  return;
323
364
  }
365
+ if (!channelId) {
366
+ yield {
367
+ type: 'action:storage:read-file:result',
368
+ data: { success: false, path: filePath, error: 'channelId is required' },
369
+ };
370
+ return;
371
+ }
324
372
  try {
325
373
  if (encoding === 'utf8') {
326
374
  const content = await storage.readFile({ channelId, path: filePath });
@@ -361,8 +409,18 @@ export const storagePlugin = {
361
409
  };
362
410
  return;
363
411
  }
412
+ if (!channelId) {
413
+ yield {
414
+ type: 'action:storage:get-file-url:result',
415
+ data: { success: false, path: filePath, error: 'channelId is required' },
416
+ };
417
+ return;
418
+ }
364
419
  try {
365
- const { size, mimeType } = await storage.getChannelFileStat({ channelId, path: filePath });
420
+ const { size, mimeType } = await storage.getChannelFileStat({
421
+ channelId,
422
+ path: filePath,
423
+ });
366
424
  const url = buildWorkspaceFileUrl({
367
425
  baseUrl: resolvePublicBaseUrl(),
368
426
  channelId,
@@ -1360,7 +1360,13 @@ export const storageService = {
1360
1360
  * Hydrates the full OpenBot state from disk/storage before a run.
1361
1361
  */
1362
1362
  getOpenBotState: async (options) => {
1363
- const { runId, agentId, channelId, threadId, event } = options;
1363
+ const { runId, agentId, event } = options;
1364
+ const channelId = typeof event.meta?.channelId === 'string'
1365
+ ? event.meta.channelId.trim() || undefined
1366
+ : undefined;
1367
+ const threadId = typeof event.meta?.threadId === 'string'
1368
+ ? event.meta.threadId.trim() || undefined
1369
+ : undefined;
1364
1370
  let agentDetails;
1365
1371
  try {
1366
1372
  agentDetails = await storageService.getAgentDetails({ agentId });
package/docs/plugins.md CHANGED
@@ -60,6 +60,33 @@ model as the tool result (`history.ts`). Structured fields (e.g. `list`,
60
60
  | `todo` | Tool | `todo_write`, `todo_read` (inbuilt in `openbot`) |
61
61
  | `plugin-manager` | Infra | Marketplace list, npm plugin install/uninstall, agent install |
62
62
 
63
+ ## Inbound webhooks
64
+
65
+ `POST /api/webhooks/:provider` forwards third-party HTTP payloads onto the bus as
66
+ `action:webhook`. The URL segment is used as the target agent id when
67
+ `~/.openbot/agents/<provider>/AGENT.md` exists; otherwise the request falls back
68
+ to the `system` agent. Attach a plugin to the routed agent and handle
69
+ verification, channel/thread routing, transforms, and HTTP responses there.
70
+
71
+ ```ts
72
+ builder.on('action:webhook', async function* (event) {
73
+ if (event.data?.provider !== 'slack') return;
74
+
75
+ const rawBody = Buffer.from(event.data.rawBody, 'base64');
76
+
77
+ // Optional: control the HTTP response (URL challenges, 401s, custom acks)
78
+ yield {
79
+ type: 'action:webhook:http-response',
80
+ data: { status: 200, body: { ok: true } },
81
+ };
82
+
83
+ yield {
84
+ type: 'agent:invoke',
85
+ data: { role: 'user', content: '...' },
86
+ };
87
+ });
88
+ ```
89
+
63
90
  ## Batteries-included: `openbot` runtime
64
91
 
65
92
  The `openbot` plugin is the standard runtime for OpenBot agents. It is designed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@
11
11
  "build": "tsc && mkdir -p dist/assets && cp src/assets/icon.svg dist/assets/icon.svg",
12
12
  "start": "node dist/app/cli.js start",
13
13
  "format": "prettier --write .",
14
- "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.9"
14
+ "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.10"
15
15
  },
16
16
  "bin": {
17
17
  "openbot": "./dist/app/cli.js"
@@ -0,0 +1,95 @@
1
+ import { ActiveRunsSnapshotEvent } from './types.js';
2
+
3
+ export type ActiveRunRecord = {
4
+ runId: string;
5
+ channelId: string;
6
+ threadId?: string;
7
+ agentId: string;
8
+ };
9
+
10
+ type Bucket = {
11
+ channelId: string;
12
+ threadId?: string;
13
+ activeCount: number;
14
+ agentIds: Set<string>;
15
+ };
16
+
17
+ export function getRunKey(
18
+ runId: string,
19
+ agentId: string,
20
+ channelId: string,
21
+ threadId?: string,
22
+ ): string {
23
+ return `${runId}:${agentId}:${channelId}:${threadId || ''}`;
24
+ }
25
+
26
+ export class ActiveRunsTracker {
27
+ private runs = new Map<string, ActiveRunRecord>();
28
+
29
+ trackStart(data: ActiveRunRecord): void {
30
+ this.runs.set(getRunKey(data.runId, data.agentId, data.channelId, data.threadId), data);
31
+ }
32
+
33
+ trackEnd(data: ActiveRunRecord): void {
34
+ this.runs.delete(getRunKey(data.runId, data.agentId, data.channelId, data.threadId));
35
+ }
36
+
37
+ buildSnapshot(): ActiveRunsSnapshotEvent {
38
+ const byBucket = new Map<string, Bucket>();
39
+ for (const run of this.runs.values()) {
40
+ const threadId = run.threadId || undefined;
41
+ const key = JSON.stringify([run.channelId, threadId ?? null]);
42
+ let bucket = byBucket.get(key);
43
+ if (!bucket) {
44
+ bucket = {
45
+ channelId: run.channelId,
46
+ threadId,
47
+ activeCount: 0,
48
+ agentIds: new Set<string>(),
49
+ };
50
+ byBucket.set(key, bucket);
51
+ }
52
+ bucket.activeCount += 1;
53
+ bucket.agentIds.add(run.agentId);
54
+ }
55
+ const channels = Array.from(byBucket.values())
56
+ .sort((a, b) => {
57
+ const c = a.channelId.localeCompare(b.channelId);
58
+ if (c !== 0) return c;
59
+ return (a.threadId ?? '').localeCompare(b.threadId ?? '');
60
+ })
61
+ .map(({ channelId, threadId, activeCount, agentIds }) => {
62
+ const row: ActiveRunsSnapshotEvent['data']['channels'][number] = {
63
+ channelId,
64
+ activeCount,
65
+ agentIds: Array.from(agentIds),
66
+ };
67
+ if (threadId !== undefined) {
68
+ row.threadId = threadId;
69
+ }
70
+ return row;
71
+ });
72
+ return {
73
+ type: 'agent:active-runs:snapshot',
74
+ data: { channels },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Drop every tracked run for a channel/thread. Returns the purged records so
80
+ * callers can emit synthetic `agent:run:end` events to SSE clients.
81
+ */
82
+ purgeForThread(channelId: string, threadId?: string): ActiveRunRecord[] {
83
+ const target = threadId || undefined;
84
+ const removed: ActiveRunRecord[] = [];
85
+
86
+ for (const [key, run] of this.runs) {
87
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
88
+ removed.push(run);
89
+ this.runs.delete(key);
90
+ }
91
+ }
92
+
93
+ return removed;
94
+ }
95
+ }
package/src/app/cli.ts CHANGED
@@ -27,7 +27,7 @@ function checkNodeVersion() {
27
27
 
28
28
  checkNodeVersion();
29
29
 
30
- program.name('openbot').description('OpenBot CLI').version('0.5.9');
30
+ program.name('openbot').description('OpenBot CLI').version('0.5.10');
31
31
 
32
32
  program
33
33
  .command('start')
@@ -0,0 +1,46 @@
1
+ import { storageService } from '../plugins/storage/service.js';
2
+ import { OpenBotEvent } from './types.js';
3
+ import { ensureEventId } from './utils.js';
4
+
5
+ export function persistLifecycleEvent(
6
+ event: OpenBotEvent,
7
+ targetChannelId: string,
8
+ targetThreadId?: string,
9
+ ): void {
10
+ ensureEventId(event);
11
+ storageService
12
+ .storeEvent({
13
+ channelId: targetChannelId,
14
+ threadId: targetThreadId,
15
+ event,
16
+ })
17
+ .catch((error) => {
18
+ console.error('[server] Failed to persist lifecycle event', {
19
+ type: event.type,
20
+ channelId: targetChannelId,
21
+ threadId: targetThreadId,
22
+ error,
23
+ });
24
+ });
25
+ }
26
+
27
+ export type LifecycleBroadcastDeps = {
28
+ sendToClientKey: (clientKey: string, event: OpenBotEvent) => void;
29
+ getClientKey: (channelId: string, threadId?: string) => string;
30
+ globalChannelId: string;
31
+ };
32
+
33
+ export function broadcastLifecycleEvent(
34
+ event: OpenBotEvent,
35
+ channelId: string,
36
+ threadId: string | undefined,
37
+ deps: LifecycleBroadcastDeps,
38
+ options?: { persist?: boolean },
39
+ ): void {
40
+ ensureEventId(event);
41
+ if (options?.persist !== false) {
42
+ persistLifecycleEvent(event, channelId, threadId);
43
+ }
44
+ deps.sendToClientKey(deps.getClientKey(channelId, threadId), event);
45
+ deps.sendToClientKey(deps.globalChannelId, event);
46
+ }
@@ -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
+ }