openbot 0.4.4 → 0.4.5
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/cli.js +1 -1
- package/dist/app/responding-agent.js +48 -0
- package/dist/app/server.js +88 -4
- package/dist/plugins/storage/service.js +1 -1
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/responding-agent.ts +74 -0
- package/src/app/server.ts +106 -4
- package/src/app/types.ts +5 -0
- package/src/plugins/storage/service.ts +2 -1
- package/src/services/plugins/domain.ts +15 -1
package/dist/app/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ function checkNodeVersion() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
checkNodeVersion();
|
|
19
|
-
program.name('openbot').description('OpenBot CLI').version('0.4.
|
|
19
|
+
program.name('openbot').description('OpenBot CLI').version('0.4.5');
|
|
20
20
|
program
|
|
21
21
|
.command('start')
|
|
22
22
|
.description('Start the OpenBot harness')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
|
|
2
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
3
|
+
/** Thread `state.json` key for the sticky responding agent id. */
|
|
4
|
+
export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
|
|
5
|
+
const readBoundAgentId = (state) => {
|
|
6
|
+
if (!state || typeof state !== 'object')
|
|
7
|
+
return undefined;
|
|
8
|
+
const value = state[THREAD_RESPONDING_AGENT_ID_KEY];
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return undefined;
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
return trimmed || undefined;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolves which agent should handle a thread-scoped publish.
|
|
16
|
+
* Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveRespondingAgentId(options) {
|
|
19
|
+
const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
|
|
20
|
+
const requested = requestedAgentId?.trim() || undefined;
|
|
21
|
+
const fallback = requested || ORCHESTRATOR_AGENT_ID;
|
|
22
|
+
if (!threadId) {
|
|
23
|
+
return { agentId: fallback, bound: false, overridden: false };
|
|
24
|
+
}
|
|
25
|
+
const details = await storageService.getThreadDetails({ channelId, threadId });
|
|
26
|
+
const bound = readBoundAgentId(details.state);
|
|
27
|
+
if (bound) {
|
|
28
|
+
const overridden = !!requested && requested !== bound;
|
|
29
|
+
if (overridden) {
|
|
30
|
+
console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
|
|
31
|
+
threadId,
|
|
32
|
+
bound,
|
|
33
|
+
requested,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return { agentId: bound, bound: true, overridden };
|
|
37
|
+
}
|
|
38
|
+
if (!bindIfUnbound) {
|
|
39
|
+
return { agentId: fallback, bound: false, overridden: false };
|
|
40
|
+
}
|
|
41
|
+
await storageService.getAgentDetails({ agentId: fallback });
|
|
42
|
+
await storageService.patchThreadState({
|
|
43
|
+
channelId,
|
|
44
|
+
threadId,
|
|
45
|
+
state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
|
|
46
|
+
});
|
|
47
|
+
return { agentId: fallback, bound: true, overridden: false };
|
|
48
|
+
}
|
package/dist/app/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { storageService } from '../plugins/storage/service.js';
|
|
|
16
16
|
import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
|
|
17
17
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
18
18
|
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
19
|
+
import { resolveRespondingAgentId } from './responding-agent.js';
|
|
19
20
|
export async function startServer(options = {}) {
|
|
20
21
|
const publishEventSchema = z
|
|
21
22
|
.object({
|
|
@@ -116,18 +117,58 @@ export async function startServer(options = {}) {
|
|
|
116
117
|
data: { channels },
|
|
117
118
|
};
|
|
118
119
|
};
|
|
120
|
+
const broadcastActiveRunsSnapshot = () => {
|
|
121
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
122
|
+
ensureEventId(snapshot);
|
|
123
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
|
|
124
|
+
};
|
|
125
|
+
const persistLifecycleEvent = (event, targetChannelId, targetThreadId) => {
|
|
126
|
+
ensureEventId(event);
|
|
127
|
+
storageService
|
|
128
|
+
.storeEvent({
|
|
129
|
+
channelId: targetChannelId,
|
|
130
|
+
threadId: targetThreadId,
|
|
131
|
+
event,
|
|
132
|
+
})
|
|
133
|
+
.catch((error) => {
|
|
134
|
+
console.error('[server] Failed to persist lifecycle event', {
|
|
135
|
+
type: event.type,
|
|
136
|
+
channelId: targetChannelId,
|
|
137
|
+
threadId: targetThreadId,
|
|
138
|
+
error,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
};
|
|
119
142
|
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
120
143
|
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
121
144
|
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
122
|
-
// orphaned entries that keep a channel falsely "active".
|
|
123
|
-
//
|
|
145
|
+
// orphaned entries that keep a channel falsely "active". Emit explicit
|
|
146
|
+
// `agent:run:end` for each purged run and refresh the global snapshot so
|
|
147
|
+
// clients stay in sync even when the parent harness stops yielding.
|
|
124
148
|
const purgeActiveRunsForThread = (channelId, threadId) => {
|
|
125
149
|
const target = threadId || undefined;
|
|
150
|
+
const removed = [];
|
|
126
151
|
for (const [key, run] of activeRuns) {
|
|
127
152
|
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
153
|
+
removed.push(run);
|
|
128
154
|
activeRuns.delete(key);
|
|
129
155
|
}
|
|
130
156
|
}
|
|
157
|
+
for (const run of removed) {
|
|
158
|
+
const endEvent = {
|
|
159
|
+
type: 'agent:run:end',
|
|
160
|
+
data: {
|
|
161
|
+
runId: run.runId,
|
|
162
|
+
agentId: run.agentId,
|
|
163
|
+
channelId: run.channelId,
|
|
164
|
+
threadId: run.threadId,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
ensureEventId(endEvent);
|
|
168
|
+
persistLifecycleEvent(endEvent, run.channelId, run.threadId);
|
|
169
|
+
sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
|
|
170
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
|
|
171
|
+
}
|
|
131
172
|
};
|
|
132
173
|
// Support for Chrome's Private Network Access (PNA)
|
|
133
174
|
// https://developer.chrome.com/blog/private-network-access-preflight/
|
|
@@ -328,19 +369,38 @@ export async function startServer(options = {}) {
|
|
|
328
369
|
const data = (event.data ?? {});
|
|
329
370
|
const targetChannelId = data.channelId || channelId;
|
|
330
371
|
const targetThreadId = data.threadId || threadId;
|
|
372
|
+
let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
|
|
373
|
+
try {
|
|
374
|
+
const resolved = await resolveRespondingAgentId({
|
|
375
|
+
channelId: targetChannelId,
|
|
376
|
+
threadId: targetThreadId,
|
|
377
|
+
requestedAgentId: data.agentId || agentId,
|
|
378
|
+
});
|
|
379
|
+
resolvedStopAgentId = resolved.agentId;
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
console.warn('[publish] Failed to resolve responding agent for stop request', {
|
|
383
|
+
channelId: targetChannelId,
|
|
384
|
+
threadId: targetThreadId,
|
|
385
|
+
error,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
331
388
|
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
332
389
|
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
390
|
+
// Resync global clients even when nothing was tracked server-side.
|
|
391
|
+
broadcastActiveRunsSnapshot();
|
|
333
392
|
const stoppedEvent = {
|
|
334
393
|
type: 'agent:run:stopped',
|
|
335
394
|
data: {
|
|
336
395
|
runId: data.runId || runId,
|
|
337
|
-
agentId:
|
|
396
|
+
agentId: resolvedStopAgentId,
|
|
338
397
|
channelId: targetChannelId,
|
|
339
398
|
threadId: targetThreadId,
|
|
340
399
|
reason: data.reason,
|
|
341
400
|
},
|
|
342
401
|
};
|
|
343
402
|
ensureEventId(stoppedEvent);
|
|
403
|
+
persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
|
|
344
404
|
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
345
405
|
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
346
406
|
res.json({ success: stopped });
|
|
@@ -363,6 +423,7 @@ export async function startServer(options = {}) {
|
|
|
363
423
|
}
|
|
364
424
|
else if (chunk.type === 'agent:run:stopped') {
|
|
365
425
|
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
426
|
+
broadcastActiveRunsSnapshot();
|
|
366
427
|
}
|
|
367
428
|
sendToClientKey(targetClientKey, chunk);
|
|
368
429
|
if (chunk.type === 'agent:run:start' ||
|
|
@@ -373,9 +434,16 @@ export async function startServer(options = {}) {
|
|
|
373
434
|
};
|
|
374
435
|
try {
|
|
375
436
|
ensureEventId(event);
|
|
437
|
+
const bindIfUnbound = event.type === 'agent:invoke';
|
|
438
|
+
const resolved = await resolveRespondingAgentId({
|
|
439
|
+
channelId,
|
|
440
|
+
threadId,
|
|
441
|
+
requestedAgentId: agentId,
|
|
442
|
+
bindIfUnbound,
|
|
443
|
+
});
|
|
376
444
|
await runAgent({
|
|
377
445
|
runId,
|
|
378
|
-
agentId: agentId
|
|
446
|
+
agentId: resolved.agentId,
|
|
379
447
|
event,
|
|
380
448
|
channelId,
|
|
381
449
|
threadId,
|
|
@@ -385,6 +453,11 @@ export async function startServer(options = {}) {
|
|
|
385
453
|
res.sendStatus(200);
|
|
386
454
|
}
|
|
387
455
|
catch (error) {
|
|
456
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
457
|
+
const isUnknownAgent = (error instanceof Error &&
|
|
458
|
+
(error.code === 'AGENT_NOT_FOUND' ||
|
|
459
|
+
error.message.includes('does not exist'))) ||
|
|
460
|
+
message.includes('does not exist');
|
|
388
461
|
console.error('[publish] Failed to dispatch event', {
|
|
389
462
|
runId,
|
|
390
463
|
channelId,
|
|
@@ -392,6 +465,10 @@ export async function startServer(options = {}) {
|
|
|
392
465
|
eventType: event.type,
|
|
393
466
|
error,
|
|
394
467
|
});
|
|
468
|
+
if (isUnknownAgent) {
|
|
469
|
+
res.status(400).json({ error: message });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
395
472
|
res.status(500).json({ error: 'Failed to process publish event' });
|
|
396
473
|
}
|
|
397
474
|
});
|
|
@@ -406,6 +483,13 @@ export async function startServer(options = {}) {
|
|
|
406
483
|
return;
|
|
407
484
|
}
|
|
408
485
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
486
|
+
// In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
|
|
487
|
+
if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
|
|
488
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
489
|
+
ensureEventId(snapshot);
|
|
490
|
+
res.json({ events: [snapshot] });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
409
493
|
if (event.type === 'action:storage:serve-file') {
|
|
410
494
|
const filePath = event.data?.path;
|
|
411
495
|
if (!channelId?.trim()) {
|
package/package.json
CHANGED
package/src/app/cli.ts
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
|
|
2
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
3
|
+
|
|
4
|
+
/** Thread `state.json` key for the sticky responding agent id. */
|
|
5
|
+
export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
|
|
6
|
+
|
|
7
|
+
export type ResolveRespondingAgentOptions = {
|
|
8
|
+
channelId: string;
|
|
9
|
+
threadId?: string;
|
|
10
|
+
requestedAgentId?: string;
|
|
11
|
+
/** When true, persist `respondingAgentId` on the first unbound thread touch. */
|
|
12
|
+
bindIfUnbound?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolveRespondingAgentResult = {
|
|
16
|
+
agentId: string;
|
|
17
|
+
/** True when the thread already had or now has a persisted responding agent. */
|
|
18
|
+
bound: boolean;
|
|
19
|
+
/** True when the request asked for a different agent than the bound one. */
|
|
20
|
+
overridden: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const readBoundAgentId = (state: unknown): string | undefined => {
|
|
24
|
+
if (!state || typeof state !== 'object') return undefined;
|
|
25
|
+
const value = (state as Record<string, unknown>)[THREAD_RESPONDING_AGENT_ID_KEY];
|
|
26
|
+
if (typeof value !== 'string') return undefined;
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
return trimmed || undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves which agent should handle a thread-scoped publish.
|
|
33
|
+
* Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveRespondingAgentId(
|
|
36
|
+
options: ResolveRespondingAgentOptions,
|
|
37
|
+
): Promise<ResolveRespondingAgentResult> {
|
|
38
|
+
const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
|
|
39
|
+
const requested = requestedAgentId?.trim() || undefined;
|
|
40
|
+
const fallback = requested || ORCHESTRATOR_AGENT_ID;
|
|
41
|
+
|
|
42
|
+
if (!threadId) {
|
|
43
|
+
return { agentId: fallback, bound: false, overridden: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const details = await storageService.getThreadDetails({ channelId, threadId });
|
|
47
|
+
const bound = readBoundAgentId(details.state);
|
|
48
|
+
|
|
49
|
+
if (bound) {
|
|
50
|
+
const overridden = !!requested && requested !== bound;
|
|
51
|
+
if (overridden) {
|
|
52
|
+
console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
|
|
53
|
+
threadId,
|
|
54
|
+
bound,
|
|
55
|
+
requested,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return { agentId: bound, bound: true, overridden };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!bindIfUnbound) {
|
|
62
|
+
return { agentId: fallback, bound: false, overridden: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await storageService.getAgentDetails({ agentId: fallback });
|
|
66
|
+
|
|
67
|
+
await storageService.patchThreadState({
|
|
68
|
+
channelId,
|
|
69
|
+
threadId,
|
|
70
|
+
state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { agentId: fallback, bound: true, overridden: false };
|
|
74
|
+
}
|
package/src/app/server.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../plugins/storage/files.js';
|
|
22
22
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
23
23
|
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
24
|
+
import { resolveRespondingAgentId } from './responding-agent.js';
|
|
24
25
|
|
|
25
26
|
type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
|
|
26
27
|
|
|
@@ -150,18 +151,71 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
150
151
|
};
|
|
151
152
|
};
|
|
152
153
|
|
|
154
|
+
const broadcastActiveRunsSnapshot = (): void => {
|
|
155
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
156
|
+
ensureEventId(snapshot);
|
|
157
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const persistLifecycleEvent = (
|
|
161
|
+
event: OpenBotEvent,
|
|
162
|
+
targetChannelId: string,
|
|
163
|
+
targetThreadId?: string,
|
|
164
|
+
): void => {
|
|
165
|
+
ensureEventId(event);
|
|
166
|
+
storageService
|
|
167
|
+
.storeEvent({
|
|
168
|
+
channelId: targetChannelId,
|
|
169
|
+
threadId: targetThreadId,
|
|
170
|
+
event,
|
|
171
|
+
})
|
|
172
|
+
.catch((error) => {
|
|
173
|
+
console.error('[server] Failed to persist lifecycle event', {
|
|
174
|
+
type: event.type,
|
|
175
|
+
channelId: targetChannelId,
|
|
176
|
+
threadId: targetThreadId,
|
|
177
|
+
error,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
153
182
|
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
154
183
|
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
155
184
|
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
156
|
-
// orphaned entries that keep a channel falsely "active".
|
|
157
|
-
//
|
|
185
|
+
// orphaned entries that keep a channel falsely "active". Emit explicit
|
|
186
|
+
// `agent:run:end` for each purged run and refresh the global snapshot so
|
|
187
|
+
// clients stay in sync even when the parent harness stops yielding.
|
|
158
188
|
const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
|
|
159
189
|
const target = threadId || undefined;
|
|
190
|
+
const removed: Array<{
|
|
191
|
+
runId: string;
|
|
192
|
+
channelId: string;
|
|
193
|
+
threadId?: string;
|
|
194
|
+
agentId: string;
|
|
195
|
+
}> = [];
|
|
196
|
+
|
|
160
197
|
for (const [key, run] of activeRuns) {
|
|
161
198
|
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
199
|
+
removed.push(run);
|
|
162
200
|
activeRuns.delete(key);
|
|
163
201
|
}
|
|
164
202
|
}
|
|
203
|
+
|
|
204
|
+
for (const run of removed) {
|
|
205
|
+
const endEvent: OpenBotEvent = {
|
|
206
|
+
type: 'agent:run:end',
|
|
207
|
+
data: {
|
|
208
|
+
runId: run.runId,
|
|
209
|
+
agentId: run.agentId,
|
|
210
|
+
channelId: run.channelId,
|
|
211
|
+
threadId: run.threadId,
|
|
212
|
+
},
|
|
213
|
+
} as OpenBotEvent;
|
|
214
|
+
ensureEventId(endEvent);
|
|
215
|
+
persistLifecycleEvent(endEvent, run.channelId, run.threadId);
|
|
216
|
+
sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
|
|
217
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
|
|
218
|
+
}
|
|
165
219
|
};
|
|
166
220
|
|
|
167
221
|
// Support for Chrome's Private Network Access (PNA)
|
|
@@ -401,20 +455,38 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
401
455
|
};
|
|
402
456
|
const targetChannelId = data.channelId || channelId;
|
|
403
457
|
const targetThreadId = data.threadId || threadId;
|
|
458
|
+
let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
|
|
459
|
+
try {
|
|
460
|
+
const resolved = await resolveRespondingAgentId({
|
|
461
|
+
channelId: targetChannelId,
|
|
462
|
+
threadId: targetThreadId,
|
|
463
|
+
requestedAgentId: data.agentId || agentId,
|
|
464
|
+
});
|
|
465
|
+
resolvedStopAgentId = resolved.agentId;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.warn('[publish] Failed to resolve responding agent for stop request', {
|
|
468
|
+
channelId: targetChannelId,
|
|
469
|
+
threadId: targetThreadId,
|
|
470
|
+
error,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
404
473
|
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
405
474
|
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
475
|
+
// Resync global clients even when nothing was tracked server-side.
|
|
476
|
+
broadcastActiveRunsSnapshot();
|
|
406
477
|
|
|
407
478
|
const stoppedEvent: OpenBotEvent = {
|
|
408
479
|
type: 'agent:run:stopped',
|
|
409
480
|
data: {
|
|
410
481
|
runId: data.runId || runId,
|
|
411
|
-
agentId:
|
|
482
|
+
agentId: resolvedStopAgentId,
|
|
412
483
|
channelId: targetChannelId,
|
|
413
484
|
threadId: targetThreadId,
|
|
414
485
|
reason: data.reason,
|
|
415
486
|
},
|
|
416
487
|
} as OpenBotEvent;
|
|
417
488
|
ensureEventId(stoppedEvent);
|
|
489
|
+
persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
|
|
418
490
|
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
419
491
|
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
420
492
|
|
|
@@ -443,6 +515,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
443
515
|
);
|
|
444
516
|
} else if (chunk.type === 'agent:run:stopped') {
|
|
445
517
|
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
518
|
+
broadcastActiveRunsSnapshot();
|
|
446
519
|
}
|
|
447
520
|
|
|
448
521
|
sendToClientKey(targetClientKey, chunk);
|
|
@@ -459,9 +532,17 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
459
532
|
try {
|
|
460
533
|
ensureEventId(event);
|
|
461
534
|
|
|
535
|
+
const bindIfUnbound = event.type === 'agent:invoke';
|
|
536
|
+
const resolved = await resolveRespondingAgentId({
|
|
537
|
+
channelId,
|
|
538
|
+
threadId,
|
|
539
|
+
requestedAgentId: agentId,
|
|
540
|
+
bindIfUnbound,
|
|
541
|
+
});
|
|
542
|
+
|
|
462
543
|
await runAgent({
|
|
463
544
|
runId,
|
|
464
|
-
agentId: agentId
|
|
545
|
+
agentId: resolved.agentId,
|
|
465
546
|
event,
|
|
466
547
|
channelId,
|
|
467
548
|
threadId,
|
|
@@ -470,6 +551,13 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
470
551
|
});
|
|
471
552
|
res.sendStatus(200);
|
|
472
553
|
} catch (error) {
|
|
554
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
555
|
+
const isUnknownAgent =
|
|
556
|
+
(error instanceof Error &&
|
|
557
|
+
((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
|
|
558
|
+
error.message.includes('does not exist'))) ||
|
|
559
|
+
message.includes('does not exist');
|
|
560
|
+
|
|
473
561
|
console.error('[publish] Failed to dispatch event', {
|
|
474
562
|
runId,
|
|
475
563
|
channelId,
|
|
@@ -477,6 +565,12 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
477
565
|
eventType: event.type,
|
|
478
566
|
error,
|
|
479
567
|
});
|
|
568
|
+
|
|
569
|
+
if (isUnknownAgent) {
|
|
570
|
+
res.status(400).json({ error: message });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
480
574
|
res.status(500).json({ error: 'Failed to process publish event' });
|
|
481
575
|
}
|
|
482
576
|
});
|
|
@@ -493,6 +587,14 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
493
587
|
|
|
494
588
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
495
589
|
|
|
590
|
+
// In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
|
|
591
|
+
if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
|
|
592
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
593
|
+
ensureEventId(snapshot);
|
|
594
|
+
res.json({ events: [snapshot] });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
496
598
|
if (event.type === 'action:storage:serve-file') {
|
|
497
599
|
const filePath = (event.data as { path?: string })?.path;
|
|
498
600
|
if (!channelId?.trim()) {
|
package/src/app/types.ts
CHANGED
|
@@ -141,6 +141,10 @@ export type GetEventsResultEvent = BaseEvent & {
|
|
|
141
141
|
};
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
+
export type GetActiveRunsEvent = BaseEvent & {
|
|
145
|
+
type: 'action:storage:get-active-runs';
|
|
146
|
+
};
|
|
147
|
+
|
|
144
148
|
export type GetAgentDetailsEvent = BaseEvent & {
|
|
145
149
|
type: 'action:storage:get-agent-details';
|
|
146
150
|
data: {
|
|
@@ -1059,6 +1063,7 @@ export type OpenBotEvent =
|
|
|
1059
1063
|
| DeleteAgentResultEvent
|
|
1060
1064
|
| GetEventsEvent
|
|
1061
1065
|
| GetEventsResultEvent
|
|
1066
|
+
| GetActiveRunsEvent
|
|
1062
1067
|
| StreamThreadEvent
|
|
1063
1068
|
| GetVariablesEvent
|
|
1064
1069
|
| GetVariablesResultEvent
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
PluginDescriptor,
|
|
25
25
|
Thread,
|
|
26
26
|
ThreadDetails,
|
|
27
|
+
ThreadState,
|
|
27
28
|
} from '../../services/plugins/domain.js';
|
|
28
29
|
import type { PluginRef } from '../../services/plugins/types.js';
|
|
29
30
|
import { openbotPlugin } from '../openbot/index.js';
|
|
@@ -771,7 +772,7 @@ export const storageService = {
|
|
|
771
772
|
id: threadId,
|
|
772
773
|
name: threadName || threadId,
|
|
773
774
|
channelId,
|
|
774
|
-
state,
|
|
775
|
+
state: (isRecord(state) ? state : {}) as ThreadState,
|
|
775
776
|
};
|
|
776
777
|
},
|
|
777
778
|
getChannelDetails: async ({ channelId }: { channelId: string }): Promise<ChannelDetails> => {
|
|
@@ -81,11 +81,25 @@ export type Thread = {
|
|
|
81
81
|
hasUnseenMessages?: boolean;
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
/** Persisted thread `state.json` fields (additional keys are allowed). */
|
|
85
|
+
export type ThreadState = {
|
|
86
|
+
name?: string;
|
|
87
|
+
/** Sticky agent id for this thread (`system` = orchestrator). Set once, then enforced on publish. */
|
|
88
|
+
respondingAgentId?: string;
|
|
89
|
+
pendingToolCallIds?: string[];
|
|
90
|
+
usage?: {
|
|
91
|
+
promptTokens?: number;
|
|
92
|
+
completionTokens?: number;
|
|
93
|
+
totalTokens?: number;
|
|
94
|
+
};
|
|
95
|
+
[key: string]: unknown;
|
|
96
|
+
};
|
|
97
|
+
|
|
84
98
|
export type ThreadDetails = {
|
|
85
99
|
id: string;
|
|
86
100
|
name: string;
|
|
87
101
|
channelId: string;
|
|
88
|
-
state:
|
|
102
|
+
state: ThreadState;
|
|
89
103
|
};
|
|
90
104
|
|
|
91
105
|
export type ChannelDetails = {
|