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/src/app/server.ts
CHANGED
|
@@ -12,10 +12,12 @@ import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
|
12
12
|
import { ActiveRunsSnapshotEvent, OpenBotEvent, OpenBotState } from './types.js';
|
|
13
13
|
import { processService } from '../harness/process.js';
|
|
14
14
|
import { storageService } from '../services/storage.js';
|
|
15
|
-
import {
|
|
15
|
+
import { dispatch } from '../harness/dispatcher.js';
|
|
16
16
|
import { initPlugins } from '../registry/plugins.js';
|
|
17
17
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
18
18
|
|
|
19
|
+
type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
|
|
20
|
+
|
|
19
21
|
export interface ServerOptions {
|
|
20
22
|
port?: number;
|
|
21
23
|
}
|
|
@@ -94,25 +96,38 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
94
96
|
};
|
|
95
97
|
|
|
96
98
|
const buildActiveRunsSnapshot = (): ActiveRunsSnapshotEvent => {
|
|
97
|
-
const
|
|
99
|
+
const byBucket = new Map<string, Bucket>();
|
|
98
100
|
for (const run of activeRuns.values()) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
const threadId = run.threadId || undefined;
|
|
102
|
+
const key = JSON.stringify([run.channelId, threadId ?? null]);
|
|
103
|
+
let bucket = byBucket.get(key);
|
|
104
|
+
if (!bucket) {
|
|
105
|
+
bucket = { channelId: run.channelId, threadId, activeCount: 0, agentIds: new Set<string>() };
|
|
106
|
+
byBucket.set(key, bucket);
|
|
107
|
+
}
|
|
108
|
+
bucket.activeCount += 1;
|
|
109
|
+
bucket.agentIds.add(run.agentId);
|
|
106
110
|
}
|
|
111
|
+
const channels = Array.from(byBucket.values())
|
|
112
|
+
.sort((a, b) => {
|
|
113
|
+
const c = a.channelId.localeCompare(b.channelId);
|
|
114
|
+
if (c !== 0) return c;
|
|
115
|
+
return (a.threadId ?? '').localeCompare(b.threadId ?? '');
|
|
116
|
+
})
|
|
117
|
+
.map(({ channelId, threadId, activeCount, agentIds }) => {
|
|
118
|
+
const row: ActiveRunsSnapshotEvent['data']['channels'][number] = {
|
|
119
|
+
channelId,
|
|
120
|
+
activeCount,
|
|
121
|
+
agentIds: Array.from(agentIds),
|
|
122
|
+
};
|
|
123
|
+
if (threadId !== undefined) {
|
|
124
|
+
row.threadId = threadId;
|
|
125
|
+
}
|
|
126
|
+
return row;
|
|
127
|
+
});
|
|
107
128
|
return {
|
|
108
129
|
type: 'agent:active-runs:snapshot',
|
|
109
|
-
data: {
|
|
110
|
-
channels: Array.from(byChannel.entries()).map(([channelId, value]) => ({
|
|
111
|
-
channelId,
|
|
112
|
-
activeCount: value.activeCount,
|
|
113
|
-
agentIds: Array.from(value.agentIds),
|
|
114
|
-
})),
|
|
115
|
-
},
|
|
130
|
+
data: { channels },
|
|
116
131
|
};
|
|
117
132
|
};
|
|
118
133
|
|
|
@@ -149,6 +164,7 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
149
164
|
|
|
150
165
|
if (channelId === GLOBAL_CHANNEL_ID) {
|
|
151
166
|
const snapshot = buildActiveRunsSnapshot();
|
|
167
|
+
|
|
152
168
|
ensureEventId(snapshot);
|
|
153
169
|
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
154
170
|
}
|
|
@@ -206,10 +222,10 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
206
222
|
activeRuns.set(
|
|
207
223
|
getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
|
|
208
224
|
{
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
runId: chunk.data.runId,
|
|
226
|
+
channelId: chunk.data.channelId,
|
|
227
|
+
threadId: chunk.data.threadId,
|
|
228
|
+
agentId: chunk.data.agentId,
|
|
213
229
|
},
|
|
214
230
|
);
|
|
215
231
|
} else if (chunk.type === 'agent:run:end') {
|
|
@@ -226,21 +242,26 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
226
242
|
|
|
227
243
|
sendToClientKey(targetClientKey, chunk);
|
|
228
244
|
|
|
229
|
-
if (
|
|
245
|
+
if (
|
|
246
|
+
chunk.type === 'agent:run:start' ||
|
|
247
|
+
chunk.type === 'agent:run:end' ||
|
|
248
|
+
chunk.type === 'agent:run:stopped'
|
|
249
|
+
) {
|
|
230
250
|
sendToClientKey(GLOBAL_CHANNEL_ID, chunk);
|
|
231
251
|
}
|
|
232
252
|
};
|
|
233
253
|
|
|
234
254
|
try {
|
|
235
|
-
|
|
255
|
+
ensureEventId(event);
|
|
256
|
+
|
|
257
|
+
await dispatch({
|
|
236
258
|
runId,
|
|
237
259
|
agentId: agentId || 'system',
|
|
260
|
+
event,
|
|
238
261
|
channelId,
|
|
239
262
|
threadId,
|
|
240
263
|
onEvent,
|
|
241
264
|
});
|
|
242
|
-
|
|
243
|
-
await harness.dispatch(event);
|
|
244
265
|
res.sendStatus(200);
|
|
245
266
|
} catch (error) {
|
|
246
267
|
console.error('[publish] Failed to dispatch event', {
|
|
@@ -272,15 +293,16 @@ export async function startServer(options: ServerOptions = {}) {
|
|
|
272
293
|
};
|
|
273
294
|
|
|
274
295
|
try {
|
|
275
|
-
|
|
296
|
+
ensureEventId(event);
|
|
297
|
+
|
|
298
|
+
await dispatch({
|
|
276
299
|
runId,
|
|
277
300
|
agentId: agentId || 'system',
|
|
301
|
+
event,
|
|
278
302
|
channelId,
|
|
279
303
|
threadId,
|
|
280
304
|
onEvent,
|
|
281
305
|
});
|
|
282
|
-
|
|
283
|
-
await harness.dispatch(event);
|
|
284
306
|
res.json({ events });
|
|
285
307
|
} catch (error) {
|
|
286
308
|
res.status(500).json({ error: 'Failed to process state request' });
|
package/src/app/types.ts
CHANGED
|
@@ -365,17 +365,48 @@ export type AgentRunEndEvent = BaseEvent & {
|
|
|
365
365
|
};
|
|
366
366
|
};
|
|
367
367
|
|
|
368
|
+
export type AgentRunStoppedEvent = BaseEvent & {
|
|
369
|
+
type: 'agent:run:stopped';
|
|
370
|
+
data: {
|
|
371
|
+
runId: string;
|
|
372
|
+
agentId: string;
|
|
373
|
+
channelId: string;
|
|
374
|
+
threadId?: string;
|
|
375
|
+
reason?: string;
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
368
379
|
export type ActiveRunsSnapshotEvent = BaseEvent & {
|
|
369
380
|
type: 'agent:active-runs:snapshot';
|
|
370
381
|
data: {
|
|
371
382
|
channels: Array<{
|
|
372
383
|
channelId: string;
|
|
384
|
+
threadId?: string;
|
|
373
385
|
activeCount: number;
|
|
374
386
|
agentIds: string[];
|
|
375
387
|
}>;
|
|
376
388
|
};
|
|
377
389
|
};
|
|
378
390
|
|
|
391
|
+
export type StopAgentRunEvent = BaseEvent & {
|
|
392
|
+
type: 'action:agent_run_stop';
|
|
393
|
+
data: {
|
|
394
|
+
runId: string;
|
|
395
|
+
agentId?: string;
|
|
396
|
+
channelId?: string;
|
|
397
|
+
threadId?: string;
|
|
398
|
+
reason?: string;
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export type StopAgentRunResultEvent = BaseEvent & {
|
|
403
|
+
type: 'action:agent_run_stop:result';
|
|
404
|
+
data: {
|
|
405
|
+
success: boolean;
|
|
406
|
+
message?: string;
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
|
|
379
410
|
export type CreateThreadEvent = BaseEvent & {
|
|
380
411
|
type: 'action:create_thread';
|
|
381
412
|
data: {
|
|
@@ -860,7 +891,10 @@ export type OpenBotEvent =
|
|
|
860
891
|
| AgentOutputEvent
|
|
861
892
|
| AgentRunStartEvent
|
|
862
893
|
| AgentRunEndEvent
|
|
894
|
+
| AgentRunStoppedEvent
|
|
863
895
|
| ActiveRunsSnapshotEvent
|
|
896
|
+
| StopAgentRunEvent
|
|
897
|
+
| StopAgentRunResultEvent
|
|
864
898
|
| GetChannelsEvent
|
|
865
899
|
| GetChannelsResultEvent
|
|
866
900
|
| GetThreadsEvent
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentInvokeEvent,
|
|
3
|
+
HandoffRequestEvent,
|
|
4
|
+
OpenBotEvent,
|
|
5
|
+
OpenBotState,
|
|
6
|
+
StopAgentRunEvent,
|
|
7
|
+
} from '../app/types.js';
|
|
8
|
+
import { ensureEventId } from '../app/utils.js';
|
|
9
|
+
import { storageService } from '../services/storage.js';
|
|
10
|
+
import { createAgentRuntime } from './runtime-factory.js';
|
|
11
|
+
import { advanceAfterRun } from './todo-advance.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Single entry point for every event arriving at the bus.
|
|
15
|
+
*
|
|
16
|
+
* Three flavors of dispatch:
|
|
17
|
+
*
|
|
18
|
+
* 1. `action:agent_run_stop` — record a stop signal, ack, done.
|
|
19
|
+
* 2. `user:input` / `agent:invoke` — *agent step*: normalize, emit user-facing
|
|
20
|
+
* copy, then run the target agent with `run:start`/`run:end` bracketing,
|
|
21
|
+
* handoff routing, and a single `advanceAfterRun` pass that can chain the
|
|
22
|
+
* next assignee. Recursive, depth-bounded.
|
|
23
|
+
* 3. Everything else — *bus pass-through*: run the event through the targeted
|
|
24
|
+
* agent's runtime once and forward emitted chunks. No `run:start`/`run:end`,
|
|
25
|
+
* no todo advance, no handoff. This is what backs `/api/state` queries and
|
|
26
|
+
* out-of-band action events posted to `/api/publish`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface DispatchOptions {
|
|
30
|
+
runId: string;
|
|
31
|
+
agentId?: string;
|
|
32
|
+
event: OpenBotEvent;
|
|
33
|
+
channelId: string;
|
|
34
|
+
threadId?: string;
|
|
35
|
+
onEvent: (chunk: OpenBotEvent, state: OpenBotState) => Promise<boolean | void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StepContext {
|
|
39
|
+
runId: string;
|
|
40
|
+
channelId: string;
|
|
41
|
+
/** Mutable: a `create_thread:result` mid-chain rebinds the rest of the chain. */
|
|
42
|
+
threadId?: string;
|
|
43
|
+
onEvent: DispatchOptions['onEvent'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FollowUp {
|
|
47
|
+
agentId: string;
|
|
48
|
+
event: AgentInvokeEvent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MAX_CHAIN_DEPTH = 20;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Stop requests
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
type StopRequest = {
|
|
58
|
+
runId: string;
|
|
59
|
+
agentId?: string;
|
|
60
|
+
channelId?: string;
|
|
61
|
+
threadId?: string;
|
|
62
|
+
reason?: string;
|
|
63
|
+
requestedAt: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const stopRequests: StopRequest[] = [];
|
|
67
|
+
const STOP_REQUEST_TTL_MS = 30 * 60 * 1000;
|
|
68
|
+
|
|
69
|
+
const pruneStopRequests = () => {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
for (let i = stopRequests.length - 1; i >= 0; i -= 1) {
|
|
72
|
+
if (now - stopRequests[i].requestedAt > STOP_REQUEST_TTL_MS) {
|
|
73
|
+
stopRequests.splice(i, 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const findStopRequest = (target: {
|
|
79
|
+
runId: string;
|
|
80
|
+
agentId: string;
|
|
81
|
+
channelId: string;
|
|
82
|
+
threadId?: string;
|
|
83
|
+
}): StopRequest | undefined => {
|
|
84
|
+
pruneStopRequests();
|
|
85
|
+
return stopRequests.find((r) => {
|
|
86
|
+
if (r.runId !== target.runId) return false;
|
|
87
|
+
if (r.agentId && r.agentId !== target.agentId) return false;
|
|
88
|
+
if (r.channelId && r.channelId !== target.channelId) return false;
|
|
89
|
+
if (r.threadId && r.threadId !== target.threadId) return false;
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Public API
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export async function dispatch(options: DispatchOptions): Promise<void> {
|
|
99
|
+
const { event } = options;
|
|
100
|
+
ensureEventId(event);
|
|
101
|
+
|
|
102
|
+
if (event.type === 'action:agent_run_stop') {
|
|
103
|
+
await handleStop(event as StopAgentRunEvent, options);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ctx: StepContext = {
|
|
108
|
+
runId: options.runId,
|
|
109
|
+
channelId: options.channelId,
|
|
110
|
+
threadId: options.threadId,
|
|
111
|
+
onEvent: options.onEvent,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (event.type === 'user:input' || event.type === 'agent:invoke') {
|
|
115
|
+
const invoke = await normalizeUserInput(event, ctx);
|
|
116
|
+
await runStep({ agentId: options.agentId || 'system', event: invoke }, ctx, 0);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Bus pass-through: route to the targeted agent's runtime once. No agent step,
|
|
121
|
+
// no advance, no follow-ups. Keeps queries (`/api/state`) cheap and idempotent.
|
|
122
|
+
await runBusEvent(event, options.agentId || 'system', ctx);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Agent step: run:start -> runtime -> run:end -> advance -> chain
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
async function runStep(step: FollowUp, ctx: StepContext, depth: number): Promise<void> {
|
|
130
|
+
if (depth >= MAX_CHAIN_DEPTH) {
|
|
131
|
+
console.warn(`[dispatcher] Reached MAX_CHAIN_DEPTH (${MAX_CHAIN_DEPTH}); stopping chain.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const target = {
|
|
136
|
+
runId: ctx.runId,
|
|
137
|
+
agentId: step.agentId,
|
|
138
|
+
channelId: ctx.channelId,
|
|
139
|
+
threadId: ctx.threadId,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const preStop = findStopRequest(target);
|
|
143
|
+
if (preStop) {
|
|
144
|
+
const state = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
145
|
+
await ctx.onEvent(
|
|
146
|
+
{ type: 'agent:run:stopped', data: { ...target, reason: preStop.reason } },
|
|
147
|
+
state,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let state: OpenBotState;
|
|
153
|
+
try {
|
|
154
|
+
state = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if ((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND') {
|
|
157
|
+
const fallback = await storageService.getOpenBotState({
|
|
158
|
+
...target,
|
|
159
|
+
agentId: 'system',
|
|
160
|
+
event: step.event,
|
|
161
|
+
});
|
|
162
|
+
await ctx.onEvent(
|
|
163
|
+
{
|
|
164
|
+
type: 'agent:output',
|
|
165
|
+
data: { content: `⚠️ Agent **${step.agentId}** does not exist. Please check the agent ID and try again.` },
|
|
166
|
+
meta: { agentId: 'system', threadId: ctx.threadId },
|
|
167
|
+
},
|
|
168
|
+
fallback,
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await ctx.onEvent({ type: 'agent:run:start', data: { ...target } }, state);
|
|
176
|
+
|
|
177
|
+
const followUps: FollowUp[] = [];
|
|
178
|
+
const queuedAgentIds = new Set<string>();
|
|
179
|
+
let lastAgentOutput: string | undefined;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const runtime = await createAgentRuntime(state);
|
|
183
|
+
|
|
184
|
+
for await (const chunk of runtime.run(step.event, { state, runId: ctx.runId })) {
|
|
185
|
+
const stop = findStopRequest(target);
|
|
186
|
+
if (stop) {
|
|
187
|
+
await ctx.onEvent(
|
|
188
|
+
{ type: 'agent:run:stopped', data: { ...target, reason: stop.reason } },
|
|
189
|
+
state,
|
|
190
|
+
);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (chunk.id === step.event.id && chunk.type === step.event.type) continue;
|
|
195
|
+
|
|
196
|
+
if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
|
|
197
|
+
ctx.threadId = chunk.data.threadId || ctx.threadId;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
chunk.type === 'agent:output' &&
|
|
202
|
+
(chunk.meta as { agentId?: string } | undefined)?.agentId === step.agentId
|
|
203
|
+
) {
|
|
204
|
+
const content = chunk.data?.content;
|
|
205
|
+
if (typeof content === 'string' && content.trim()) lastAgentOutput = content.trim();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Handoff requests are internal: queue a follow-up step instead of forwarding.
|
|
209
|
+
if (chunk.type === 'handoff:request') {
|
|
210
|
+
const req = chunk as HandoffRequestEvent;
|
|
211
|
+
const targetAgent = req.data?.agentId;
|
|
212
|
+
if (targetAgent && targetAgent !== step.agentId && !queuedAgentIds.has(targetAgent)) {
|
|
213
|
+
queuedAgentIds.add(targetAgent);
|
|
214
|
+
followUps.push({
|
|
215
|
+
agentId: targetAgent,
|
|
216
|
+
event: makeInvoke(req.data.content, ctx.threadId, req.meta),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
chunk.meta = { ...chunk.meta, agentId: step.agentId };
|
|
223
|
+
await ctx.onEvent(chunk, state);
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(`[dispatcher] Agent run failed: ${step.agentId}`, error);
|
|
227
|
+
} finally {
|
|
228
|
+
const endState = await storageService.getOpenBotState({ ...target, event: step.event });
|
|
229
|
+
await ctx.onEvent({ type: 'agent:run:end', data: { ...target } }, endState);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Autonomous todo advance: single trigger point, runs once per `agent:run:end`.
|
|
233
|
+
try {
|
|
234
|
+
const handoff = await advanceAfterRun({
|
|
235
|
+
storage: storageService,
|
|
236
|
+
channelId: ctx.channelId,
|
|
237
|
+
threadId: ctx.threadId,
|
|
238
|
+
endedAgentId: step.agentId,
|
|
239
|
+
lastAgentOutput,
|
|
240
|
+
});
|
|
241
|
+
if (handoff && !queuedAgentIds.has(handoff.agentId)) {
|
|
242
|
+
queuedAgentIds.add(handoff.agentId);
|
|
243
|
+
followUps.push({
|
|
244
|
+
agentId: handoff.agentId,
|
|
245
|
+
event: makeInvoke(handoff.content, ctx.threadId),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.warn('[dispatcher] todo advance failed', error);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const next of followUps) {
|
|
253
|
+
await runStep(next, ctx, depth + 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Bus pass-through: run an event through the targeted agent's runtime, forward
|
|
259
|
+
// chunks. No run:start/end, no advance, no follow-ups.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
async function runBusEvent(
|
|
263
|
+
event: OpenBotEvent,
|
|
264
|
+
agentId: string,
|
|
265
|
+
ctx: StepContext,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
let state: OpenBotState;
|
|
268
|
+
try {
|
|
269
|
+
state = await storageService.getOpenBotState({
|
|
270
|
+
runId: ctx.runId,
|
|
271
|
+
agentId,
|
|
272
|
+
channelId: ctx.channelId,
|
|
273
|
+
threadId: ctx.threadId,
|
|
274
|
+
event,
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if ((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND') {
|
|
278
|
+
// Silently drop: bus pass-through has no UI surface to warn into.
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const runtime = await createAgentRuntime(state);
|
|
286
|
+
for await (const chunk of runtime.run(event, { state, runId: ctx.runId })) {
|
|
287
|
+
if (chunk.id === event.id && chunk.type === event.type) continue;
|
|
288
|
+
chunk.meta = { ...chunk.meta, agentId };
|
|
289
|
+
await ctx.onEvent(chunk, state);
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error(`[dispatcher] Bus event failed: ${event.type} (${agentId})`, error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Helpers
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
async function normalizeUserInput(
|
|
301
|
+
event: OpenBotEvent,
|
|
302
|
+
ctx: StepContext,
|
|
303
|
+
): Promise<AgentInvokeEvent> {
|
|
304
|
+
const rawContent = (event as { data?: { content?: string } }).data?.content || '';
|
|
305
|
+
|
|
306
|
+
// The user-facing copy stored/streamed for the UI.
|
|
307
|
+
const userFacing: AgentInvokeEvent = {
|
|
308
|
+
type: 'agent:invoke',
|
|
309
|
+
id: event.id,
|
|
310
|
+
data: { content: rawContent, role: 'user' },
|
|
311
|
+
meta: {
|
|
312
|
+
agentId: 'system',
|
|
313
|
+
userId: event.meta?.userId,
|
|
314
|
+
userName: event.meta?.userName,
|
|
315
|
+
userAvatarUrl: event.meta?.userAvatarUrl,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const initialState = await storageService.getOpenBotState({
|
|
320
|
+
runId: ctx.runId,
|
|
321
|
+
agentId: 'system',
|
|
322
|
+
channelId: ctx.channelId,
|
|
323
|
+
threadId: ctx.threadId,
|
|
324
|
+
event: userFacing,
|
|
325
|
+
});
|
|
326
|
+
await ctx.onEvent(userFacing, initialState);
|
|
327
|
+
|
|
328
|
+
// The event actually fed to the target agent. Carries the input threadId (or the
|
|
329
|
+
// message id, used as the anchor for Slack-style new threads).
|
|
330
|
+
return {
|
|
331
|
+
...(event as AgentInvokeEvent),
|
|
332
|
+
type: 'agent:invoke',
|
|
333
|
+
data: { ...((event as AgentInvokeEvent).data || {}), content: rawContent, role: 'user' },
|
|
334
|
+
meta: {
|
|
335
|
+
...(event.meta || {}),
|
|
336
|
+
threadId: ctx.threadId || event.id,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function makeInvoke(
|
|
342
|
+
content: string,
|
|
343
|
+
threadId?: string,
|
|
344
|
+
baseMeta?: Record<string, unknown>,
|
|
345
|
+
): AgentInvokeEvent {
|
|
346
|
+
return ensureEventId({
|
|
347
|
+
type: 'agent:invoke',
|
|
348
|
+
data: { role: 'user', content },
|
|
349
|
+
meta: { ...(baseMeta || {}), threadId },
|
|
350
|
+
} satisfies AgentInvokeEvent) as AgentInvokeEvent;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function handleStop(stopEvent: StopAgentRunEvent, options: DispatchOptions): Promise<void> {
|
|
354
|
+
const { runId, channelId, threadId, onEvent } = options;
|
|
355
|
+
stopRequests.push({
|
|
356
|
+
runId: stopEvent.data.runId,
|
|
357
|
+
agentId: stopEvent.data.agentId,
|
|
358
|
+
channelId: stopEvent.data.channelId || channelId,
|
|
359
|
+
threadId: stopEvent.data.threadId || threadId,
|
|
360
|
+
reason: stopEvent.data.reason,
|
|
361
|
+
requestedAt: Date.now(),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const state = await storageService.getOpenBotState({
|
|
365
|
+
runId,
|
|
366
|
+
agentId: options.agentId || 'system',
|
|
367
|
+
channelId,
|
|
368
|
+
threadId,
|
|
369
|
+
event: stopEvent,
|
|
370
|
+
});
|
|
371
|
+
await onEvent(
|
|
372
|
+
{
|
|
373
|
+
type: 'action:agent_run_stop:result',
|
|
374
|
+
data: { success: true, message: `Stop requested for run ${stopEvent.data.runId}.` },
|
|
375
|
+
meta: stopEvent.meta,
|
|
376
|
+
},
|
|
377
|
+
state,
|
|
378
|
+
);
|
|
379
|
+
}
|
package/src/services/storage.ts
CHANGED
|
@@ -440,7 +440,7 @@ export const storageService = {
|
|
|
440
440
|
await fs.writeFile(
|
|
441
441
|
specPath,
|
|
442
442
|
spec?.trim() ||
|
|
443
|
-
|
|
443
|
+
`# ${normalizedChannelId}\n\nDefine the goals and rules for this channel here.\n`,
|
|
444
444
|
);
|
|
445
445
|
await fs.writeFile(statePath, JSON.stringify(finalState, null, 2));
|
|
446
446
|
},
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { Runtime } from 'melony';
|
|
2
|
-
import { OpenBotEvent, OpenBotState } from '../app/types.js';
|
|
3
|
-
import { Harness, HarnessOptions } from './types.js';
|
|
4
|
-
import { orchestratorService } from './orchestrator.js';
|
|
5
|
-
import { ensureEventId } from '../app/utils.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Standard implementation of the Agent Harness.
|
|
9
|
-
* It wraps the orchestrator logic into a clean, stateful container.
|
|
10
|
-
*/
|
|
11
|
-
export class AgentHarness implements Harness {
|
|
12
|
-
public readonly runId: string;
|
|
13
|
-
public readonly agentId: string;
|
|
14
|
-
public readonly channelId: string;
|
|
15
|
-
public threadId?: string;
|
|
16
|
-
private eventCallbacks: ((event: OpenBotEvent, state: OpenBotState) => Promise<void>)[] = [];
|
|
17
|
-
|
|
18
|
-
constructor(options: HarnessOptions) {
|
|
19
|
-
this.runId = options.runId;
|
|
20
|
-
this.agentId = options.agentId;
|
|
21
|
-
this.channelId = options.channelId;
|
|
22
|
-
this.threadId = options.threadId;
|
|
23
|
-
if (options.onEvent) {
|
|
24
|
-
this.eventCallbacks.push(options.onEvent);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Dispatches an event to the agent within this harness.
|
|
30
|
-
*/
|
|
31
|
-
async dispatch(event: OpenBotEvent): Promise<void> {
|
|
32
|
-
ensureEventId(event);
|
|
33
|
-
|
|
34
|
-
await orchestratorService.dispatch({
|
|
35
|
-
runId: this.runId,
|
|
36
|
-
agentId: this.agentId,
|
|
37
|
-
event,
|
|
38
|
-
channelId: this.channelId,
|
|
39
|
-
threadId: this.threadId,
|
|
40
|
-
onEvent: async (chunk, state) => {
|
|
41
|
-
// Update internal thread state if it changes (e.g. thread creation)
|
|
42
|
-
if (chunk.type === 'action:create_thread:result' && chunk.data.success) {
|
|
43
|
-
this.threadId = chunk.data.threadId || this.threadId;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Notify all observers
|
|
47
|
-
await Promise.all(this.eventCallbacks.map(cb => cb(chunk, state)));
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Adds an event listener to the harness.
|
|
54
|
-
*/
|
|
55
|
-
onEvent(callback: (event: OpenBotEvent, state: OpenBotState) => Promise<void>): void {
|
|
56
|
-
this.eventCallbacks.push(callback);
|
|
57
|
-
}
|
|
58
|
-
}
|