openbot 0.5.9 → 0.5.11
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 +124 -124
- 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 +39 -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 +143 -154
- package/src/app/types.ts +32 -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/dist/harness/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
22
|
+
const channelId = state?.channelId;
|
|
23
|
+
if (persistEvents && channelId) {
|
|
23
24
|
await storageService.storeEvent({
|
|
24
|
-
channelId
|
|
25
|
-
threadId: state?.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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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,
|
|
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,45 @@ 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
|
+
`GET` and `POST /api/webhooks/:provider` forward third-party HTTP requests onto the
|
|
66
|
+
bus as `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
|
+
GET requests have an empty `rawBody`; use `event.data.method` and `event.data.query`
|
|
72
|
+
for URL verification challenges (for example Meta `hub.challenge`).
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
builder.on('action:webhook', async function* (event) {
|
|
76
|
+
if (event.data?.provider !== 'slack') return;
|
|
77
|
+
|
|
78
|
+
if (event.data.method === 'GET') {
|
|
79
|
+
const challenge = event.data.query?.['hub.challenge'];
|
|
80
|
+
yield {
|
|
81
|
+
type: 'action:webhook:http-response',
|
|
82
|
+
data: { status: 200, body: String(challenge) },
|
|
83
|
+
};
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawBody = Buffer.from(event.data.rawBody, 'base64');
|
|
88
|
+
|
|
89
|
+
// Optional: control the HTTP response (URL challenges, 401s, custom acks)
|
|
90
|
+
yield {
|
|
91
|
+
type: 'action:webhook:http-response',
|
|
92
|
+
data: { status: 200, body: { ok: true } },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
yield {
|
|
96
|
+
type: 'agent:invoke',
|
|
97
|
+
data: { role: 'user', content: '...' },
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
63
102
|
## Batteries-included: `openbot` runtime
|
|
64
103
|
|
|
65
104
|
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.
|
|
3
|
+
"version": "0.5.11",
|
|
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.
|
|
14
|
+
"push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.11"
|
|
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
|
@@ -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
|
+
}
|