openbot 0.4.4 → 0.4.6
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/channel-ids.js +3 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/responding-agent.js +48 -0
- package/dist/app/server.js +112 -6
- package/dist/plugins/openbot/context.js +6 -25
- package/dist/plugins/openbot/runtime.js +116 -81
- package/dist/plugins/openbot/system-prompt.js +3 -6
- package/dist/plugins/plugin-manager/index.js +3 -45
- package/dist/plugins/storage/index.js +2 -41
- package/dist/plugins/storage/service.js +55 -15
- package/dist/services/plugins/service.js +0 -4
- package/package.json +2 -1
- package/src/app/channel-ids.ts +5 -0
- package/src/app/cli.ts +1 -1
- package/src/app/responding-agent.ts +74 -0
- package/src/app/server.ts +137 -7
- package/src/app/types.ts +7 -10
- package/src/plugins/openbot/context.ts +7 -26
- package/src/plugins/openbot/runtime.ts +137 -89
- package/src/plugins/openbot/system-prompt.ts +3 -8
- package/src/plugins/plugin-manager/index.ts +2 -50
- package/src/plugins/storage/index.ts +4 -46
- package/src/plugins/storage/service.ts +80 -15
- package/src/services/plugins/domain.ts +22 -5
- package/src/services/plugins/service.ts +0 -6
- package/dist/plugins/thread-naming/generate-title.js +0 -44
- package/dist/plugins/thread-naming/index.js +0 -103
- package/dist/services/thread-naming.js +0 -81
|
@@ -54,64 +54,22 @@ export const pluginManagerPlugin = {
|
|
|
54
54
|
});
|
|
55
55
|
builder.on('action:channel:install', async function* (event) {
|
|
56
56
|
try {
|
|
57
|
-
const { channelId: instanceId, name: templateName,
|
|
58
|
-
const {
|
|
57
|
+
const { channelId: instanceId, name: templateName, initialState: customInitialState, } = event.data;
|
|
58
|
+
const { channels } = await resolveMarketplaceRegistry();
|
|
59
59
|
// Try to find the template by ID or Name
|
|
60
60
|
const channelListing = channels.find((c) => c.id === instanceId) ||
|
|
61
61
|
channels.find((c) => c.name === templateName);
|
|
62
62
|
const channelId = instanceId;
|
|
63
|
-
const participants = customParticipants || channelListing?.participants || [];
|
|
64
63
|
const initialState = {
|
|
65
64
|
...(channelListing?.initialState || {}),
|
|
66
65
|
...(customInitialState || {}),
|
|
67
66
|
};
|
|
68
67
|
const spec = channelListing?.spec || '';
|
|
69
|
-
// 1. Auto-install participant agents if missing
|
|
70
|
-
for (const agentId of participants) {
|
|
71
|
-
const existingAgents = await storage.getAgents();
|
|
72
|
-
if (existingAgents.some((a) => a.id === agentId)) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
// Not found locally, look in marketplace
|
|
76
|
-
const agentListing = marketplaceAgents.find((a) => a.id === agentId);
|
|
77
|
-
if (agentListing) {
|
|
78
|
-
console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
|
|
79
|
-
// Install plugins for this agent
|
|
80
|
-
for (const ref of agentListing.plugins) {
|
|
81
|
-
const installed = await pluginService.isInstalled(ref.id);
|
|
82
|
-
if (!installed &&
|
|
83
|
-
ref.id.includes('/') === false &&
|
|
84
|
-
ref.id.includes('-plugin-') === false) {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (!installed) {
|
|
88
|
-
try {
|
|
89
|
-
await pluginService.install({ packageName: ref.id });
|
|
90
|
-
}
|
|
91
|
-
catch (err) {
|
|
92
|
-
console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Create the agent
|
|
97
|
-
await storage.createAgent({
|
|
98
|
-
agentId: agentListing.id,
|
|
99
|
-
name: agentListing.name,
|
|
100
|
-
description: agentListing.description,
|
|
101
|
-
image: agentListing.image,
|
|
102
|
-
instructions: agentListing.instructions,
|
|
103
|
-
plugins: agentListing.plugins,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
68
|
// 2. Create the channel
|
|
108
69
|
await storage.createChannel({
|
|
109
70
|
channelId,
|
|
110
71
|
spec,
|
|
111
|
-
initialState
|
|
112
|
-
...initialState,
|
|
113
|
-
participants,
|
|
114
|
-
},
|
|
72
|
+
initialState,
|
|
115
73
|
});
|
|
116
74
|
const channelUrl = `/channels/${channelId}`;
|
|
117
75
|
yield {
|
|
@@ -38,15 +38,10 @@ const storageToolDefinitions = {
|
|
|
38
38
|
.optional()
|
|
39
39
|
.describe('Markdown content for the channel specification (SPEC.md). Use for goals and rules.'),
|
|
40
40
|
cwd: z.string().optional().describe('Current working directory for the channel.'),
|
|
41
|
-
participants: z
|
|
42
|
-
.array(z.string())
|
|
43
|
-
.optional()
|
|
44
|
-
.describe('List of agent IDs that are participants in this channel. When a user tags an agent (e.g. @agent-id), you should ensure they are added to this list if they are not already there.'),
|
|
45
41
|
})
|
|
46
42
|
.refine((value) => value.state !== undefined ||
|
|
47
43
|
value.spec !== undefined ||
|
|
48
|
-
value.cwd !== undefined
|
|
49
|
-
value.participants !== undefined, { message: 'Provide at least one of state, spec, cwd, or participants.' }),
|
|
44
|
+
value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
|
|
50
45
|
},
|
|
51
46
|
patch_thread_details: {
|
|
52
47
|
description: 'Patch current thread details (state).',
|
|
@@ -125,7 +120,7 @@ export const storagePlugin = {
|
|
|
125
120
|
};
|
|
126
121
|
});
|
|
127
122
|
builder.on('action:create_channel', async function* (event, context) {
|
|
128
|
-
const { channelId, spec, initialState, cwd
|
|
123
|
+
const { channelId, spec, initialState, cwd } = event.data;
|
|
129
124
|
const rawChannelId = (channelId || '').trim();
|
|
130
125
|
const channelSpec = typeof spec === 'string' ? spec : '';
|
|
131
126
|
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
@@ -139,15 +134,6 @@ export const storagePlugin = {
|
|
|
139
134
|
}
|
|
140
135
|
const channelUrl = `/channels/${rawChannelId}`;
|
|
141
136
|
const mergedInitial = { ...(initialState || {}) };
|
|
142
|
-
if (participants !== undefined) {
|
|
143
|
-
const normalized = Array.isArray(participants)
|
|
144
|
-
? participants
|
|
145
|
-
.filter((x) => typeof x === 'string')
|
|
146
|
-
.map((s) => s.trim())
|
|
147
|
-
.filter(Boolean)
|
|
148
|
-
: [];
|
|
149
|
-
mergedInitial.participants = normalized;
|
|
150
|
-
}
|
|
151
137
|
try {
|
|
152
138
|
await storage.createChannel({
|
|
153
139
|
channelId: rawChannelId,
|
|
@@ -232,18 +218,6 @@ export const storagePlugin = {
|
|
|
232
218
|
patch.cwd = data.cwd.trim();
|
|
233
219
|
updatedFields.push('cwd');
|
|
234
220
|
}
|
|
235
|
-
if (data.participants !== undefined) {
|
|
236
|
-
if (Array.isArray(data.participants)) {
|
|
237
|
-
patch.participants = data.participants
|
|
238
|
-
.filter((x) => typeof x === 'string')
|
|
239
|
-
.map((s) => s.trim())
|
|
240
|
-
.filter(Boolean);
|
|
241
|
-
}
|
|
242
|
-
else {
|
|
243
|
-
patch.participants = [];
|
|
244
|
-
}
|
|
245
|
-
updatedFields.push('participants');
|
|
246
|
-
}
|
|
247
221
|
try {
|
|
248
222
|
if (updatedFields.length > 0) {
|
|
249
223
|
await storage.patchChannelState({ channelId: targetChannelId, state: patch });
|
|
@@ -293,19 +267,6 @@ export const storagePlugin = {
|
|
|
293
267
|
});
|
|
294
268
|
updatedFields.push('cwd');
|
|
295
269
|
}
|
|
296
|
-
if (data.participants !== undefined) {
|
|
297
|
-
const normalized = Array.isArray(data.participants)
|
|
298
|
-
? data.participants
|
|
299
|
-
.filter((x) => typeof x === 'string')
|
|
300
|
-
.map((s) => s.trim())
|
|
301
|
-
.filter(Boolean)
|
|
302
|
-
: [];
|
|
303
|
-
await storage.patchChannelState({
|
|
304
|
-
channelId: context.state.channelId,
|
|
305
|
-
state: { participants: normalized },
|
|
306
|
-
});
|
|
307
|
-
updatedFields.push('participants');
|
|
308
|
-
}
|
|
309
270
|
context.state.channelDetails = await storage.getChannelDetails({
|
|
310
271
|
channelId: context.state.channelId,
|
|
311
272
|
});
|
|
@@ -340,18 +340,16 @@ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArr
|
|
|
340
340
|
/** Display-oriented fields persisted in a channel's `state.json`. */
|
|
341
341
|
const readChannelStateFileFields = (parsed) => {
|
|
342
342
|
if (!isRecord(parsed)) {
|
|
343
|
-
return {
|
|
343
|
+
return {};
|
|
344
344
|
}
|
|
345
345
|
const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : undefined;
|
|
346
346
|
const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : undefined;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
return { name, cwd, participants };
|
|
347
|
+
return { name, cwd };
|
|
348
|
+
};
|
|
349
|
+
const isChannelProvisioned = async (channelId) => {
|
|
350
|
+
const statePath = `${getConversationDir(channelId)}/state.json`;
|
|
351
|
+
const state = await readJsonFile(statePath, {});
|
|
352
|
+
return !!readChannelStateFileFields(state).cwd;
|
|
355
353
|
};
|
|
356
354
|
/**
|
|
357
355
|
* Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
|
|
@@ -412,23 +410,23 @@ export const storageService = {
|
|
|
412
410
|
const statePath = path.join(channelDir, 'state.json');
|
|
413
411
|
let cwd;
|
|
414
412
|
let displayName = name;
|
|
415
|
-
let participants = [];
|
|
416
413
|
try {
|
|
417
414
|
const parsed = await readJsonFile(statePath, {});
|
|
418
415
|
const fields = readChannelStateFileFields(parsed);
|
|
419
416
|
cwd = fields.cwd;
|
|
420
417
|
displayName = fields.name ?? name;
|
|
421
|
-
participants = fields.participants;
|
|
422
418
|
}
|
|
423
419
|
catch {
|
|
424
420
|
// ignore
|
|
425
421
|
}
|
|
422
|
+
if (!cwd) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
426
425
|
const channel = {
|
|
427
426
|
id: name,
|
|
428
427
|
name: displayName,
|
|
429
428
|
description: '',
|
|
430
429
|
cwd,
|
|
431
|
-
participants,
|
|
432
430
|
createdAt: stats.birthtime,
|
|
433
431
|
updatedAt: stats.mtime,
|
|
434
432
|
};
|
|
@@ -452,7 +450,9 @@ export const storageService = {
|
|
|
452
450
|
}
|
|
453
451
|
return channel;
|
|
454
452
|
}));
|
|
455
|
-
return channels
|
|
453
|
+
return channels
|
|
454
|
+
.filter((channel) => channel !== null)
|
|
455
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
456
456
|
},
|
|
457
457
|
createChannel: async ({ channelId, spec, initialState, cwd, }) => {
|
|
458
458
|
const normalizedChannelId = channelId.trim();
|
|
@@ -486,6 +486,44 @@ export const storageService = {
|
|
|
486
486
|
`# ${normalizedChannelId}\n\n`);
|
|
487
487
|
await writeJsonFileAtomically(statePath, finalState);
|
|
488
488
|
},
|
|
489
|
+
ensureChannel: async ({ channelId, spec, initialState, cwd, }) => {
|
|
490
|
+
const normalizedChannelId = channelId.trim();
|
|
491
|
+
if (!normalizedChannelId) {
|
|
492
|
+
throw new Error('channelId is required');
|
|
493
|
+
}
|
|
494
|
+
const channelDir = getConversationDir(normalizedChannelId);
|
|
495
|
+
const specPath = `${channelDir}/SPEC.md`;
|
|
496
|
+
const statePath = `${channelDir}/state.json`;
|
|
497
|
+
const existingState = await readJsonFile(statePath, {});
|
|
498
|
+
const existingFields = readChannelStateFileFields(existingState);
|
|
499
|
+
if (existingFields.cwd) {
|
|
500
|
+
await fs.mkdir(resolvePath(existingFields.cwd), { recursive: true });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const finalState = {
|
|
504
|
+
...existingState,
|
|
505
|
+
...(initialState || {}),
|
|
506
|
+
};
|
|
507
|
+
const rawCwd = (typeof cwd === 'string' && cwd.trim()) ||
|
|
508
|
+
(typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
|
|
509
|
+
getDefaultChannelCwd(normalizedChannelId);
|
|
510
|
+
const resolvedCwd = resolvePath(rawCwd);
|
|
511
|
+
finalState.cwd = resolvedCwd;
|
|
512
|
+
await fs.mkdir(resolvedCwd, { recursive: true });
|
|
513
|
+
await fs.mkdir(channelDir, { recursive: true });
|
|
514
|
+
try {
|
|
515
|
+
await fs.access(specPath);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (error?.code === 'ENOENT') {
|
|
519
|
+
await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
await writeJsonFileAtomically(statePath, finalState);
|
|
526
|
+
},
|
|
489
527
|
deleteChannel: async ({ channelId }) => {
|
|
490
528
|
const normalizedChannelId = channelId.trim();
|
|
491
529
|
if (!normalizedChannelId) {
|
|
@@ -614,7 +652,7 @@ export const storageService = {
|
|
|
614
652
|
id: threadId,
|
|
615
653
|
name: threadName || threadId,
|
|
616
654
|
channelId,
|
|
617
|
-
state,
|
|
655
|
+
state: (isRecord(state) ? state : {}),
|
|
618
656
|
};
|
|
619
657
|
},
|
|
620
658
|
getChannelDetails: async ({ channelId }) => {
|
|
@@ -648,7 +686,6 @@ export const storageService = {
|
|
|
648
686
|
spec,
|
|
649
687
|
state,
|
|
650
688
|
cwd,
|
|
651
|
-
participants: diskFields.participants,
|
|
652
689
|
};
|
|
653
690
|
details.threads = await storageService.getThreads({ channelId });
|
|
654
691
|
return details;
|
|
@@ -1030,6 +1067,9 @@ export const storageService = {
|
|
|
1030
1067
|
},
|
|
1031
1068
|
storeEvent: async ({ channelId, threadId, event, }) => {
|
|
1032
1069
|
try {
|
|
1070
|
+
if (!(await isChannelProvisioned(channelId))) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1033
1073
|
const threadDir = getConversationDir(channelId, threadId);
|
|
1034
1074
|
if (threadId) {
|
|
1035
1075
|
let exists = false;
|
|
@@ -74,20 +74,16 @@ export function parseMarketplaceRegistryJson(data) {
|
|
|
74
74
|
const id = item.id;
|
|
75
75
|
const name = item.name;
|
|
76
76
|
const description = item.description;
|
|
77
|
-
const participants = item.participants;
|
|
78
77
|
if (typeof id !== 'string' || !id)
|
|
79
78
|
throw new Error(`channels[${i}].id must be a non-empty string`);
|
|
80
79
|
if (typeof name !== 'string')
|
|
81
80
|
throw new Error(`channels[${i}].name must be a string`);
|
|
82
81
|
if (typeof description !== 'string')
|
|
83
82
|
throw new Error(`channels[${i}].description must be a string`);
|
|
84
|
-
if (!Array.isArray(participants))
|
|
85
|
-
throw new Error(`channels[${i}].participants must be an array`);
|
|
86
83
|
const listing = {
|
|
87
84
|
id,
|
|
88
85
|
name,
|
|
89
86
|
description,
|
|
90
|
-
participants: participants.filter((p) => typeof p === 'string'),
|
|
91
87
|
};
|
|
92
88
|
if (typeof item.image === 'string')
|
|
93
89
|
listing.image = item.image;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openbot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@ai-sdk/anthropic": "^3.0.33",
|
|
19
|
+
"@ai-sdk/google": "^3.0.82",
|
|
19
20
|
"@ai-sdk/openai": "^3.0.13",
|
|
20
21
|
"@anthropic-ai/claude-agent-sdk": "^0.2.138",
|
|
21
22
|
"@types/cors": "^2.8.19",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Default channel when requests omit channelId (general-purpose conversation). */
|
|
2
|
+
export const UNCATEGORIZED_CHANNEL_ID = 'uncategorized';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_UNCATEGORIZED_SPEC =
|
|
5
|
+
'# Uncategorized\n\nGeneral-purpose channel for conversations without a dedicated channel.';
|
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,11 @@ 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';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_UNCATEGORIZED_SPEC,
|
|
27
|
+
UNCATEGORIZED_CHANNEL_ID,
|
|
28
|
+
} from './channel-ids.js';
|
|
24
29
|
|
|
25
30
|
type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
|
|
26
31
|
|
|
@@ -64,9 +69,18 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
64
69
|
storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
|
|
65
70
|
storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
|
|
66
71
|
|
|
72
|
+
const getRawChannelId = (req: express.Request): string | undefined => {
|
|
73
|
+
const raw =
|
|
74
|
+
req.get('x-openbot-channel-id') ||
|
|
75
|
+
req.query.channelId ||
|
|
76
|
+
(req.body && req.body.channelId);
|
|
77
|
+
if (typeof raw !== 'string') return undefined;
|
|
78
|
+
const trimmed = raw.trim();
|
|
79
|
+
return trimmed || undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
67
82
|
const getContext = (req: express.Request) => {
|
|
68
|
-
const
|
|
69
|
-
req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
|
|
83
|
+
const rawChannelId = getRawChannelId(req);
|
|
70
84
|
const threadId =
|
|
71
85
|
req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
|
|
72
86
|
const agentId =
|
|
@@ -82,7 +96,8 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
82
96
|
(req.body && req.body.responseType);
|
|
83
97
|
|
|
84
98
|
return {
|
|
85
|
-
channelId: (
|
|
99
|
+
channelId: (rawChannelId || UNCATEGORIZED_CHANNEL_ID) as string,
|
|
100
|
+
rawChannelId,
|
|
86
101
|
threadId: threadId as string | undefined,
|
|
87
102
|
agentId: agentId as string | undefined,
|
|
88
103
|
runId: runId as string,
|
|
@@ -150,18 +165,71 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
150
165
|
};
|
|
151
166
|
};
|
|
152
167
|
|
|
168
|
+
const broadcastActiveRunsSnapshot = (): void => {
|
|
169
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
170
|
+
ensureEventId(snapshot);
|
|
171
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const persistLifecycleEvent = (
|
|
175
|
+
event: OpenBotEvent,
|
|
176
|
+
targetChannelId: string,
|
|
177
|
+
targetThreadId?: string,
|
|
178
|
+
): void => {
|
|
179
|
+
ensureEventId(event);
|
|
180
|
+
storageService
|
|
181
|
+
.storeEvent({
|
|
182
|
+
channelId: targetChannelId,
|
|
183
|
+
threadId: targetThreadId,
|
|
184
|
+
event,
|
|
185
|
+
})
|
|
186
|
+
.catch((error) => {
|
|
187
|
+
console.error('[server] Failed to persist lifecycle event', {
|
|
188
|
+
type: event.type,
|
|
189
|
+
channelId: targetChannelId,
|
|
190
|
+
threadId: targetThreadId,
|
|
191
|
+
error,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
153
196
|
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
154
197
|
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
155
198
|
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
156
|
-
// orphaned entries that keep a channel falsely "active".
|
|
157
|
-
//
|
|
199
|
+
// orphaned entries that keep a channel falsely "active". Emit explicit
|
|
200
|
+
// `agent:run:end` for each purged run and refresh the global snapshot so
|
|
201
|
+
// clients stay in sync even when the parent harness stops yielding.
|
|
158
202
|
const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
|
|
159
203
|
const target = threadId || undefined;
|
|
204
|
+
const removed: Array<{
|
|
205
|
+
runId: string;
|
|
206
|
+
channelId: string;
|
|
207
|
+
threadId?: string;
|
|
208
|
+
agentId: string;
|
|
209
|
+
}> = [];
|
|
210
|
+
|
|
160
211
|
for (const [key, run] of activeRuns) {
|
|
161
212
|
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
213
|
+
removed.push(run);
|
|
162
214
|
activeRuns.delete(key);
|
|
163
215
|
}
|
|
164
216
|
}
|
|
217
|
+
|
|
218
|
+
for (const run of removed) {
|
|
219
|
+
const endEvent: OpenBotEvent = {
|
|
220
|
+
type: 'agent:run:end',
|
|
221
|
+
data: {
|
|
222
|
+
runId: run.runId,
|
|
223
|
+
agentId: run.agentId,
|
|
224
|
+
channelId: run.channelId,
|
|
225
|
+
threadId: run.threadId,
|
|
226
|
+
},
|
|
227
|
+
} as OpenBotEvent;
|
|
228
|
+
ensureEventId(endEvent);
|
|
229
|
+
persistLifecycleEvent(endEvent, run.channelId, run.threadId);
|
|
230
|
+
sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
|
|
231
|
+
sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
|
|
232
|
+
}
|
|
165
233
|
};
|
|
166
234
|
|
|
167
235
|
// Support for Chrome's Private Network Access (PNA)
|
|
@@ -401,20 +469,38 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
401
469
|
};
|
|
402
470
|
const targetChannelId = data.channelId || channelId;
|
|
403
471
|
const targetThreadId = data.threadId || threadId;
|
|
472
|
+
let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
|
|
473
|
+
try {
|
|
474
|
+
const resolved = await resolveRespondingAgentId({
|
|
475
|
+
channelId: targetChannelId,
|
|
476
|
+
threadId: targetThreadId,
|
|
477
|
+
requestedAgentId: data.agentId || agentId,
|
|
478
|
+
});
|
|
479
|
+
resolvedStopAgentId = resolved.agentId;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.warn('[publish] Failed to resolve responding agent for stop request', {
|
|
482
|
+
channelId: targetChannelId,
|
|
483
|
+
threadId: targetThreadId,
|
|
484
|
+
error,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
404
487
|
const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
|
|
405
488
|
purgeActiveRunsForThread(targetChannelId, targetThreadId);
|
|
489
|
+
// Resync global clients even when nothing was tracked server-side.
|
|
490
|
+
broadcastActiveRunsSnapshot();
|
|
406
491
|
|
|
407
492
|
const stoppedEvent: OpenBotEvent = {
|
|
408
493
|
type: 'agent:run:stopped',
|
|
409
494
|
data: {
|
|
410
495
|
runId: data.runId || runId,
|
|
411
|
-
agentId:
|
|
496
|
+
agentId: resolvedStopAgentId,
|
|
412
497
|
channelId: targetChannelId,
|
|
413
498
|
threadId: targetThreadId,
|
|
414
499
|
reason: data.reason,
|
|
415
500
|
},
|
|
416
501
|
} as OpenBotEvent;
|
|
417
502
|
ensureEventId(stoppedEvent);
|
|
503
|
+
persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
|
|
418
504
|
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
419
505
|
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
420
506
|
|
|
@@ -443,6 +529,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
443
529
|
);
|
|
444
530
|
} else if (chunk.type === 'agent:run:stopped') {
|
|
445
531
|
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
532
|
+
broadcastActiveRunsSnapshot();
|
|
446
533
|
}
|
|
447
534
|
|
|
448
535
|
sendToClientKey(targetClientKey, chunk);
|
|
@@ -459,9 +546,31 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
459
546
|
try {
|
|
460
547
|
ensureEventId(event);
|
|
461
548
|
|
|
549
|
+
const isUserConversationStart =
|
|
550
|
+
event.type === 'agent:invoke' &&
|
|
551
|
+
event.data?.role === 'user' &&
|
|
552
|
+
typeof event.data.content === 'string' &&
|
|
553
|
+
event.data.content.trim().length > 0;
|
|
554
|
+
|
|
555
|
+
if (isUserConversationStart && channelId === UNCATEGORIZED_CHANNEL_ID) {
|
|
556
|
+
await storageService.ensureChannel({
|
|
557
|
+
channelId: UNCATEGORIZED_CHANNEL_ID,
|
|
558
|
+
spec: DEFAULT_UNCATEGORIZED_SPEC,
|
|
559
|
+
initialState: { name: 'Uncategorized' },
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const bindIfUnbound = event.type === 'agent:invoke';
|
|
564
|
+
const resolved = await resolveRespondingAgentId({
|
|
565
|
+
channelId,
|
|
566
|
+
threadId,
|
|
567
|
+
requestedAgentId: agentId,
|
|
568
|
+
bindIfUnbound,
|
|
569
|
+
});
|
|
570
|
+
|
|
462
571
|
await runAgent({
|
|
463
572
|
runId,
|
|
464
|
-
agentId: agentId
|
|
573
|
+
agentId: resolved.agentId,
|
|
465
574
|
event,
|
|
466
575
|
channelId,
|
|
467
576
|
threadId,
|
|
@@ -470,6 +579,13 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
470
579
|
});
|
|
471
580
|
res.sendStatus(200);
|
|
472
581
|
} catch (error) {
|
|
582
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
583
|
+
const isUnknownAgent =
|
|
584
|
+
(error instanceof Error &&
|
|
585
|
+
((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
|
|
586
|
+
error.message.includes('does not exist'))) ||
|
|
587
|
+
message.includes('does not exist');
|
|
588
|
+
|
|
473
589
|
console.error('[publish] Failed to dispatch event', {
|
|
474
590
|
runId,
|
|
475
591
|
channelId,
|
|
@@ -477,6 +593,12 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
477
593
|
eventType: event.type,
|
|
478
594
|
error,
|
|
479
595
|
});
|
|
596
|
+
|
|
597
|
+
if (isUnknownAgent) {
|
|
598
|
+
res.status(400).json({ error: message });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
480
602
|
res.status(500).json({ error: 'Failed to process publish event' });
|
|
481
603
|
}
|
|
482
604
|
});
|
|
@@ -493,6 +615,14 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
493
615
|
|
|
494
616
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
495
617
|
|
|
618
|
+
// In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
|
|
619
|
+
if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
|
|
620
|
+
const snapshot = buildActiveRunsSnapshot();
|
|
621
|
+
ensureEventId(snapshot);
|
|
622
|
+
res.json({ events: [snapshot] });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
496
626
|
if (event.type === 'action:storage:serve-file') {
|
|
497
627
|
const filePath = (event.data as { path?: string })?.path;
|
|
498
628
|
if (!channelId?.trim()) {
|