@g3un/pi-orchestra 0.1.1 → 0.2.0
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 +8 -6
- package/docs/orchestration-model.md +8 -4
- package/package.json +12 -1
- package/src/core/orchestra.ts +1 -195
- package/src/core/subagent.ts +8 -0
- package/src/extension/index.ts +33 -3
- package/src/extension/orchestra-events.ts +231 -0
- package/src/tools/bus.ts +9 -133
- package/src/tools/workflow.ts +36 -105
- package/src/tools/workgroup.ts +186 -71
- package/src/utils.ts +4 -14
package/README.md
CHANGED
|
@@ -15,9 +15,11 @@ pi -e npm:@g3un/pi-orchestra
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Pi-Orchestra registers four tools: `bus`, `subagent`, `workgroup`, and `workflow`.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Subagent, workgroup-member, and workflow completions are delivered back to the
|
|
19
|
+
main agent as pi-orchestra events, so the main conversation stays responsive
|
|
20
|
+
while delegated work runs. Active workflows are also shown in a TUI progress
|
|
21
|
+
widget with the current stage and agent completion counts. Use
|
|
22
|
+
`/orchestra-workflows` to reopen the widget if needed.
|
|
21
23
|
|
|
22
24
|
## Core concepts
|
|
23
25
|
|
|
@@ -29,7 +31,7 @@ Use subagents when you want to delegate a focused task, such as review, research
|
|
|
29
31
|
|
|
30
32
|
### Workgroup
|
|
31
33
|
|
|
32
|
-
A workgroup starts multiple subagents on the same bus for one shared goal. Each member can have a different profile or assignment.
|
|
34
|
+
A workgroup starts multiple subagents on the same bus for one shared goal. Each member can have a different profile or assignment. Member completions are delivered as `workgroup.member_finished` events with the strategy and pending run ids.
|
|
33
35
|
|
|
34
36
|
Workgroups support two strategies:
|
|
35
37
|
|
|
@@ -38,12 +40,12 @@ Workgroups support two strategies:
|
|
|
38
40
|
|
|
39
41
|
### Workflow
|
|
40
42
|
|
|
41
|
-
A workflow runs ordered workgroup stages. Each stage gets a fresh bus, starts its workers, collects results, and uses a stage leader to produce a canonical stage output.
|
|
43
|
+
A workflow runs ordered workgroup stages. Each stage gets a fresh bus, starts its workers, collects results through internal finish-event subscriptions, and uses a stage leader to produce a canonical stage output. The main agent receives a single `workflow.finished` event for the whole workflow.
|
|
42
44
|
|
|
43
45
|
Use workflows for multi-step plans where later stages should depend on the summarized output of earlier stages instead of raw worker transcripts.
|
|
44
46
|
|
|
45
47
|
## Notes
|
|
46
48
|
|
|
47
49
|
- Create a `bus` before spawning related subagents or workgroups.
|
|
48
|
-
- Subagents report completion with `success`, `blocked`, or `failed
|
|
50
|
+
- Subagents report completion with `success`, `blocked`, or `failed`; completions are surfaced as pi-orchestra events.
|
|
49
51
|
- Use `workflow` for linear staged work, not branching/DAG execution.
|
|
@@ -46,10 +46,12 @@ Strategies:
|
|
|
46
46
|
- `compete`: one successful member can be enough.
|
|
47
47
|
- `synthesize`: collect complementary findings and combine them.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Main receives finish events instead of blocking on completion calls:
|
|
50
50
|
|
|
51
|
-
-
|
|
52
|
-
- `
|
|
51
|
+
- Standalone subagent completions arrive as `subagent.finished` events.
|
|
52
|
+
- Workgroup member completions arrive as `workgroup.member_finished` events with the strategy and pending run ids.
|
|
53
|
+
- For `compete`, a successful member may be enough; close pending losers when appropriate.
|
|
54
|
+
- For `synthesize`, use each member event to decide whether to steer active members, spawn follow-up work, publish more context, or continue collecting results.
|
|
53
55
|
|
|
54
56
|
## Workflow
|
|
55
57
|
|
|
@@ -60,10 +62,12 @@ For each stage:
|
|
|
60
62
|
|
|
61
63
|
1. Create a fresh bus.
|
|
62
64
|
2. Spawn the worker workgroup.
|
|
63
|
-
3. Collect worker results.
|
|
65
|
+
3. Collect worker results through store finish-event subscriptions in the background.
|
|
64
66
|
4. Spawn a restricted stage leader.
|
|
65
67
|
5. Store the leader's canonical output as the stage output.
|
|
66
68
|
|
|
69
|
+
Workflow-internal worker and leader completions are consumed by the workflow runner. Main receives a single `workflow.finished` event when the whole workflow reaches `success`, `blocked`, `failed`, or `closed`.
|
|
70
|
+
|
|
67
71
|
The next stage receives the previous stage output, not raw worker transcripts.
|
|
68
72
|
If no leader is provided, `createStageLeaderProfile` supplies a restricted
|
|
69
73
|
leader with no tools.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g3un/pi-orchestra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Subagent orchestration tools for Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"orchestration",
|
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
"pi-package",
|
|
10
10
|
"subagent"
|
|
11
11
|
],
|
|
12
|
+
"homepage": "https://codeberg.org/g3un/pi-orchestra",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://codeberg.org/g3un/pi-orchestra/issues"
|
|
15
|
+
},
|
|
12
16
|
"license": "MIT",
|
|
13
17
|
"author": "Changeun Park <g3un@protonmail.ch>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://codeberg.org/g3un/pi-orchestra.git"
|
|
21
|
+
},
|
|
14
22
|
"files": [
|
|
15
23
|
"src/**/*.ts",
|
|
16
24
|
"!src/**/*.test.ts",
|
|
@@ -19,6 +27,9 @@
|
|
|
19
27
|
"LICENSE"
|
|
20
28
|
],
|
|
21
29
|
"type": "module",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
22
33
|
"scripts": {
|
|
23
34
|
"dev": "pi -e .",
|
|
24
35
|
"fmt": "oxfmt",
|
package/src/core/orchestra.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentProfile, AgentRun } from "./subagent.ts";
|
|
2
2
|
import type { Bus, BusMessage } from "./bus.ts";
|
|
3
3
|
import type { AgentRuntime } from "./runtime.ts";
|
|
4
|
-
import { createEntityIdentity
|
|
4
|
+
import { createEntityIdentity } from "../utils.ts";
|
|
5
5
|
import type { AgentStore } from "./store.ts";
|
|
6
6
|
|
|
7
7
|
export interface OrchestraApi {
|
|
@@ -14,8 +14,6 @@ export interface OrchestraApi {
|
|
|
14
14
|
listRuns(options?: ListRunsOptions): AgentRun[];
|
|
15
15
|
messageAgent(id: string, message: string, options?: RunLookupOptions): Promise<AgentRun>;
|
|
16
16
|
closeAgent(id: string, options?: RunLookupOptions): Promise<AgentRun | undefined>;
|
|
17
|
-
waitBusSettled(busId: string, options?: WaitBusSettledOptions): Promise<WaitBusSettledResult>;
|
|
18
|
-
waitNextRun(busId: string, options?: WaitNextRunOptions): Promise<WaitNextRunResult>;
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
export interface CreateBusOptions {
|
|
@@ -31,44 +29,6 @@ export interface PublishedBusMessage {
|
|
|
31
29
|
busMessage: BusMessage;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
export interface WaitBusSettledResult {
|
|
35
|
-
bus: Bus;
|
|
36
|
-
runs: AgentRun[];
|
|
37
|
-
runResults: WaitRunResult[];
|
|
38
|
-
timedOut: boolean;
|
|
39
|
-
pendingRunIds: string[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface WaitNextRunResult {
|
|
43
|
-
bus: Bus;
|
|
44
|
-
run?: AgentRun;
|
|
45
|
-
runResult?: WaitRunResult;
|
|
46
|
-
runs: AgentRun[];
|
|
47
|
-
runResults: WaitRunResult[];
|
|
48
|
-
timedOut: boolean;
|
|
49
|
-
pendingRunIds: string[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface WaitRunResult {
|
|
53
|
-
runId: string;
|
|
54
|
-
name: string;
|
|
55
|
-
profile: string;
|
|
56
|
-
state: AgentRun["state"];
|
|
57
|
-
result?: AgentRun["result"];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface WaitBusSettledOptions {
|
|
61
|
-
/** Defaults to 10 minutes. Use null to wait indefinitely. */
|
|
62
|
-
timeoutMs?: number | null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface WaitNextRunOptions {
|
|
66
|
-
/** Defaults to 10 minutes. Use null to wait indefinitely. */
|
|
67
|
-
timeoutMs?: number | null;
|
|
68
|
-
/** Run ids or names that have already been handled by the leader. */
|
|
69
|
-
excludeRunIds?: string[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
32
|
export interface ListRunsOptions {
|
|
73
33
|
busId?: string;
|
|
74
34
|
}
|
|
@@ -140,121 +100,6 @@ export class Orchestra implements OrchestraApi {
|
|
|
140
100
|
return await this.runtime.close(run?.id ?? id);
|
|
141
101
|
}
|
|
142
102
|
|
|
143
|
-
waitBusSettled(busId: string, options: WaitBusSettledOptions = {}): Promise<WaitBusSettledResult> {
|
|
144
|
-
const timeoutMs = resolveWaitTimeoutMs("waitBusSettled", options.timeoutMs);
|
|
145
|
-
const bus = this.requireBus(busId);
|
|
146
|
-
const initialRuns = this.listRuns({ busId: bus.id });
|
|
147
|
-
if (initialRuns.every((run) => isTerminalAgentState(run.state))) {
|
|
148
|
-
return Promise.resolve(buildWaitBusSettledResult(bus, initialRuns, false));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return new Promise((resolve) => {
|
|
152
|
-
const latestRuns = new Map(initialRuns.map((run) => [run.id, run]));
|
|
153
|
-
const unsubscribeAll: Array<() => void> = [];
|
|
154
|
-
let settled = false;
|
|
155
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
156
|
-
|
|
157
|
-
const cleanup = () => {
|
|
158
|
-
if (timeout) clearTimeout(timeout);
|
|
159
|
-
for (const unsubscribe of unsubscribeAll.splice(0)) unsubscribe();
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const getLatestRuns = () => initialRuns.map((run) => latestRuns.get(run.id) ?? run);
|
|
163
|
-
|
|
164
|
-
const resolveIfDone = () => {
|
|
165
|
-
const runs = getLatestRuns();
|
|
166
|
-
if (runs.some((run) => !isTerminalAgentState(run.state))) return;
|
|
167
|
-
|
|
168
|
-
settled = true;
|
|
169
|
-
cleanup();
|
|
170
|
-
resolve(buildWaitBusSettledResult(bus, runs, false));
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
if (timeoutMs !== null) {
|
|
174
|
-
timeout = setTimeout(() => {
|
|
175
|
-
if (settled) return;
|
|
176
|
-
|
|
177
|
-
settled = true;
|
|
178
|
-
cleanup();
|
|
179
|
-
resolve(buildWaitBusSettledResult(bus, getLatestRuns(), true));
|
|
180
|
-
}, timeoutMs);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
for (const run of initialRuns) {
|
|
184
|
-
if (isTerminalAgentState(run.state)) continue;
|
|
185
|
-
unsubscribeAll.push(
|
|
186
|
-
this.store.subscribeRuns(
|
|
187
|
-
(updatedRun) => {
|
|
188
|
-
if (settled) return;
|
|
189
|
-
latestRuns.set(updatedRun.id, updatedRun);
|
|
190
|
-
resolveIfDone();
|
|
191
|
-
},
|
|
192
|
-
(updatedRun) => updatedRun.id === run.id,
|
|
193
|
-
),
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
resolveIfDone();
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
waitNextRun(busId: string, options: WaitNextRunOptions = {}): Promise<WaitNextRunResult> {
|
|
202
|
-
const timeoutMs = resolveWaitTimeoutMs("waitNextRun", options.timeoutMs);
|
|
203
|
-
const bus = this.requireBus(busId);
|
|
204
|
-
const initialRuns = this.listRuns({ busId: bus.id });
|
|
205
|
-
const excludedRunIds = this.resolveExcludedRunIds(initialRuns, options.excludeRunIds ?? []);
|
|
206
|
-
const candidateRuns = initialRuns.filter((run) => !excludedRunIds.has(run.id));
|
|
207
|
-
const alreadyDone = candidateRuns.find((run) => isTerminalAgentState(run.state));
|
|
208
|
-
if (alreadyDone || candidateRuns.length === 0) {
|
|
209
|
-
return Promise.resolve(buildWaitNextRunResult(bus, initialRuns, alreadyDone, false, excludedRunIds));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return new Promise((resolve) => {
|
|
213
|
-
const latestRuns = new Map(initialRuns.map((run) => [run.id, run]));
|
|
214
|
-
const unsubscribeAll: Array<() => void> = [];
|
|
215
|
-
let settled = false;
|
|
216
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
-
|
|
218
|
-
const cleanup = () => {
|
|
219
|
-
if (timeout) clearTimeout(timeout);
|
|
220
|
-
for (const unsubscribe of unsubscribeAll.splice(0)) unsubscribe();
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const getLatestRuns = () => initialRuns.map((run) => latestRuns.get(run.id) ?? run);
|
|
224
|
-
|
|
225
|
-
const resolveWithRun = (run: AgentRun) => {
|
|
226
|
-
settled = true;
|
|
227
|
-
cleanup();
|
|
228
|
-
resolve(buildWaitNextRunResult(bus, getLatestRuns(), run, false, excludedRunIds));
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
if (timeoutMs !== null) {
|
|
232
|
-
timeout = setTimeout(() => {
|
|
233
|
-
if (settled) return;
|
|
234
|
-
|
|
235
|
-
settled = true;
|
|
236
|
-
cleanup();
|
|
237
|
-
resolve(buildWaitNextRunResult(bus, getLatestRuns(), undefined, true, excludedRunIds));
|
|
238
|
-
}, timeoutMs);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
for (const run of initialRuns) {
|
|
242
|
-
if (isTerminalAgentState(run.state)) continue;
|
|
243
|
-
unsubscribeAll.push(
|
|
244
|
-
this.store.subscribeRuns(
|
|
245
|
-
(updatedRun) => {
|
|
246
|
-
if (settled) return;
|
|
247
|
-
latestRuns.set(updatedRun.id, updatedRun);
|
|
248
|
-
if (!excludedRunIds.has(updatedRun.id) && isTerminalAgentState(updatedRun.state))
|
|
249
|
-
resolveWithRun(updatedRun);
|
|
250
|
-
},
|
|
251
|
-
(updatedRun) => updatedRun.id === run.id,
|
|
252
|
-
),
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
103
|
private requireBus(id: string): Bus {
|
|
259
104
|
const bus = this.findBus(id);
|
|
260
105
|
if (!bus) throw new Error(`Bus ${id} not found.`);
|
|
@@ -279,15 +124,6 @@ export class Orchestra implements OrchestraApi {
|
|
|
279
124
|
return this.store.listRuns().find((run) => run.name === id && (!bus || run.busId === bus.id));
|
|
280
125
|
}
|
|
281
126
|
|
|
282
|
-
private resolveExcludedRunIds(busRuns: AgentRun[], excludedRunIds: string[]): Set<string> {
|
|
283
|
-
const resolvedIds = new Set<string>();
|
|
284
|
-
for (const id of excludedRunIds) {
|
|
285
|
-
const run = busRuns.find((current) => current.id === id || current.name === id);
|
|
286
|
-
resolvedIds.add(run?.id ?? id);
|
|
287
|
-
}
|
|
288
|
-
return resolvedIds;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
127
|
private createBusIdentity(name: string | undefined) {
|
|
292
128
|
return createEntityIdentity(name, "bus", this.store.listBuses(), "Bus");
|
|
293
129
|
}
|
|
@@ -296,33 +132,3 @@ export class Orchestra implements OrchestraApi {
|
|
|
296
132
|
return createEntityIdentity(name, profile.name, this.store.listRuns(), "Agent");
|
|
297
133
|
}
|
|
298
134
|
}
|
|
299
|
-
|
|
300
|
-
function buildWaitBusSettledResult(bus: Bus, runs: AgentRun[], timedOut: boolean): WaitBusSettledResult {
|
|
301
|
-
return {
|
|
302
|
-
bus,
|
|
303
|
-
runs,
|
|
304
|
-
runResults: runs.map(toWaitRunResult),
|
|
305
|
-
timedOut,
|
|
306
|
-
pendingRunIds: runs.filter((run) => !isTerminalAgentState(run.state)).map((run) => run.id),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function buildWaitNextRunResult(
|
|
311
|
-
bus: Bus,
|
|
312
|
-
runs: AgentRun[],
|
|
313
|
-
run: AgentRun | undefined,
|
|
314
|
-
timedOut: boolean,
|
|
315
|
-
excludedRunIds: Set<string>,
|
|
316
|
-
): WaitNextRunResult {
|
|
317
|
-
return {
|
|
318
|
-
bus,
|
|
319
|
-
run,
|
|
320
|
-
runResult: run ? toWaitRunResult(run) : undefined,
|
|
321
|
-
runs,
|
|
322
|
-
runResults: runs.map(toWaitRunResult),
|
|
323
|
-
timedOut,
|
|
324
|
-
pendingRunIds: runs
|
|
325
|
-
.filter((current) => !excludedRunIds.has(current.id) && !isTerminalAgentState(current.state))
|
|
326
|
-
.map((current) => current.id),
|
|
327
|
-
};
|
|
328
|
-
}
|
package/src/core/subagent.ts
CHANGED
package/src/extension/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createBusTool, defineBusPiTool, type BusTool } from "../tools/bus.ts";
|
|
|
6
6
|
import { createSubagentTool, defineSubagentPiTool, type SubagentTool } from "../tools/subagent.ts";
|
|
7
7
|
import { createWorkflowTool, defineWorkflowPiTool, type WorkflowTool } from "../tools/workflow.ts";
|
|
8
8
|
import { createWorkgroupTool, defineWorkgroupPiTool, type WorkgroupTool } from "../tools/workgroup.ts";
|
|
9
|
+
import { ORCHESTRA_EVENT_CUSTOM_TYPE, OrchestraEventController, type OrchestraMainEvent } from "./orchestra-events.ts";
|
|
9
10
|
import { WorkflowMonitorController } from "./workflow-monitor.ts";
|
|
10
11
|
|
|
11
12
|
interface ToolBundle {
|
|
@@ -14,11 +15,12 @@ interface ToolBundle {
|
|
|
14
15
|
workgroupTool: WorkgroupTool;
|
|
15
16
|
workflowTool: WorkflowTool;
|
|
16
17
|
workflowMonitor: WorkflowMonitorController;
|
|
18
|
+
orchestraEvents: OrchestraEventController;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export default function piOrchestraExtension(pi: ExtensionAPI): void {
|
|
20
22
|
const bundles = new Map<string, ToolBundle>();
|
|
21
|
-
const getToolBundle = (ctx: ExtensionContext) => getBundle(bundles, ctx);
|
|
23
|
+
const getToolBundle = (ctx: ExtensionContext) => getBundle(pi, bundles, ctx);
|
|
22
24
|
|
|
23
25
|
pi.registerTool(defineBusPiTool((ctx) => getToolBundle(ctx).busTool));
|
|
24
26
|
pi.registerTool(defineSubagentPiTool((ctx) => getToolBundle(ctx).subagentTool));
|
|
@@ -44,7 +46,7 @@ export default function piOrchestraExtension(pi: ExtensionAPI): void {
|
|
|
44
46
|
});
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
function getBundle(bundles: Map<string, ToolBundle>, ctx: ExtensionContext): ToolBundle {
|
|
49
|
+
function getBundle(pi: ExtensionAPI, bundles: Map<string, ToolBundle>, ctx: ExtensionContext): ToolBundle {
|
|
48
50
|
const existing = bundles.get(ctx.cwd);
|
|
49
51
|
if (existing) return existing;
|
|
50
52
|
|
|
@@ -55,17 +57,45 @@ function getBundle(bundles: Map<string, ToolBundle>, ctx: ExtensionContext): Too
|
|
|
55
57
|
resolveModel: (model) => resolveModel(ctx, model),
|
|
56
58
|
});
|
|
57
59
|
const orchestra = new Orchestra({ runtime, store });
|
|
60
|
+
const orchestraEvents = new OrchestraEventController({
|
|
61
|
+
store,
|
|
62
|
+
sendEvents: (events, content) => sendOrchestraEvents(pi, events, content),
|
|
63
|
+
});
|
|
64
|
+
const workgroupTool = createWorkgroupTool({
|
|
65
|
+
orchestra,
|
|
66
|
+
onWorkgroupLaunching: ({ input, bus }) => orchestraEvents.beginWorkgroup(bus.id, input.strategy),
|
|
67
|
+
onWorkgroupLaunched: ({ input, output }) =>
|
|
68
|
+
orchestraEvents.registerWorkgroup({
|
|
69
|
+
busId: output.bus.id,
|
|
70
|
+
strategy: input.strategy,
|
|
71
|
+
runIds: output.runs.map((run) => run.id),
|
|
72
|
+
}),
|
|
73
|
+
onWorkgroupLaunchFailed: ({ bus }) => orchestraEvents.cancelWorkgroupLaunch(bus.id),
|
|
74
|
+
});
|
|
58
75
|
const bundle = {
|
|
59
76
|
busTool: createBusTool({ orchestra }),
|
|
60
77
|
subagentTool: createSubagentTool({ orchestra }),
|
|
61
|
-
workgroupTool
|
|
78
|
+
workgroupTool,
|
|
62
79
|
workflowTool: createWorkflowTool({ orchestra, store }),
|
|
63
80
|
workflowMonitor: new WorkflowMonitorController(store),
|
|
81
|
+
orchestraEvents,
|
|
64
82
|
};
|
|
65
83
|
bundles.set(ctx.cwd, bundle);
|
|
66
84
|
return bundle;
|
|
67
85
|
}
|
|
68
86
|
|
|
87
|
+
function sendOrchestraEvents(pi: ExtensionAPI, events: OrchestraMainEvent[], content: string): void {
|
|
88
|
+
pi.sendMessage(
|
|
89
|
+
{
|
|
90
|
+
customType: ORCHESTRA_EVENT_CUSTOM_TYPE,
|
|
91
|
+
content,
|
|
92
|
+
display: true,
|
|
93
|
+
details: { events },
|
|
94
|
+
},
|
|
95
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
function resolveModel(ctx: ExtensionContext, model: string): ReturnType<ExtensionContext["modelRegistry"]["find"]> {
|
|
70
100
|
const slashIndex = model.indexOf("/");
|
|
71
101
|
if (slashIndex < 0) {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { AgentRun, AgentRunResult, AgentState } from "../core/subagent.ts";
|
|
2
|
+
import type { AgentStore } from "../core/store.ts";
|
|
3
|
+
import type { WorkflowRun } from "../core/workflow.ts";
|
|
4
|
+
import type { WorkgroupStrategy } from "../core/workgroup.ts";
|
|
5
|
+
import { formatNamedEntityLabel, isTerminalAgentState, toAgentRunResult } from "../utils.ts";
|
|
6
|
+
|
|
7
|
+
export const ORCHESTRA_EVENT_CUSTOM_TYPE = "pi-orchestra.event";
|
|
8
|
+
|
|
9
|
+
export type OrchestraMainEvent =
|
|
10
|
+
| {
|
|
11
|
+
type: "subagent.finished";
|
|
12
|
+
busId: string;
|
|
13
|
+
run: AgentRunResult;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
type: "workgroup.member_finished";
|
|
17
|
+
busId: string;
|
|
18
|
+
strategy: WorkgroupStrategy;
|
|
19
|
+
run: AgentRunResult;
|
|
20
|
+
pendingRunIds: string[];
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "workflow.finished";
|
|
24
|
+
workflow: WorkflowRun;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface WorkgroupRegistration {
|
|
28
|
+
busId: string;
|
|
29
|
+
strategy: WorkgroupStrategy;
|
|
30
|
+
runIds: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OrchestraEventControllerOptions {
|
|
34
|
+
store: AgentStore;
|
|
35
|
+
sendEvents: (events: OrchestraMainEvent[], content: string) => void;
|
|
36
|
+
/** Defaults to 50 ms. Use 0 in tests for immediate delivery. */
|
|
37
|
+
flushDelayMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RegisteredWorkgroup {
|
|
41
|
+
strategy: WorkgroupStrategy;
|
|
42
|
+
runIds: Set<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface LaunchingWorkgroup {
|
|
46
|
+
strategy: WorkgroupStrategy;
|
|
47
|
+
finishedRunIds: Set<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class OrchestraEventController {
|
|
51
|
+
private readonly store: AgentStore;
|
|
52
|
+
private readonly sendEvents: OrchestraEventControllerOptions["sendEvents"];
|
|
53
|
+
private readonly flushDelayMs: number;
|
|
54
|
+
private readonly runStates = new Map<string, AgentState>();
|
|
55
|
+
private readonly workflowStates = new Map<string, AgentState>();
|
|
56
|
+
private readonly mainWorkgroupsByBusId = new Map<string, RegisteredWorkgroup>();
|
|
57
|
+
private readonly launchingWorkgroupsByBusId = new Map<string, LaunchingWorkgroup>();
|
|
58
|
+
private readonly queuedEvents: OrchestraMainEvent[] = [];
|
|
59
|
+
private readonly unsubscribeRuns: () => void;
|
|
60
|
+
private readonly unsubscribeWorkflows: () => void;
|
|
61
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(options: OrchestraEventControllerOptions) {
|
|
64
|
+
this.store = options.store;
|
|
65
|
+
this.sendEvents = options.sendEvents;
|
|
66
|
+
this.flushDelayMs = options.flushDelayMs ?? 50;
|
|
67
|
+
|
|
68
|
+
for (const run of this.store.listRuns()) this.runStates.set(run.id, run.state);
|
|
69
|
+
for (const workflow of this.store.listWorkflows()) this.workflowStates.set(workflow.id, workflow.state);
|
|
70
|
+
|
|
71
|
+
this.unsubscribeRuns = this.store.subscribeRuns((run) => this.handleRunSaved(run));
|
|
72
|
+
this.unsubscribeWorkflows = this.store.subscribeWorkflows((workflow) => this.handleWorkflowSaved(workflow));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beginWorkgroup(busId: string, strategy: WorkgroupStrategy): void {
|
|
76
|
+
this.launchingWorkgroupsByBusId.set(busId, { strategy, finishedRunIds: new Set() });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
registerWorkgroup(registration: WorkgroupRegistration): void {
|
|
80
|
+
const launching = this.launchingWorkgroupsByBusId.get(registration.busId);
|
|
81
|
+
const existing = this.mainWorkgroupsByBusId.get(registration.busId);
|
|
82
|
+
const runIds = new Set<string>(existing?.runIds);
|
|
83
|
+
for (const runId of launching?.finishedRunIds ?? []) runIds.add(runId);
|
|
84
|
+
for (const runId of registration.runIds) runIds.add(runId);
|
|
85
|
+
|
|
86
|
+
this.mainWorkgroupsByBusId.set(registration.busId, {
|
|
87
|
+
strategy: registration.strategy,
|
|
88
|
+
runIds,
|
|
89
|
+
});
|
|
90
|
+
this.launchingWorkgroupsByBusId.delete(registration.busId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cancelWorkgroupLaunch(busId: string): void {
|
|
94
|
+
this.launchingWorkgroupsByBusId.delete(busId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
dispose(): void {
|
|
98
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
99
|
+
this.flushTimer = undefined;
|
|
100
|
+
this.queuedEvents.length = 0;
|
|
101
|
+
this.unsubscribeRuns();
|
|
102
|
+
this.unsubscribeWorkflows();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
flush(): void {
|
|
106
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
107
|
+
this.flushTimer = undefined;
|
|
108
|
+
if (this.queuedEvents.length === 0) return;
|
|
109
|
+
|
|
110
|
+
const events = this.queuedEvents.splice(0);
|
|
111
|
+
this.sendEvents(events, formatOrchestraEvents(events));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private handleRunSaved(run: AgentRun): void {
|
|
115
|
+
const previousState = this.runStates.get(run.id);
|
|
116
|
+
this.runStates.set(run.id, run.state);
|
|
117
|
+
|
|
118
|
+
if (!isAgentFinishState(run.state) || isAgentFinishState(previousState)) return;
|
|
119
|
+
if (this.isWorkflowBus(run.busId)) return;
|
|
120
|
+
|
|
121
|
+
const registeredWorkgroup = this.mainWorkgroupsByBusId.get(run.busId);
|
|
122
|
+
if (registeredWorkgroup?.runIds.has(run.id)) {
|
|
123
|
+
this.queueEvent({
|
|
124
|
+
type: "workgroup.member_finished",
|
|
125
|
+
busId: run.busId,
|
|
126
|
+
strategy: registeredWorkgroup.strategy,
|
|
127
|
+
run: toAgentRunResult(run),
|
|
128
|
+
pendingRunIds: this.getPendingWorkgroupRunIds(run.busId, registeredWorkgroup.runIds),
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const launchingWorkgroup = this.launchingWorkgroupsByBusId.get(run.busId);
|
|
134
|
+
if (launchingWorkgroup) {
|
|
135
|
+
launchingWorkgroup.finishedRunIds.add(run.id);
|
|
136
|
+
this.queueEvent({
|
|
137
|
+
type: "workgroup.member_finished",
|
|
138
|
+
busId: run.busId,
|
|
139
|
+
strategy: launchingWorkgroup.strategy,
|
|
140
|
+
run: toAgentRunResult(run),
|
|
141
|
+
pendingRunIds: this.getPendingBusRunIds(run.busId),
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.queueEvent({ type: "subagent.finished", busId: run.busId, run: toAgentRunResult(run) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private handleWorkflowSaved(workflow: WorkflowRun): void {
|
|
150
|
+
const previousState = this.workflowStates.get(workflow.id);
|
|
151
|
+
this.workflowStates.set(workflow.id, workflow.state);
|
|
152
|
+
|
|
153
|
+
if (!isTerminalAgentState(workflow.state) || isTerminalWorkflowState(previousState)) return;
|
|
154
|
+
this.queueEvent({ type: "workflow.finished", workflow });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private isWorkflowBus(busId: string): boolean {
|
|
158
|
+
return this.store.listWorkflows().some((workflow) => workflow.stages.some((stage) => stage.busId === busId));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private getPendingWorkgroupRunIds(busId: string, runIds: Set<string>): string[] {
|
|
162
|
+
return this.store
|
|
163
|
+
.listRuns()
|
|
164
|
+
.filter((run) => run.busId === busId && runIds.has(run.id) && run.state === "idle")
|
|
165
|
+
.map((run) => run.id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private getPendingBusRunIds(busId: string): string[] {
|
|
169
|
+
return this.store
|
|
170
|
+
.listRuns()
|
|
171
|
+
.filter((run) => run.busId === busId && run.state === "idle")
|
|
172
|
+
.map((run) => run.id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private queueEvent(event: OrchestraMainEvent): void {
|
|
176
|
+
this.queuedEvents.push(event);
|
|
177
|
+
if (this.flushDelayMs === 0) {
|
|
178
|
+
this.flush();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.flushTimer ??= setTimeout(() => this.flush(), this.flushDelayMs);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function formatOrchestraEvents(events: OrchestraMainEvent[]): string {
|
|
187
|
+
const headline = events.length === 1 ? "Pi-orchestra event:" : `Pi-orchestra events (${events.length}):`;
|
|
188
|
+
return [headline, ...events.flatMap((event) => ["", formatOrchestraEvent(event)])].join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatOrchestraEvent(event: OrchestraMainEvent): string {
|
|
192
|
+
if (event.type === "workflow.finished") return formatWorkflowFinishedEvent(event.workflow);
|
|
193
|
+
|
|
194
|
+
const runLabel = event.run.name === event.run.runId ? event.run.runId : `${event.run.name} (${event.run.runId})`;
|
|
195
|
+
const lines =
|
|
196
|
+
event.type === "workgroup.member_finished"
|
|
197
|
+
? [
|
|
198
|
+
`- Workgroup member finished on bus ${event.busId}: ${runLabel} is ${event.run.state}.`,
|
|
199
|
+
` Strategy: ${event.strategy}`,
|
|
200
|
+
` Pending workgroup run ids: ${event.pendingRunIds.length > 0 ? event.pendingRunIds.join(", ") : "none"}`,
|
|
201
|
+
]
|
|
202
|
+
: [`- Subagent finished on bus ${event.busId}: ${runLabel} is ${event.run.state}.`];
|
|
203
|
+
|
|
204
|
+
lines.push(...formatRunResultLines(event.run));
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatWorkflowFinishedEvent(workflow: WorkflowRun): string {
|
|
209
|
+
const lines = [`- Workflow finished: ${formatNamedEntityLabel(workflow)} is ${workflow.state}.`];
|
|
210
|
+
if (workflow.result) {
|
|
211
|
+
lines.push(` Result: ${workflow.result.status}`, ` Summary: ${workflow.result.summary}`);
|
|
212
|
+
}
|
|
213
|
+
if (workflow.error) lines.push(` Error: ${workflow.error}`);
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatRunResultLines(run: AgentRunResult): string[] {
|
|
218
|
+
if (!run.result) return [" No result payload recorded."];
|
|
219
|
+
|
|
220
|
+
const lines = [` Result: ${run.result.status}`, ` Summary: ${run.result.summary}`];
|
|
221
|
+
if (run.result.data !== undefined) lines.push(` Data: ${JSON.stringify(run.result.data)}`);
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isAgentFinishState(state: AgentState | undefined): boolean {
|
|
226
|
+
return state === "success" || state === "blocked" || state === "failed";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isTerminalWorkflowState(state: AgentState | undefined): boolean {
|
|
230
|
+
return state !== undefined && isTerminalAgentState(state);
|
|
231
|
+
}
|