openbot 0.3.2 → 0.3.4
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/README.md +1 -1
- package/dist/app/cli.js +1 -1
- package/dist/app/server.js +39 -21
- package/dist/harness/dispatcher.js +267 -0
- package/dist/harness/orchestrator.js +76 -0
- package/dist/harness/queue-processor.js +26 -0
- package/docs/architecture.md +5 -8
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/server.ts +49 -27
- package/src/app/types.ts +34 -0
- package/src/harness/dispatcher.ts +379 -0
- package/src/services/storage.ts +1 -1
- package/src/harness/agent-harness.ts +0 -58
- package/src/harness/event-normalizer.ts +0 -82
- package/src/harness/orchestrator.ts +0 -104
- package/src/harness/queue-processor.ts +0 -220
- package/src/harness/types.ts +0 -34
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ Shared plugins can be placed in `~/.openbot/plugins` and referenced by agents.
|
|
|
120
120
|
## Project Layout
|
|
121
121
|
|
|
122
122
|
- `src/app`: CLI, server, event types, and app config.
|
|
123
|
-
- `src/harness`:
|
|
123
|
+
- `src/harness`: orchestration, process, and MCP runtime helpers.
|
|
124
124
|
- `src/plugins`: built-in plugin implementations.
|
|
125
125
|
- `src/services`: local storage service.
|
|
126
126
|
- `src/registry`: plugin registry.
|
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.3.
|
|
19
|
+
program.name('openbot').description('OpenBot CLI').version('0.3.4');
|
|
20
20
|
program
|
|
21
21
|
.command('start')
|
|
22
22
|
.description('Start the OpenBot harness')
|
package/dist/app/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { generateId } from 'melony';
|
|
|
11
11
|
import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
12
12
|
import { processService } from '../harness/process.js';
|
|
13
13
|
import { storageService } from '../services/storage.js';
|
|
14
|
-
import {
|
|
14
|
+
import { dispatch } from '../harness/dispatcher.js';
|
|
15
15
|
import { initPlugins } from '../registry/plugins.js';
|
|
16
16
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
17
17
|
export async function startServer(options = {}) {
|
|
@@ -69,25 +69,39 @@ export async function startServer(options = {}) {
|
|
|
69
69
|
});
|
|
70
70
|
};
|
|
71
71
|
const buildActiveRunsSnapshot = () => {
|
|
72
|
-
const
|
|
72
|
+
const byBucket = new Map();
|
|
73
73
|
for (const run of activeRuns.values()) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
const threadId = run.threadId || undefined;
|
|
75
|
+
const key = JSON.stringify([run.channelId, threadId ?? null]);
|
|
76
|
+
let bucket = byBucket.get(key);
|
|
77
|
+
if (!bucket) {
|
|
78
|
+
bucket = { channelId: run.channelId, threadId, activeCount: 0, agentIds: new Set() };
|
|
79
|
+
byBucket.set(key, bucket);
|
|
80
|
+
}
|
|
81
|
+
bucket.activeCount += 1;
|
|
82
|
+
bucket.agentIds.add(run.agentId);
|
|
81
83
|
}
|
|
84
|
+
const channels = Array.from(byBucket.values())
|
|
85
|
+
.sort((a, b) => {
|
|
86
|
+
const c = a.channelId.localeCompare(b.channelId);
|
|
87
|
+
if (c !== 0)
|
|
88
|
+
return c;
|
|
89
|
+
return (a.threadId ?? '').localeCompare(b.threadId ?? '');
|
|
90
|
+
})
|
|
91
|
+
.map(({ channelId, threadId, activeCount, agentIds }) => {
|
|
92
|
+
const row = {
|
|
93
|
+
channelId,
|
|
94
|
+
activeCount,
|
|
95
|
+
agentIds: Array.from(agentIds),
|
|
96
|
+
};
|
|
97
|
+
if (threadId !== undefined) {
|
|
98
|
+
row.threadId = threadId;
|
|
99
|
+
}
|
|
100
|
+
return row;
|
|
101
|
+
});
|
|
82
102
|
return {
|
|
83
103
|
type: 'agent:active-runs:snapshot',
|
|
84
|
-
data: {
|
|
85
|
-
channels: Array.from(byChannel.entries()).map(([channelId, value]) => ({
|
|
86
|
-
channelId,
|
|
87
|
-
activeCount: value.activeCount,
|
|
88
|
-
agentIds: Array.from(value.agentIds),
|
|
89
|
-
})),
|
|
90
|
-
},
|
|
104
|
+
data: { channels },
|
|
91
105
|
};
|
|
92
106
|
};
|
|
93
107
|
app.use(cors());
|
|
@@ -178,19 +192,22 @@ export async function startServer(options = {}) {
|
|
|
178
192
|
event: chunk,
|
|
179
193
|
});
|
|
180
194
|
sendToClientKey(targetClientKey, chunk);
|
|
181
|
-
if (chunk.type === 'agent:run:start' ||
|
|
195
|
+
if (chunk.type === 'agent:run:start' ||
|
|
196
|
+
chunk.type === 'agent:run:end' ||
|
|
197
|
+
chunk.type === 'agent:run:stopped') {
|
|
182
198
|
sendToClientKey(GLOBAL_CHANNEL_ID, chunk);
|
|
183
199
|
}
|
|
184
200
|
};
|
|
185
201
|
try {
|
|
186
|
-
|
|
202
|
+
ensureEventId(event);
|
|
203
|
+
await dispatch({
|
|
187
204
|
runId,
|
|
188
205
|
agentId: agentId || 'system',
|
|
206
|
+
event,
|
|
189
207
|
channelId,
|
|
190
208
|
threadId,
|
|
191
209
|
onEvent,
|
|
192
210
|
});
|
|
193
|
-
await harness.dispatch(event);
|
|
194
211
|
res.sendStatus(200);
|
|
195
212
|
}
|
|
196
213
|
catch (error) {
|
|
@@ -220,14 +237,15 @@ export async function startServer(options = {}) {
|
|
|
220
237
|
events.push(chunk);
|
|
221
238
|
};
|
|
222
239
|
try {
|
|
223
|
-
|
|
240
|
+
ensureEventId(event);
|
|
241
|
+
await dispatch({
|
|
224
242
|
runId,
|
|
225
243
|
agentId: agentId || 'system',
|
|
244
|
+
event,
|
|
226
245
|
channelId,
|
|
227
246
|
threadId,
|
|
228
247
|
onEvent,
|
|
229
248
|
});
|
|
230
|
-
await harness.dispatch(event);
|
|
231
249
|
res.json({ events });
|
|
232
250
|
}
|
|
233
251
|
catch (error) {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ensureEventId } from '../app/utils.js';
|
|
2
|
+
import { storageService } from '../services/storage.js';
|
|
3
|
+
import { createAgentRuntime } from './runtime-factory.js';
|
|
4
|
+
import { advanceAfterRun } from './todo-advance.js';
|
|
5
|
+
const MAX_CHAIN_DEPTH = 20;
|
|
6
|
+
const stopRequests = [];
|
|
7
|
+
const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
|
|
8
|
+
const pruneStopRequests = () => {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
|
|
11
|
+
if (now - stopRequests[i].requestedAt > STOP_REQUEST_TTL_MS) {
|
|
12
|
+
stopRequests.splice(i, 1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const findStopRequest = (target) => {
|
|
17
|
+
pruneStopRequests();
|
|
18
|
+
return stopRequests.find((r) => {
|
|
19
|
+
if (r.runId !== target.runId)
|
|
20
|
+
return false;
|
|
21
|
+
if (r.agentId && r.agentId !== target.agentId)
|
|
22
|
+
return false;
|
|
23
|
+
if (r.channelId && r.channelId !== target.channelId)
|
|
24
|
+
return false;
|
|
25
|
+
if (r.threadId && r.threadId !== target.threadId)
|
|
26
|
+
return false;
|
|
27
|
+
return true;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Public API
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export async function dispatch(options) {
|
|
34
|
+
const { event } = options;
|
|
35
|
+
ensureEventId(event);
|
|
36
|
+
if (event.type === 'action:agent_run_stop') {
|
|
37
|
+
await handleStop(event, options);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const ctx = {
|
|
41
|
+
runId: options.runId,
|
|
42
|
+
channelId: options.channelId,
|
|
43
|
+
threadId: options.threadId,
|
|
44
|
+
onEvent: options.onEvent,
|
|
45
|
+
};
|
|
46
|
+
if (event.type === 'user:input' || event.type === 'agent:invoke') {
|
|
47
|
+
const invoke = await normalizeUserInput(event, ctx);
|
|
48
|
+
await runStep({ agentId: options.agentId || 'system', event: invoke }, ctx, 0);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Bus pass-through: route to the targeted agent's runtime once. No agent step,
|
|
52
|
+
// no advance, no follow-ups. Keeps queries (`/api/state`) cheap and idempotent.
|
|
53
|
+
await runBusEvent(event, options.agentId || 'system', ctx);
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Agent step: run:start -> runtime -> run:end -> advance -> chain
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
async function runStep(step, ctx, depth) {
|
|
59
|
+
if (depth >= MAX_CHAIN_DEPTH) {
|
|
60
|
+
console.warn(`[dispatcher] Reached MAX_CHAIN_DEPTH (${MAX_CHAIN_DEPTH}); stopping chain.`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const target = {
|
|
64
|
+
runId: ctx.runId,
|
|
65
|
+
agentId: step.agentId,
|
|
66
|
+
channelId: ctx.channelId,
|
|
67
|
+
threadId: ctx.threadId,
|
|
68
|
+
};
|
|
69
|
+
const preStop = findStopRequest(target);
|
|
70
|
+
if (preStop) {
|
|
71
|
+
const state = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
72
|
+
await ctx.onEvent({ type: 'agent:run:stopped', data: { ...target, reason: preStop.reason } }, state);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let state;
|
|
76
|
+
try {
|
|
77
|
+
state = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === 'AGENT_NOT_FOUND') {
|
|
81
|
+
const fallback = await storageService.getOpenBotState({
|
|
82
|
+
...target,
|
|
83
|
+
agentId: 'system',
|
|
84
|
+
event: step.event,
|
|
85
|
+
});
|
|
86
|
+
await ctx.onEvent({
|
|
87
|
+
type: 'agent:output',
|
|
88
|
+
data: { content: `⚠️ Agent **${step.agentId}** does not exist. Please check the agent ID and try again.` },
|
|
89
|
+
meta: { agentId: 'system', threadId: ctx.threadId },
|
|
90
|
+
}, fallback);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
await ctx.onEvent({ type: 'agent:run:start', data: { ...target } }, state);
|
|
96
|
+
const followUps = [];
|
|
97
|
+
const queuedAgentIds = new Set();
|
|
98
|
+
let lastAgentOutput;
|
|
99
|
+
try {
|
|
100
|
+
const runtime = await createAgentRuntime(state);
|
|
101
|
+
for await (const chunk of runtime.run(step.event, { state, runId: ctx.runId })) {
|
|
102
|
+
const stop = findStopRequest(target);
|
|
103
|
+
if (stop) {
|
|
104
|
+
await ctx.onEvent({ type: 'agent:run:stopped', data: { ...target, reason: stop.reason } }, state);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (chunk.id === step.event.id && chunk.type === step.event.type)
|
|
108
|
+
continue;
|
|
109
|
+
if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
|
|
110
|
+
ctx.threadId = chunk.data.threadId || ctx.threadId;
|
|
111
|
+
}
|
|
112
|
+
if (chunk.type === 'agent:output' &&
|
|
113
|
+
chunk.meta?.agentId === step.agentId) {
|
|
114
|
+
const content = chunk.data?.content;
|
|
115
|
+
if (typeof content === 'string' && content.trim())
|
|
116
|
+
lastAgentOutput = content.trim();
|
|
117
|
+
}
|
|
118
|
+
// Handoff requests are internal: queue a follow-up step instead of forwarding.
|
|
119
|
+
if (chunk.type === 'handoff:request') {
|
|
120
|
+
const req = chunk;
|
|
121
|
+
const targetAgent = req.data?.agentId;
|
|
122
|
+
if (targetAgent && targetAgent !== step.agentId && !queuedAgentIds.has(targetAgent)) {
|
|
123
|
+
queuedAgentIds.add(targetAgent);
|
|
124
|
+
followUps.push({
|
|
125
|
+
agentId: targetAgent,
|
|
126
|
+
event: makeInvoke(req.data.content, ctx.threadId, req.meta),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
chunk.meta = { ...chunk.meta, agentId: step.agentId };
|
|
132
|
+
await ctx.onEvent(chunk, state);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error(`[dispatcher] Agent run failed: ${step.agentId}`, error);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
const endState = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
140
|
+
await ctx.onEvent({ type: 'agent:run:end', data: { ...target } }, endState);
|
|
141
|
+
}
|
|
142
|
+
// Autonomous todo advance: single trigger point, runs once per `agent:run:end`.
|
|
143
|
+
try {
|
|
144
|
+
const handoff = await advanceAfterRun({
|
|
145
|
+
storage: storageService,
|
|
146
|
+
channelId: ctx.channelId,
|
|
147
|
+
threadId: ctx.threadId,
|
|
148
|
+
endedAgentId: step.agentId,
|
|
149
|
+
lastAgentOutput,
|
|
150
|
+
});
|
|
151
|
+
if (handoff && !queuedAgentIds.has(handoff.agentId)) {
|
|
152
|
+
queuedAgentIds.add(handoff.agentId);
|
|
153
|
+
followUps.push({
|
|
154
|
+
agentId: handoff.agentId,
|
|
155
|
+
event: makeInvoke(handoff.content, ctx.threadId),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
console.warn('[dispatcher] todo advance failed', error);
|
|
161
|
+
}
|
|
162
|
+
for (const next of followUps) {
|
|
163
|
+
await runStep(next, ctx, depth + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Bus pass-through: run an event through the targeted agent's runtime, forward
|
|
168
|
+
// chunks. No run:start/end, no advance, no follow-ups.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
async function runBusEvent(event, agentId, ctx) {
|
|
171
|
+
let state;
|
|
172
|
+
try {
|
|
173
|
+
state = await storageService.getOpenBotState({
|
|
174
|
+
runId: ctx.runId,
|
|
175
|
+
agentId,
|
|
176
|
+
channelId: ctx.channelId,
|
|
177
|
+
threadId: ctx.threadId,
|
|
178
|
+
event,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code === 'AGENT_NOT_FOUND') {
|
|
183
|
+
// Silently drop: bus pass-through has no UI surface to warn into.
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const runtime = await createAgentRuntime(state);
|
|
190
|
+
for await (const chunk of runtime.run(event, { state, runId: ctx.runId })) {
|
|
191
|
+
if (chunk.id === event.id && chunk.type === event.type)
|
|
192
|
+
continue;
|
|
193
|
+
chunk.meta = { ...chunk.meta, agentId };
|
|
194
|
+
await ctx.onEvent(chunk, state);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
console.error(`[dispatcher] Bus event failed: ${event.type} (${agentId})`, error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Helpers
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
async function normalizeUserInput(event, ctx) {
|
|
205
|
+
const rawContent = event.data?.content || '';
|
|
206
|
+
// The user-facing copy stored/streamed for the UI.
|
|
207
|
+
const userFacing = {
|
|
208
|
+
type: 'agent:invoke',
|
|
209
|
+
id: event.id,
|
|
210
|
+
data: { content: rawContent, role: 'user' },
|
|
211
|
+
meta: {
|
|
212
|
+
agentId: 'system',
|
|
213
|
+
userId: event.meta?.userId,
|
|
214
|
+
userName: event.meta?.userName,
|
|
215
|
+
userAvatarUrl: event.meta?.userAvatarUrl,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const initialState = await storageService.getOpenBotState({
|
|
219
|
+
runId: ctx.runId,
|
|
220
|
+
agentId: 'system',
|
|
221
|
+
channelId: ctx.channelId,
|
|
222
|
+
threadId: ctx.threadId,
|
|
223
|
+
event: userFacing,
|
|
224
|
+
});
|
|
225
|
+
await ctx.onEvent(userFacing, initialState);
|
|
226
|
+
// The event actually fed to the target agent. Carries the input threadId (or the
|
|
227
|
+
// message id, used as the anchor for Slack-style new threads).
|
|
228
|
+
return {
|
|
229
|
+
...event,
|
|
230
|
+
type: 'agent:invoke',
|
|
231
|
+
data: { ...(event.data || {}), content: rawContent, role: 'user' },
|
|
232
|
+
meta: {
|
|
233
|
+
...(event.meta || {}),
|
|
234
|
+
threadId: ctx.threadId || event.id,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function makeInvoke(content, threadId, baseMeta) {
|
|
239
|
+
return ensureEventId({
|
|
240
|
+
type: 'agent:invoke',
|
|
241
|
+
data: { role: 'user', content },
|
|
242
|
+
meta: { ...(baseMeta || {}), threadId },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
async function handleStop(stopEvent, options) {
|
|
246
|
+
const { runId, channelId, threadId, onEvent } = options;
|
|
247
|
+
stopRequests.push({
|
|
248
|
+
runId: stopEvent.data.runId,
|
|
249
|
+
agentId: stopEvent.data.agentId,
|
|
250
|
+
channelId: stopEvent.data.channelId || channelId,
|
|
251
|
+
threadId: stopEvent.data.threadId || threadId,
|
|
252
|
+
reason: stopEvent.data.reason,
|
|
253
|
+
requestedAt: Date.now(),
|
|
254
|
+
});
|
|
255
|
+
const state = await storageService.getOpenBotState({
|
|
256
|
+
runId,
|
|
257
|
+
agentId: options.agentId || 'system',
|
|
258
|
+
channelId,
|
|
259
|
+
threadId,
|
|
260
|
+
event: stopEvent,
|
|
261
|
+
});
|
|
262
|
+
await onEvent({
|
|
263
|
+
type: 'action:agent_run_stop:result',
|
|
264
|
+
data: { success: true, message: `Stop requested for run ${stopEvent.data.runId}.` },
|
|
265
|
+
meta: stopEvent.meta,
|
|
266
|
+
}, state);
|
|
267
|
+
}
|
|
@@ -2,6 +2,30 @@ import { storageService } from '../services/storage.js';
|
|
|
2
2
|
import { createAgentRuntime } from './runtime-factory.js';
|
|
3
3
|
import { EventNormalizer } from './event-normalizer.js';
|
|
4
4
|
import { QueueProcessor } from './queue-processor.js';
|
|
5
|
+
const stopRequests = [];
|
|
6
|
+
const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
|
|
7
|
+
const pruneStopRequests = () => {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
|
|
10
|
+
if (now - stopRequests[i].requestedAt > STOP_REQUEST_TTL_MS) {
|
|
11
|
+
stopRequests.splice(i, 1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const findStopRequest = (options) => {
|
|
16
|
+
pruneStopRequests();
|
|
17
|
+
return stopRequests.find((request) => {
|
|
18
|
+
if (request.runId !== options.runId)
|
|
19
|
+
return false;
|
|
20
|
+
if (request.agentId && request.agentId !== options.agentId)
|
|
21
|
+
return false;
|
|
22
|
+
if (request.channelId && request.channelId !== options.channelId)
|
|
23
|
+
return false;
|
|
24
|
+
if (request.threadId && request.threadId !== options.threadId)
|
|
25
|
+
return false;
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
};
|
|
5
29
|
export const orchestratorService = {
|
|
6
30
|
/**
|
|
7
31
|
* The primary entry point for all events coming into the system (e.g. from the API).
|
|
@@ -9,6 +33,33 @@ export const orchestratorService = {
|
|
|
9
33
|
*/
|
|
10
34
|
dispatch: async (options) => {
|
|
11
35
|
const { runId, channelId, threadId, onEvent } = options;
|
|
36
|
+
if (options.event.type === 'action:agent_run_stop') {
|
|
37
|
+
const stopEvent = options.event;
|
|
38
|
+
stopRequests.push({
|
|
39
|
+
runId: stopEvent.data.runId,
|
|
40
|
+
agentId: stopEvent.data.agentId,
|
|
41
|
+
channelId: stopEvent.data.channelId || channelId,
|
|
42
|
+
threadId: stopEvent.data.threadId || threadId,
|
|
43
|
+
reason: stopEvent.data.reason,
|
|
44
|
+
requestedAt: Date.now(),
|
|
45
|
+
});
|
|
46
|
+
const state = await storageService.getOpenBotState({
|
|
47
|
+
runId,
|
|
48
|
+
agentId: options.agentId || 'system',
|
|
49
|
+
channelId,
|
|
50
|
+
threadId,
|
|
51
|
+
event: options.event,
|
|
52
|
+
});
|
|
53
|
+
await onEvent({
|
|
54
|
+
type: 'action:agent_run_stop:result',
|
|
55
|
+
data: {
|
|
56
|
+
success: true,
|
|
57
|
+
message: `Stop requested for run ${stopEvent.data.runId}.`,
|
|
58
|
+
},
|
|
59
|
+
meta: options.event.meta,
|
|
60
|
+
}, state);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
12
63
|
// 1. Normalize incoming event
|
|
13
64
|
const { finalEvent, finalAgentId } = await EventNormalizer.normalize(options.event, {
|
|
14
65
|
runId,
|
|
@@ -24,6 +75,7 @@ export const orchestratorService = {
|
|
|
24
75
|
threadId,
|
|
25
76
|
onEvent,
|
|
26
77
|
executeAgent: orchestratorService.executeAgent,
|
|
78
|
+
shouldStopRun: orchestratorService.shouldStopRun,
|
|
27
79
|
});
|
|
28
80
|
// 3. Enqueue initial event
|
|
29
81
|
processor.enqueue({ agentId: finalAgentId, event: finalEvent });
|
|
@@ -59,9 +111,29 @@ export const orchestratorService = {
|
|
|
59
111
|
throw error;
|
|
60
112
|
}
|
|
61
113
|
const agentRuntime = await createAgentRuntime(agentState);
|
|
114
|
+
const stopInfo = {
|
|
115
|
+
runId,
|
|
116
|
+
agentId,
|
|
117
|
+
channelId,
|
|
118
|
+
threadId,
|
|
119
|
+
};
|
|
62
120
|
try {
|
|
63
121
|
// RUN the agent runtime
|
|
64
122
|
for await (const chunk of agentRuntime.run(event, { state: agentState, runId })) {
|
|
123
|
+
const stopRequest = findStopRequest(stopInfo);
|
|
124
|
+
if (stopRequest) {
|
|
125
|
+
await onEvent({
|
|
126
|
+
type: 'agent:run:stopped',
|
|
127
|
+
data: {
|
|
128
|
+
runId,
|
|
129
|
+
agentId,
|
|
130
|
+
channelId,
|
|
131
|
+
threadId,
|
|
132
|
+
reason: stopRequest.reason,
|
|
133
|
+
},
|
|
134
|
+
}, agentState);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
65
137
|
chunk.meta = { ...chunk.meta, agentId };
|
|
66
138
|
await onEvent(chunk, agentState);
|
|
67
139
|
}
|
|
@@ -70,4 +142,8 @@ export const orchestratorService = {
|
|
|
70
142
|
console.error(`[orchestrator] Agent run failed: ${agentId}`, error);
|
|
71
143
|
}
|
|
72
144
|
},
|
|
145
|
+
shouldStopRun: (options) => {
|
|
146
|
+
const request = findStopRequest(options);
|
|
147
|
+
return request ? { shouldStop: true, reason: request.reason } : { shouldStop: false };
|
|
148
|
+
},
|
|
73
149
|
};
|
|
@@ -28,6 +28,32 @@ export class QueueProcessor {
|
|
|
28
28
|
// Run items for the SAME agent sequentially to preserve event order and state consistency.
|
|
29
29
|
for (const item of items) {
|
|
30
30
|
const { event: currentEvent } = item;
|
|
31
|
+
const stopCheck = this.options.shouldStopRun?.({
|
|
32
|
+
runId: this.options.runId,
|
|
33
|
+
agentId,
|
|
34
|
+
channelId: this.options.channelId,
|
|
35
|
+
threadId: this.currentThreadId,
|
|
36
|
+
});
|
|
37
|
+
if (stopCheck?.shouldStop) {
|
|
38
|
+
const stoppedState = await storageService.getOpenBotState({
|
|
39
|
+
runId: this.options.runId,
|
|
40
|
+
agentId,
|
|
41
|
+
channelId: this.options.channelId,
|
|
42
|
+
threadId: this.currentThreadId,
|
|
43
|
+
event: currentEvent,
|
|
44
|
+
});
|
|
45
|
+
await this.options.onEvent({
|
|
46
|
+
type: 'agent:run:stopped',
|
|
47
|
+
data: {
|
|
48
|
+
runId: this.options.runId,
|
|
49
|
+
agentId,
|
|
50
|
+
channelId: this.options.channelId,
|
|
51
|
+
threadId: this.currentThreadId,
|
|
52
|
+
reason: stopCheck.reason,
|
|
53
|
+
},
|
|
54
|
+
}, stoppedState);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
31
57
|
// Track handoff requests queued in this step to avoid accidental duplicates.
|
|
32
58
|
const queuedRequestKeys = new Set();
|
|
33
59
|
const queuedItems = [];
|
package/docs/architecture.md
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
# Architecture
|
|
2
2
|
|
|
3
|
-
OpenBot is an orchestration platform built on a modular, event-driven architecture. It leverages the `melony` framework to coordinate interactions between multiple specialized agents
|
|
3
|
+
OpenBot is an orchestration platform built on a modular, event-driven architecture. It leverages the `melony` framework to coordinate interactions between multiple specialized agents through a central **orchestrator** (HTTP handlers call it directly).
|
|
4
4
|
|
|
5
5
|
## Core Components
|
|
6
6
|
|
|
7
|
-
### 1.
|
|
8
|
-
The
|
|
9
|
-
|
|
10
|
-
### 2. Agent Orchestration & Routing
|
|
11
|
-
The orchestrator is the central dispatcher within the harness. It receives user input and determines how to delegate tasks across the agent network using the following logic:
|
|
7
|
+
### 1. Orchestrator & routing
|
|
8
|
+
The orchestrator is the execution entry point for agent work: it normalizes incoming events, runs the queue processor (handoffs and todo-driven assignees), builds per-agent Melony runtimes, and streams emitted events back to callers (for example storage and SSE). Routing across the agent network uses:
|
|
12
9
|
|
|
13
10
|
1. **Command Prefix** — Explicit delegation to a specific agent (e.g., `/os list files`).
|
|
14
11
|
2. **DM context** — Direct communication with a specific agent.
|
|
@@ -20,10 +17,10 @@ A dynamic registry that manages all available agents. Agents can be:
|
|
|
20
17
|
- **YAML-based**: Rapidly defined agents in `~/.openbot/agents/*/AGENT.md`.
|
|
21
18
|
- **TS Packages**: Advanced agents with custom logic in `~/.openbot/agents/*/index.ts`.
|
|
22
19
|
|
|
23
|
-
### 3. Plugin
|
|
20
|
+
### 3. Plugin registry
|
|
24
21
|
The "capability layer" that provides tools and logic shared across the platform. Plugins (like `shell`, `file-system`, or `mcp`) define the actions agents can perform.
|
|
25
22
|
|
|
26
|
-
### 4. Orchestration
|
|
23
|
+
### 4. Orchestration layer (Melony)
|
|
27
24
|
The underlying event bus that handles all communication. It ensures that agents can collaborate asynchronously, share context, and emit real-time updates to the UI.
|
|
28
25
|
|
|
29
26
|
## Multi-Agent Workflow
|
package/package.json
CHANGED
package/src/app/cli.ts
CHANGED