@g3un/pi-orchestra 0.1.0 → 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 -3
- package/docs/orchestration-model.md +8 -4
- package/package.json +12 -1
- package/src/adapters/in-memory-store.ts +25 -21
- package/src/core/orchestra.ts +1 -189
- package/src/core/store.ts +5 -2
- package/src/core/subagent.ts +8 -0
- package/src/extension/index.ts +55 -4
- package/src/extension/orchestra-events.ts +231 -0
- package/src/extension/workflow-monitor.ts +143 -0
- package/src/tools/bus.ts +9 -133
- package/src/tools/workflow.ts +53 -109
- package/src/tools/workgroup.ts +186 -71
- package/src/utils.ts +4 -14
package/src/tools/workflow.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { AgentResultStatus, AgentRun } from "../core/subagent.ts";
|
|
4
|
-
import type { OrchestraApi
|
|
3
|
+
import type { AgentResultStatus, AgentRun, AgentRunResult } from "../core/subagent.ts";
|
|
4
|
+
import type { OrchestraApi } from "../core/orchestra.ts";
|
|
5
5
|
import type { AgentStore } from "../core/store.ts";
|
|
6
6
|
import type { WorkflowRun, WorkflowStageOutput, WorkflowStageRun, WorkflowStageSpec } from "../core/workflow.ts";
|
|
7
7
|
import { WORKGROUP_STRATEGY_VALUES, type WorkgroupMember, type WorkgroupStrategy } from "../core/workgroup.ts";
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
isTerminalAgentState,
|
|
16
16
|
normalizeEntityName,
|
|
17
17
|
requireWorkflow,
|
|
18
|
-
resolveWaitTimeoutMs,
|
|
19
18
|
} from "../utils.ts";
|
|
20
19
|
import {
|
|
21
20
|
createWorkgroupTool,
|
|
@@ -41,17 +40,10 @@ export type WorkflowInput =
|
|
|
41
40
|
| {
|
|
42
41
|
action: "cancel";
|
|
43
42
|
id: string;
|
|
44
|
-
}
|
|
45
|
-
| {
|
|
46
|
-
action: "wait";
|
|
47
|
-
id: string;
|
|
48
|
-
/** Defaults to 10 minutes. Use null to wait indefinitely. */
|
|
49
|
-
timeoutMs?: number | null;
|
|
50
43
|
};
|
|
51
44
|
|
|
52
45
|
export interface WorkflowOutput {
|
|
53
46
|
workflow?: WorkflowRun;
|
|
54
|
-
timedOut?: boolean;
|
|
55
47
|
message: string;
|
|
56
48
|
}
|
|
57
49
|
|
|
@@ -65,6 +57,11 @@ export interface WorkflowToolDeps {
|
|
|
65
57
|
store: AgentStore;
|
|
66
58
|
}
|
|
67
59
|
|
|
60
|
+
export interface WorkflowPiToolOptions {
|
|
61
|
+
onWorkflowInput?: (ctx: ExtensionContext, input: WorkflowInput) => void;
|
|
62
|
+
onWorkflowOutput?: (ctx: ExtensionContext, output: WorkflowOutput) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
68
65
|
const WorkflowStageParams = Type.Object(
|
|
69
66
|
{
|
|
70
67
|
name: Type.String({
|
|
@@ -87,8 +84,8 @@ const WorkflowStageParams = Type.Object(
|
|
|
87
84
|
);
|
|
88
85
|
|
|
89
86
|
const WorkflowActionParams = Type.String({
|
|
90
|
-
enum: ["start", "status", "cancel"
|
|
91
|
-
description: "start launches; status inspects; cancel closes active runs
|
|
87
|
+
enum: ["start", "status", "cancel"],
|
|
88
|
+
description: "start launches; status inspects progress or results; cancel closes active runs.",
|
|
92
89
|
});
|
|
93
90
|
|
|
94
91
|
const WorkflowToolParams = Type.Object(
|
|
@@ -101,7 +98,7 @@ const WorkflowToolParams = Type.Object(
|
|
|
101
98
|
),
|
|
102
99
|
id: Type.Optional(
|
|
103
100
|
Type.String({
|
|
104
|
-
description: "Required for status/cancel
|
|
101
|
+
description: "Required for status/cancel. Workflow id/name.",
|
|
105
102
|
}),
|
|
106
103
|
),
|
|
107
104
|
goal: Type.Optional(
|
|
@@ -115,19 +112,6 @@ const WorkflowToolParams = Type.Object(
|
|
|
115
112
|
minItems: 1,
|
|
116
113
|
}),
|
|
117
114
|
),
|
|
118
|
-
timeoutMs: Type.Optional(
|
|
119
|
-
Type.Union(
|
|
120
|
-
[
|
|
121
|
-
Type.Number({
|
|
122
|
-
exclusiveMinimum: 0,
|
|
123
|
-
}),
|
|
124
|
-
Type.Null(),
|
|
125
|
-
],
|
|
126
|
-
{
|
|
127
|
-
description: "Optional for action=wait. Positive ms; default 10 min; null waits indefinitely.",
|
|
128
|
-
},
|
|
129
|
-
),
|
|
130
|
-
),
|
|
131
115
|
},
|
|
132
116
|
{ additionalProperties: false },
|
|
133
117
|
);
|
|
@@ -156,24 +140,9 @@ export function createWorkflowTool({ orchestra, store }: WorkflowToolDeps): Work
|
|
|
156
140
|
}
|
|
157
141
|
|
|
158
142
|
const workflow = findWorkflow(store, input.id);
|
|
159
|
-
if (!workflow) {
|
|
160
|
-
return input.action === "wait"
|
|
161
|
-
? { timedOut: false, message: formatWorkflowNotFound(input.id) }
|
|
162
|
-
: { message: formatWorkflowNotFound(input.id) };
|
|
163
|
-
}
|
|
143
|
+
if (!workflow) return { message: formatWorkflowNotFound(input.id) };
|
|
164
144
|
|
|
165
|
-
if (input.action === "status") {
|
|
166
|
-
return { workflow, message: formatWorkflowMessage(workflow) };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (input.action === "wait") {
|
|
170
|
-
const result = await waitWorkflow(store, workflow.id, input.timeoutMs);
|
|
171
|
-
return {
|
|
172
|
-
workflow: result.workflow,
|
|
173
|
-
timedOut: result.timedOut,
|
|
174
|
-
message: formatWaitWorkflowMessage(result.workflow, result.timedOut, input.id),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
145
|
+
if (input.action === "status") return { workflow, message: formatWorkflowMessage(workflow) };
|
|
177
146
|
|
|
178
147
|
const closedWorkflow = await closeWorkflow(orchestra, store, workflow);
|
|
179
148
|
return { workflow: closedWorkflow, message: formatWorkflowMessage(closedWorkflow, "Workflow cancelled.") };
|
|
@@ -181,24 +150,29 @@ export function createWorkflowTool({ orchestra, store }: WorkflowToolDeps): Work
|
|
|
181
150
|
};
|
|
182
151
|
}
|
|
183
152
|
|
|
184
|
-
export function defineWorkflowPiTool(
|
|
153
|
+
export function defineWorkflowPiTool(
|
|
154
|
+
resolveTool: (ctx: ExtensionContext) => WorkflowTool,
|
|
155
|
+
options: WorkflowPiToolOptions = {},
|
|
156
|
+
) {
|
|
185
157
|
return defineTool({
|
|
186
158
|
name: "workflow",
|
|
187
159
|
label: "Workflow",
|
|
188
160
|
description: "Run linear workgroup stages with automatic restricted stage leaders.",
|
|
189
|
-
promptSnippet: "Launch a multi-stage workflow
|
|
161
|
+
promptSnippet: "Launch a multi-stage workflow; completion is delivered automatically as a pi-orchestra event.",
|
|
190
162
|
promptGuidelines: [
|
|
191
163
|
"Use workflow for ordered multi-stage work; not branching/DAG plans.",
|
|
192
164
|
"Each stage gets its own bus and automatic leader; previous outputs feed the next stage.",
|
|
193
165
|
"Use compete when one worker success is enough; use synthesize when findings must be combined.",
|
|
194
|
-
"Use status for progress
|
|
166
|
+
"Use workflow status for progress; workflow.finished events deliver terminal success/blocked/failed/closed results.",
|
|
195
167
|
],
|
|
196
168
|
parameters: WorkflowToolParams,
|
|
197
169
|
executionMode: "sequential",
|
|
198
170
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
171
|
+
const input = withDefaultModels(toWorkflowInput(params as RawWorkflowParams), ctx);
|
|
172
|
+
options.onWorkflowInput?.(ctx, input);
|
|
173
|
+
|
|
174
|
+
const output = await resolveTool(ctx).execute(input);
|
|
175
|
+
if (output.workflow) options.onWorkflowOutput?.(ctx, output);
|
|
202
176
|
|
|
203
177
|
return {
|
|
204
178
|
content: [{ type: "text", text: output.message }],
|
|
@@ -262,7 +236,13 @@ async function runStage(workflowId: string, stageIndex: number, deps: WorkflowRu
|
|
|
262
236
|
workerRunIds: workgroupOutput.runs.map((run) => run.id),
|
|
263
237
|
});
|
|
264
238
|
|
|
265
|
-
const settledWorkgroup = await settleWorkgroupRuns(
|
|
239
|
+
const settledWorkgroup = await settleWorkgroupRuns(
|
|
240
|
+
deps.orchestra,
|
|
241
|
+
deps.store,
|
|
242
|
+
bus.id,
|
|
243
|
+
workgroupOutput.runs.map((run) => run.id),
|
|
244
|
+
stage.strategy,
|
|
245
|
+
);
|
|
266
246
|
if (isWorkflowClosed(deps.store, workflowId)) return;
|
|
267
247
|
|
|
268
248
|
if (stage.strategy === "compete" && !settledWorkgroup.winner) {
|
|
@@ -278,7 +258,7 @@ async function runStageLeader(
|
|
|
278
258
|
workflowId: string,
|
|
279
259
|
stageIndex: number,
|
|
280
260
|
busId: string,
|
|
281
|
-
workerResults:
|
|
261
|
+
workerResults: AgentRunResult[],
|
|
282
262
|
deps: WorkflowRunnerDeps,
|
|
283
263
|
): Promise<void> {
|
|
284
264
|
const workflow = requireWorkflow(deps.store, workflowId);
|
|
@@ -300,10 +280,9 @@ async function runStageLeader(
|
|
|
300
280
|
leaderRunId: leaderRun.id,
|
|
301
281
|
});
|
|
302
282
|
|
|
303
|
-
const
|
|
283
|
+
const latestLeaderRun = await terminalRunEvent(deps.store, leaderRun.id);
|
|
304
284
|
if (isWorkflowClosed(deps.store, workflowId)) return;
|
|
305
285
|
|
|
306
|
-
const latestLeaderRun = leaderSettled.runs.find((run) => run.id === leaderRun.id) ?? leaderRun;
|
|
307
286
|
const output = buildStageOutput(latestLeaderRun, leaderRun.id, workerResults);
|
|
308
287
|
finishStage(deps.store, requireWorkflow(deps.store, workflowId), stageIndex, output);
|
|
309
288
|
}
|
|
@@ -381,7 +360,6 @@ function toWorkflowInput(params: RawWorkflowParams): WorkflowInput {
|
|
|
381
360
|
}
|
|
382
361
|
|
|
383
362
|
if (!params.id) throw new Error(`workflow action=${params.action} requires id.`);
|
|
384
|
-
if (params.action === "wait") return { action: "wait", id: params.id, timeoutMs: params.timeoutMs };
|
|
385
363
|
return { action: params.action, id: params.id };
|
|
386
364
|
}
|
|
387
365
|
|
|
@@ -489,7 +467,7 @@ function buildStageWorkerGoal(workflow: WorkflowRun, stageIndex: number): string
|
|
|
489
467
|
return parts.join("\n");
|
|
490
468
|
}
|
|
491
469
|
|
|
492
|
-
function buildLeaderTask(workflow: WorkflowRun, stageIndex: number, workerResults:
|
|
470
|
+
function buildLeaderTask(workflow: WorkflowRun, stageIndex: number, workerResults: AgentRunResult[]): string {
|
|
493
471
|
const stage = workflow.stages[stageIndex];
|
|
494
472
|
const strategyInstructions =
|
|
495
473
|
stage.strategy === "compete"
|
|
@@ -536,12 +514,12 @@ function formatPreviousStageOutput(stageName: string, output: WorkflowStageOutpu
|
|
|
536
514
|
return [`<stage_output name="${stageName}">`, formatStageOutputForPrompt(output), "</stage_output>"].join("\n");
|
|
537
515
|
}
|
|
538
516
|
|
|
539
|
-
function formatWorkerResults(workerResults:
|
|
517
|
+
function formatWorkerResults(workerResults: AgentRunResult[]): string {
|
|
540
518
|
if (workerResults.length === 0) return "None.";
|
|
541
519
|
return workerResults.map(formatWorkerResult).join("\n\n");
|
|
542
520
|
}
|
|
543
521
|
|
|
544
|
-
function formatWorkerResult(result:
|
|
522
|
+
function formatWorkerResult(result: AgentRunResult): string {
|
|
545
523
|
const lines = [
|
|
546
524
|
`<worker_result run_id="${result.runId}" name="${result.name}" profile="${result.profile}" state="${result.state}">`,
|
|
547
525
|
];
|
|
@@ -565,7 +543,7 @@ function formatJsonData(data: unknown): string {
|
|
|
565
543
|
return JSON.stringify(data, null, 2) ?? String(data);
|
|
566
544
|
}
|
|
567
545
|
|
|
568
|
-
function buildCompeteNoWinnerOutput(workerResults:
|
|
546
|
+
function buildCompeteNoWinnerOutput(workerResults: AgentRunResult[]): WorkflowStageOutput {
|
|
569
547
|
const status = workerResults.some((worker) => worker.result?.status === "blocked") ? "blocked" : "failed";
|
|
570
548
|
const counts = countWorkerResultStatuses(workerResults);
|
|
571
549
|
const workflowRunResults = workerResults.map(toWorkflowRunResult);
|
|
@@ -577,7 +555,7 @@ function buildCompeteNoWinnerOutput(workerResults: WaitRunResult[]): WorkflowSta
|
|
|
577
555
|
};
|
|
578
556
|
}
|
|
579
557
|
|
|
580
|
-
function countWorkerResultStatuses(workerResults:
|
|
558
|
+
function countWorkerResultStatuses(workerResults: AgentRunResult[]): Record<AgentResultStatus, number> {
|
|
581
559
|
const counts: Record<AgentResultStatus, number> = { success: 0, blocked: 0, failed: 0 };
|
|
582
560
|
for (const worker of workerResults) {
|
|
583
561
|
if (worker.result) counts[worker.result.status]++;
|
|
@@ -588,7 +566,7 @@ function countWorkerResultStatuses(workerResults: WaitRunResult[]): Record<Agent
|
|
|
588
566
|
function buildStageOutput(
|
|
589
567
|
leaderRun: AgentRun,
|
|
590
568
|
leaderRunId: string,
|
|
591
|
-
workerResults:
|
|
569
|
+
workerResults: AgentRunResult[],
|
|
592
570
|
): WorkflowStageOutput {
|
|
593
571
|
if (!leaderRun.result) {
|
|
594
572
|
return {
|
|
@@ -609,7 +587,7 @@ function buildStageOutput(
|
|
|
609
587
|
return output;
|
|
610
588
|
}
|
|
611
589
|
|
|
612
|
-
function toWorkflowRunResult(result:
|
|
590
|
+
function toWorkflowRunResult(result: AgentRunResult) {
|
|
613
591
|
const output = {
|
|
614
592
|
runId: result.runId,
|
|
615
593
|
name: result.name,
|
|
@@ -625,46 +603,21 @@ function formatStageOutput(output: WorkflowStageOutput): string {
|
|
|
625
603
|
return parts.join("\n");
|
|
626
604
|
}
|
|
627
605
|
|
|
628
|
-
function
|
|
629
|
-
store
|
|
630
|
-
|
|
631
|
-
timeoutMs: number | null | undefined,
|
|
632
|
-
): Promise<{ workflow: WorkflowRun; timedOut: boolean }> {
|
|
633
|
-
const resolvedTimeoutMs = resolveWaitTimeoutMs("workflow wait", timeoutMs);
|
|
634
|
-
const initialWorkflow = requireWorkflow(store, workflowId);
|
|
635
|
-
if (isTerminalAgentState(initialWorkflow.state)) {
|
|
636
|
-
return Promise.resolve({ workflow: initialWorkflow, timedOut: false });
|
|
637
|
-
}
|
|
606
|
+
function terminalRunEvent(store: AgentStore, runId: string): Promise<AgentRun> {
|
|
607
|
+
const initialRun = store.getRun(runId);
|
|
608
|
+
if (initialRun && isTerminalAgentState(initialRun.state)) return Promise.resolve(initialRun);
|
|
638
609
|
|
|
639
610
|
return new Promise((resolve) => {
|
|
640
|
-
let
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (settled) return;
|
|
651
|
-
latestWorkflow = workflow;
|
|
652
|
-
if (!isTerminalAgentState(workflow.state)) return;
|
|
653
|
-
|
|
654
|
-
settled = true;
|
|
655
|
-
cleanup();
|
|
656
|
-
resolve({ workflow, timedOut: false });
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
if (resolvedTimeoutMs !== null) {
|
|
660
|
-
timeout = setTimeout(() => {
|
|
661
|
-
if (settled) return;
|
|
662
|
-
|
|
663
|
-
settled = true;
|
|
664
|
-
cleanup();
|
|
665
|
-
resolve({ workflow: latestWorkflow, timedOut: true });
|
|
666
|
-
}, resolvedTimeoutMs);
|
|
667
|
-
}
|
|
611
|
+
let unsubscribe: () => void = () => undefined;
|
|
612
|
+
unsubscribe = store.subscribeRuns(
|
|
613
|
+
(run) => {
|
|
614
|
+
if (!isTerminalAgentState(run.state)) return;
|
|
615
|
+
|
|
616
|
+
unsubscribe();
|
|
617
|
+
resolve(run);
|
|
618
|
+
},
|
|
619
|
+
(run) => run.id === runId,
|
|
620
|
+
);
|
|
668
621
|
});
|
|
669
622
|
}
|
|
670
623
|
|
|
@@ -672,14 +625,6 @@ function formatWorkflowNotFound(id: string): string {
|
|
|
672
625
|
return `Workflow ${id} not found.`;
|
|
673
626
|
}
|
|
674
627
|
|
|
675
|
-
function formatWaitWorkflowMessage(workflow: WorkflowRun | undefined, timedOut: boolean, requestedId?: string): string {
|
|
676
|
-
if (!workflow) return formatWorkflowNotFound(requestedId ?? "");
|
|
677
|
-
const label = requestedId === workflow.id ? workflow.id : formatNamedEntityLabel(workflow);
|
|
678
|
-
const prefix = timedOut ? "Timed out waiting for" : "Workflow reached terminal state:";
|
|
679
|
-
const result = workflow.result ? ` result=${workflow.result.status}` : "";
|
|
680
|
-
return `${prefix} ${label}; state=${workflow.state}${result}.`;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
628
|
function formatWorkflowMessage(
|
|
684
629
|
workflow: WorkflowRun,
|
|
685
630
|
headline = `Workflow ${formatNamedEntityLabel(workflow)} is ${workflow.state}.`,
|
|
@@ -695,12 +640,11 @@ function formatWorkflowMessage(
|
|
|
695
640
|
}
|
|
696
641
|
|
|
697
642
|
type RawWorkflowParams = {
|
|
698
|
-
action: "start" | "status" | "cancel"
|
|
643
|
+
action: "start" | "status" | "cancel";
|
|
699
644
|
name?: string;
|
|
700
645
|
id?: string;
|
|
701
646
|
goal?: string;
|
|
702
647
|
stages?: RawWorkflowStageParams[];
|
|
703
|
-
timeoutMs?: number | null;
|
|
704
648
|
};
|
|
705
649
|
|
|
706
650
|
type RawWorkflowStageParams = {
|
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
|
|