@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/src/tools/workgroup.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { AgentProfile, AgentRun } from "../core/subagent.ts";
|
|
3
|
+
import type { AgentProfile, AgentResultStatus, AgentRun, AgentRunResult } from "../core/subagent.ts";
|
|
4
4
|
import type { Bus } from "../core/bus.ts";
|
|
5
|
-
import type {
|
|
6
|
-
import type {
|
|
5
|
+
import type { OrchestraApi } from "../core/orchestra.ts";
|
|
6
|
+
import type { AgentStore } from "../core/store.ts";
|
|
7
7
|
import { WORKGROUP_STRATEGY_VALUES, type WorkgroupMember, type WorkgroupStrategy } from "../core/workgroup.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
closeAgentRuns,
|
|
10
|
+
formatError,
|
|
11
|
+
formatNamedEntityLabel,
|
|
12
|
+
isTerminalAgentState,
|
|
13
|
+
normalizeEntityName,
|
|
14
|
+
slugify,
|
|
15
|
+
toAgentRunResult,
|
|
16
|
+
} from "../utils.ts";
|
|
9
17
|
import {
|
|
10
18
|
AgentProfileParams,
|
|
11
19
|
spawnSubagent,
|
|
@@ -33,10 +41,10 @@ export interface WorkgroupSettlement {
|
|
|
33
41
|
strategy: WorkgroupStrategy;
|
|
34
42
|
status: AgentResultStatus;
|
|
35
43
|
/** Results that should be consumed by downstream orchestration. For compete, this is the winning result when present. */
|
|
36
|
-
workerResults:
|
|
44
|
+
workerResults: AgentRunResult[];
|
|
37
45
|
/** Every terminal result observed while settling this workgroup. */
|
|
38
|
-
completedResults:
|
|
39
|
-
winner?:
|
|
46
|
+
completedResults: AgentRunResult[];
|
|
47
|
+
winner?: AgentRunResult;
|
|
40
48
|
pendingRunIds: string[];
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -45,8 +53,25 @@ export interface WorkgroupTool {
|
|
|
45
53
|
execute(input: WorkgroupInput): Promise<WorkgroupOutput>;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
export interface WorkgroupLaunchEvent {
|
|
57
|
+
input: WorkgroupInput;
|
|
58
|
+
bus: Bus;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WorkgroupLaunchedEvent {
|
|
62
|
+
input: WorkgroupInput;
|
|
63
|
+
output: WorkgroupOutput;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WorkgroupLaunchFailedEvent extends WorkgroupLaunchEvent {
|
|
67
|
+
error: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export interface WorkgroupToolDeps {
|
|
49
71
|
orchestra: OrchestraApi;
|
|
72
|
+
onWorkgroupLaunching?: (event: WorkgroupLaunchEvent) => void;
|
|
73
|
+
onWorkgroupLaunched?: (event: WorkgroupLaunchedEvent) => void;
|
|
74
|
+
onWorkgroupLaunchFailed?: (event: WorkgroupLaunchFailedEvent) => void;
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
export const WorkgroupMemberParams = Type.Object(
|
|
@@ -100,7 +125,12 @@ interface SpawnFailure {
|
|
|
100
125
|
error: unknown;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
export function createWorkgroupTool({
|
|
128
|
+
export function createWorkgroupTool({
|
|
129
|
+
orchestra,
|
|
130
|
+
onWorkgroupLaunching,
|
|
131
|
+
onWorkgroupLaunched,
|
|
132
|
+
onWorkgroupLaunchFailed,
|
|
133
|
+
}: WorkgroupToolDeps): WorkgroupTool {
|
|
104
134
|
return {
|
|
105
135
|
name: "workgroup",
|
|
106
136
|
|
|
@@ -110,51 +140,52 @@ export function createWorkgroupTool({ orchestra }: WorkgroupToolDeps): Workgroup
|
|
|
110
140
|
const bus = orchestra.getBus(input.busId);
|
|
111
141
|
if (!bus) throw new Error(`Bus ${input.busId} not found.`);
|
|
112
142
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const successes = collectSpawnSuccesses(spawnResults);
|
|
125
|
-
const failures = collectSpawnFailures(preparedInput.members, spawnResults);
|
|
126
|
-
if (failures.length > 0) {
|
|
127
|
-
const cleanupResults = await Promise.allSettled(
|
|
128
|
-
successes.map((success) => orchestra.closeAgent(success.run.id)),
|
|
143
|
+
onWorkgroupLaunching?.({ input, bus });
|
|
144
|
+
try {
|
|
145
|
+
const preparedInput: PreparedWorkgroupInput = {
|
|
146
|
+
...input,
|
|
147
|
+
members: prepareMembers(input.members, orchestra.listRuns()),
|
|
148
|
+
};
|
|
149
|
+
const spawnResults = await Promise.allSettled(
|
|
150
|
+
preparedInput.members.map(async (member): Promise<SpawnSuccess> => {
|
|
151
|
+
const run = await spawnSubagent(orchestra, toSubagentSpawnInput(preparedInput, member, bus.id));
|
|
152
|
+
return { member, run };
|
|
153
|
+
}),
|
|
129
154
|
);
|
|
130
|
-
throw new Error(formatLaunchFailure(failures, successes, cleanupResults));
|
|
131
|
-
}
|
|
132
155
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
const successes = collectSpawnSuccesses(spawnResults);
|
|
157
|
+
const failures = collectSpawnFailures(preparedInput.members, spawnResults);
|
|
158
|
+
if (failures.length > 0) {
|
|
159
|
+
const cleanupResults = await Promise.allSettled(
|
|
160
|
+
successes.map((success) => orchestra.closeAgent(success.run.id)),
|
|
161
|
+
);
|
|
162
|
+
throw new Error(formatLaunchFailure(failures, successes, cleanupResults));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const runs = successes.map((success) => success.run);
|
|
166
|
+
const output = {
|
|
167
|
+
bus,
|
|
168
|
+
runs,
|
|
169
|
+
message: formatWorkgroupMessage(bus, preparedInput, runs),
|
|
170
|
+
};
|
|
171
|
+
onWorkgroupLaunched?.({ input, output });
|
|
172
|
+
return output;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
onWorkgroupLaunchFailed?.({ input, bus, error });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
139
177
|
},
|
|
140
178
|
};
|
|
141
179
|
}
|
|
142
180
|
|
|
143
181
|
export async function settleWorkgroupRuns(
|
|
144
182
|
orchestra: OrchestraApi,
|
|
183
|
+
store: AgentStore,
|
|
145
184
|
busId: string,
|
|
185
|
+
runIds: string[],
|
|
146
186
|
strategy: WorkgroupStrategy,
|
|
147
187
|
): Promise<WorkgroupSettlement> {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const settled = await orchestra.waitBusSettled(busId, { timeoutMs: null });
|
|
151
|
-
return {
|
|
152
|
-
strategy,
|
|
153
|
-
status: resolveWorkgroupStatus(settled.runResults),
|
|
154
|
-
workerResults: settled.runResults,
|
|
155
|
-
completedResults: settled.runResults,
|
|
156
|
-
pendingRunIds: settled.pendingRunIds,
|
|
157
|
-
};
|
|
188
|
+
return await new WorkgroupSettlementCollector(orchestra, store, busId, runIds, strategy).settle();
|
|
158
189
|
}
|
|
159
190
|
|
|
160
191
|
export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => WorkgroupTool) {
|
|
@@ -162,11 +193,11 @@ export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => Wo
|
|
|
162
193
|
name: "workgroup",
|
|
163
194
|
label: "Workgroup",
|
|
164
195
|
description: "Spawn multiple subagents onto an existing bus; you lead and collect results.",
|
|
165
|
-
promptSnippet: "Spawn a main-led workgroup on an existing bus
|
|
196
|
+
promptSnippet: "Spawn a main-led workgroup on an existing bus; member finish events are delivered automatically.",
|
|
166
197
|
promptGuidelines: [
|
|
167
198
|
"Create a bus first; workgroup only spawns members.",
|
|
168
|
-
"Use compete when one successful member is enough;
|
|
169
|
-
"Use synthesize when members provide complementary findings to
|
|
199
|
+
"Use workgroup compete when one successful member is enough; close losers after a success event if appropriate.",
|
|
200
|
+
"Use workgroup synthesize when members provide complementary findings; react to member finish events as they arrive.",
|
|
170
201
|
"publish_bus is peer-reference context, not a leader-request channel.",
|
|
171
202
|
],
|
|
172
203
|
parameters: WorkgroupToolParams,
|
|
@@ -183,39 +214,123 @@ export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => Wo
|
|
|
183
214
|
});
|
|
184
215
|
}
|
|
185
216
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
217
|
+
class WorkgroupSettlementCollector {
|
|
218
|
+
private readonly runIds: Set<string>;
|
|
219
|
+
private readonly completedRunIds = new Set<string>();
|
|
220
|
+
private readonly completedResults: AgentRunResult[] = [];
|
|
221
|
+
|
|
222
|
+
constructor(
|
|
223
|
+
private readonly orchestra: OrchestraApi,
|
|
224
|
+
private readonly store: AgentStore,
|
|
225
|
+
private readonly busId: string,
|
|
226
|
+
runIds: string[],
|
|
227
|
+
private readonly strategy: WorkgroupStrategy,
|
|
228
|
+
) {
|
|
229
|
+
this.runIds = new Set(runIds);
|
|
230
|
+
}
|
|
189
231
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
232
|
+
settle(): Promise<WorkgroupSettlement> {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let settled = false;
|
|
235
|
+
let unsubscribe: () => void = () => undefined;
|
|
236
|
+
|
|
237
|
+
const finish = (settlement: WorkgroupSettlement) => {
|
|
238
|
+
if (settled) return;
|
|
239
|
+
|
|
240
|
+
settled = true;
|
|
241
|
+
unsubscribe();
|
|
242
|
+
resolve(settlement);
|
|
199
243
|
};
|
|
200
|
-
}
|
|
201
244
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
245
|
+
const finishWithWinner = (winner: AgentRunResult) => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
|
|
248
|
+
settled = true;
|
|
249
|
+
unsubscribe();
|
|
250
|
+
void closeAgentRuns(this.orchestra, this.getPendingRunIds()).finally(() => {
|
|
251
|
+
resolve({
|
|
252
|
+
strategy: this.strategy,
|
|
253
|
+
status: "success",
|
|
254
|
+
workerResults: [winner],
|
|
255
|
+
completedResults: this.completedResults,
|
|
256
|
+
winner,
|
|
257
|
+
pendingRunIds: [],
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const finishFromCurrentState = () => {
|
|
263
|
+
this.captureTerminalRuns();
|
|
264
|
+
|
|
265
|
+
const winner = this.strategy === "compete" ? this.completedResults.find(isSuccessfulRunResult) : undefined;
|
|
266
|
+
if (winner) {
|
|
267
|
+
finishWithWinner(winner);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!this.isSettled()) return;
|
|
272
|
+
|
|
273
|
+
finish({
|
|
274
|
+
strategy: this.strategy,
|
|
275
|
+
status: resolveWorkgroupStatus(this.completedResults),
|
|
276
|
+
workerResults: this.completedResults,
|
|
277
|
+
completedResults: this.completedResults,
|
|
278
|
+
pendingRunIds: this.getPendingRunIds(),
|
|
279
|
+
});
|
|
213
280
|
};
|
|
281
|
+
|
|
282
|
+
const observeRun = (run: AgentRun) => {
|
|
283
|
+
if (settled || !this.runIds.has(run.id) || !isTerminalAgentState(run.state)) return;
|
|
284
|
+
this.recordTerminalRun(run);
|
|
285
|
+
finishFromCurrentState();
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
unsubscribe = this.store.subscribeRuns(observeRun, (run) => run.busId === this.busId && this.runIds.has(run.id));
|
|
289
|
+
finishFromCurrentState();
|
|
290
|
+
|
|
291
|
+
if (!settled && this.runIds.size === 0) {
|
|
292
|
+
finish({
|
|
293
|
+
strategy: this.strategy,
|
|
294
|
+
status: "failed",
|
|
295
|
+
workerResults: [],
|
|
296
|
+
completedResults: [],
|
|
297
|
+
pendingRunIds: [],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private captureTerminalRuns(): void {
|
|
304
|
+
for (const runId of this.runIds) {
|
|
305
|
+
const run = this.store.getRun(runId);
|
|
306
|
+
if (run) this.recordTerminalRun(run);
|
|
214
307
|
}
|
|
215
308
|
}
|
|
309
|
+
|
|
310
|
+
private recordTerminalRun(run: AgentRun): void {
|
|
311
|
+
if (!isTerminalAgentState(run.state) || this.completedRunIds.has(run.id)) return;
|
|
312
|
+
|
|
313
|
+
this.completedRunIds.add(run.id);
|
|
314
|
+
this.completedResults.push(toAgentRunResult(run));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private isSettled(): boolean {
|
|
318
|
+
return [...this.runIds].every((runId) => {
|
|
319
|
+
const run = this.store.getRun(runId);
|
|
320
|
+
return run !== undefined && isTerminalAgentState(run.state);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private getPendingRunIds(): string[] {
|
|
325
|
+
return [...this.runIds].filter((runId) => this.store.getRun(runId)?.state === "idle");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isSuccessfulRunResult(result: AgentRunResult): boolean {
|
|
330
|
+
return result.result?.status === "success";
|
|
216
331
|
}
|
|
217
332
|
|
|
218
|
-
function resolveWorkgroupStatus(results:
|
|
333
|
+
function resolveWorkgroupStatus(results: AgentRunResult[]): AgentResultStatus {
|
|
219
334
|
const statuses = results.map((result) => result.result?.status);
|
|
220
335
|
if (statuses.includes("success")) return "success";
|
|
221
336
|
if (statuses.includes("blocked")) return "blocked";
|
|
@@ -404,7 +519,7 @@ function formatWorkgroupMessage(bus: Bus, input: PreparedWorkgroupInput, runs: A
|
|
|
404
519
|
"Runs:",
|
|
405
520
|
...runs.map((run) => `- ${formatNamedEntityLabel(run)}: ${run.state}`),
|
|
406
521
|
"",
|
|
407
|
-
"
|
|
522
|
+
"Pi-orchestra will deliver workgroup.member_finished events as members finish.",
|
|
408
523
|
].join("\n");
|
|
409
524
|
}
|
|
410
525
|
|
package/src/utils.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type { OrchestraApi
|
|
2
|
-
import type { AgentRun, AgentState } from "./core/subagent.ts";
|
|
1
|
+
import type { OrchestraApi } from "./core/orchestra.ts";
|
|
2
|
+
import type { AgentRun, AgentRunResult, AgentState } from "./core/subagent.ts";
|
|
3
3
|
import type { AgentStore } from "./core/store.ts";
|
|
4
4
|
import type { WorkflowRun } from "./core/workflow.ts";
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
-
|
|
8
6
|
export interface NamedEntity {
|
|
9
7
|
id: string;
|
|
10
8
|
name: string;
|
|
@@ -52,14 +50,6 @@ export function formatNamedEntityLabel(entity: NamedEntity): string {
|
|
|
52
50
|
return entity.name === entity.id ? entity.id : `${entity.name} (${entity.id})`;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
export function resolveWaitTimeoutMs(label: string, timeoutMs: number | null | undefined): number | null {
|
|
56
|
-
const resolvedTimeoutMs = timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : timeoutMs;
|
|
57
|
-
if (resolvedTimeoutMs !== null && (!Number.isFinite(resolvedTimeoutMs) || resolvedTimeoutMs <= 0)) {
|
|
58
|
-
throw new Error(`${label} timeoutMs must be positive, or null to wait indefinitely.`);
|
|
59
|
-
}
|
|
60
|
-
return resolvedTimeoutMs;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
53
|
export function findWorkflow(store: AgentStore, id: string): WorkflowRun | undefined {
|
|
64
54
|
return store.getWorkflow(id) ?? store.listWorkflows().find((workflow) => workflow.name === id);
|
|
65
55
|
}
|
|
@@ -85,8 +75,8 @@ export function formatError(error: unknown): string {
|
|
|
85
75
|
return error instanceof Error ? error.message : String(error);
|
|
86
76
|
}
|
|
87
77
|
|
|
88
|
-
export function
|
|
89
|
-
const runResult:
|
|
78
|
+
export function toAgentRunResult(run: AgentRun): AgentRunResult {
|
|
79
|
+
const runResult: AgentRunResult = {
|
|
90
80
|
runId: run.id,
|
|
91
81
|
name: run.name,
|
|
92
82
|
profile: run.profile,
|