openbot 0.4.3 → 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 +100 -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 +119 -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,19 +117,71 @@ 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
|
};
|
|
173
|
+
// Support for Chrome's Private Network Access (PNA)
|
|
174
|
+
// https://developer.chrome.com/blog/private-network-access-preflight/
|
|
175
|
+
app.use((req, res, next) => {
|
|
176
|
+
if (req.headers['access-control-request-private-network'] === 'true') {
|
|
177
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
178
|
+
}
|
|
179
|
+
// If it's a preflight request, we should also ensure the Vary header is set
|
|
180
|
+
if (req.method === 'OPTIONS') {
|
|
181
|
+
res.setHeader('Vary', 'Access-Control-Request-Private-Network');
|
|
182
|
+
}
|
|
183
|
+
next();
|
|
184
|
+
});
|
|
132
185
|
app.use(cors());
|
|
133
186
|
const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
|
|
134
187
|
app.use((req, res, next) => {
|
|
@@ -316,19 +369,38 @@ export async function startServer(options = {}) {
|
|
|
316
369
|
const data = (event.data ?? {});
|
|
317
370
|
const targetChannelId = data.channelId || channelId;
|
|
318
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
|
+
}
|
|
319
388
|
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
320
389
|
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
390
|
+
// Resync global clients even when nothing was tracked server-side.
|
|
391
|
+
broadcastActiveRunsSnapshot();
|
|
321
392
|
const stoppedEvent = {
|
|
322
393
|
type: 'agent:run:stopped',
|
|
323
394
|
data: {
|
|
324
395
|
runId: data.runId || runId,
|
|
325
|
-
agentId:
|
|
396
|
+
agentId: resolvedStopAgentId,
|
|
326
397
|
channelId: targetChannelId,
|
|
327
398
|
threadId: targetThreadId,
|
|
328
399
|
reason: data.reason,
|
|
329
400
|
},
|
|
330
401
|
};
|
|
331
402
|
ensureEventId(stoppedEvent);
|
|
403
|
+
persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
|
|
332
404
|
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
333
405
|
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
334
406
|
res.json({ success: stopped });
|
|
@@ -351,6 +423,7 @@ export async function startServer(options = {}) {
|
|
|
351
423
|
}
|
|
352
424
|
else if (chunk.type === 'agent:run:stopped') {
|
|
353
425
|
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
426
|
+
broadcastActiveRunsSnapshot();
|
|
354
427
|
}
|
|
355
428
|
sendToClientKey(targetClientKey, chunk);
|
|
356
429
|
if (chunk.type === 'agent:run:start' ||
|
|
@@ -361,9 +434,16 @@ export async function startServer(options = {}) {
|
|
|
361
434
|
};
|
|
362
435
|
try {
|
|
363
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
|
+
});
|
|
364
444
|
await runAgent({
|
|
365
445
|
runId,
|
|
366
|
-
agentId: agentId
|
|
446
|
+
agentId: resolved.agentId,
|
|
367
447
|
event,
|
|
368
448
|
channelId,
|
|
369
449
|
threadId,
|
|
@@ -373,6 +453,11 @@ export async function startServer(options = {}) {
|
|
|
373
453
|
res.sendStatus(200);
|
|
374
454
|
}
|
|
375
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');
|
|
376
461
|
console.error('[publish] Failed to dispatch event', {
|
|
377
462
|
runId,
|
|
378
463
|
channelId,
|
|
@@ -380,6 +465,10 @@ export async function startServer(options = {}) {
|
|
|
380
465
|
eventType: event.type,
|
|
381
466
|
error,
|
|
382
467
|
});
|
|
468
|
+
if (isUnknownAgent) {
|
|
469
|
+
res.status(400).json({ error: message });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
383
472
|
res.status(500).json({ error: 'Failed to process publish event' });
|
|
384
473
|
}
|
|
385
474
|
});
|
|
@@ -394,6 +483,13 @@ export async function startServer(options = {}) {
|
|
|
394
483
|
return;
|
|
395
484
|
}
|
|
396
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
|
+
}
|
|
397
493
|
if (event.type === 'action:storage:serve-file') {
|
|
398
494
|
const filePath = event.data?.path;
|
|
399
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,20 +151,86 @@ 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
|
|
|
221
|
+
// Support for Chrome's Private Network Access (PNA)
|
|
222
|
+
// https://developer.chrome.com/blog/private-network-access-preflight/
|
|
223
|
+
app.use((req, res, next) => {
|
|
224
|
+
if (req.headers['access-control-request-private-network'] === 'true') {
|
|
225
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
226
|
+
}
|
|
227
|
+
// If it's a preflight request, we should also ensure the Vary header is set
|
|
228
|
+
if (req.method === 'OPTIONS') {
|
|
229
|
+
res.setHeader('Vary', 'Access-Control-Request-Private-Network');
|
|
230
|
+
}
|
|
231
|
+
next();
|
|
232
|
+
});
|
|
233
|
+
|
|
167
234
|
app.use(cors());
|
|
168
235
|
|
|
169
236
|
const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
|
|
@@ -388,20 +455,38 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
388
455
|
};
|
|
389
456
|
const targetChannelId = data.channelId || channelId;
|
|
390
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
|
+
}
|
|
391
473
|
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
392
474
|
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
475
|
+
// Resync global clients even when nothing was tracked server-side.
|
|
476
|
+
broadcastActiveRunsSnapshot();
|
|
393
477
|
|
|
394
478
|
const stoppedEvent: OpenBotEvent = {
|
|
395
479
|
type: 'agent:run:stopped',
|
|
396
480
|
data: {
|
|
397
481
|
runId: data.runId || runId,
|
|
398
|
-
agentId:
|
|
482
|
+
agentId: resolvedStopAgentId,
|
|
399
483
|
channelId: targetChannelId,
|
|
400
484
|
threadId: targetThreadId,
|
|
401
485
|
reason: data.reason,
|
|
402
486
|
},
|
|
403
487
|
} as OpenBotEvent;
|
|
404
488
|
ensureEventId(stoppedEvent);
|
|
489
|
+
persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
|
|
405
490
|
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
406
491
|
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
407
492
|
|
|
@@ -430,6 +515,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
430
515
|
);
|
|
431
516
|
} else if (chunk.type === 'agent:run:stopped') {
|
|
432
517
|
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
518
|
+
broadcastActiveRunsSnapshot();
|
|
433
519
|
}
|
|
434
520
|
|
|
435
521
|
sendToClientKey(targetClientKey, chunk);
|
|
@@ -446,9 +532,17 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
446
532
|
try {
|
|
447
533
|
ensureEventId(event);
|
|
448
534
|
|
|
535
|
+
const bindIfUnbound = event.type === 'agent:invoke';
|
|
536
|
+
const resolved = await resolveRespondingAgentId({
|
|
537
|
+
channelId,
|
|
538
|
+
threadId,
|
|
539
|
+
requestedAgentId: agentId,
|
|
540
|
+
bindIfUnbound,
|
|
541
|
+
});
|
|
542
|
+
|
|
449
543
|
await runAgent({
|
|
450
544
|
runId,
|
|
451
|
-
agentId: agentId
|
|
545
|
+
agentId: resolved.agentId,
|
|
452
546
|
event,
|
|
453
547
|
channelId,
|
|
454
548
|
threadId,
|
|
@@ -457,6 +551,13 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
457
551
|
});
|
|
458
552
|
res.sendStatus(200);
|
|
459
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
|
+
|
|
460
561
|
console.error('[publish] Failed to dispatch event', {
|
|
461
562
|
runId,
|
|
462
563
|
channelId,
|
|
@@ -464,6 +565,12 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
464
565
|
eventType: event.type,
|
|
465
566
|
error,
|
|
466
567
|
});
|
|
568
|
+
|
|
569
|
+
if (isUnknownAgent) {
|
|
570
|
+
res.status(400).json({ error: message });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
467
574
|
res.status(500).json({ error: 'Failed to process publish event' });
|
|
468
575
|
}
|
|
469
576
|
});
|
|
@@ -480,6 +587,14 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
480
587
|
|
|
481
588
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
482
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
|
+
|
|
483
598
|
if (event.type === 'action:storage:serve-file') {
|
|
484
599
|
const filePath = (event.data as { path?: string })?.path;
|
|
485
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 = {
|