@alexeiled/pi-fusion 0.6.0 → 0.6.1

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 CHANGED
@@ -177,7 +177,7 @@ Requirements:
177
177
 
178
178
  - Pi
179
179
  - Node.js 22.19+
180
- - `pi-subagents`
180
+ - `pi-subagents` 0.42.1 or later
181
181
 
182
182
  ```bash
183
183
  pi install npm:pi-subagents
@@ -210,7 +210,7 @@ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./
210
210
  - Fusion sends your prompt and any inspected snippets to every panel model, and to the judge, through `pi-subagents`.
211
211
  - Reports include available per-panel and judge time, aggregate model time, usage, estimated cost, and model failure details. Missing provider usage is shown as unknown. `$0.0000` is a known zero cost.
212
212
  - `Model` is lifecycle metadata. `Configured model` is the profile request. Both appear when the run differs from the request.
213
- - `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence, stops only unfinished panelists, and still runs the judge.
213
+ - `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence, evaluates panelists in pairs so it can avoid starting later work, records skipped panelists in the report, and still runs the judge.
214
214
  - Panel answers reach the judge in an order seeded from the run id, not in config order. A fixed order advantages the same member on every run, because judges favour whichever candidate they see first or last.
215
215
  - Panelists can search the web by opting in to the `fusion-panelist-web` agent, which requires `pi-web-providers`. Defaults stay local-only on purpose: tool names are a strict allowlist, so an agent declaring a tool whose extension is missing fails every task that uses it.
216
216
  - `synthesis: "merge"` switches from picking the best answer to merging answers that covered different facets, using the `fusion-composer` agent. Panel members get facets through their optional `question` field. See the user guide.
@@ -29,7 +29,7 @@ prompts, or both. **Mixing models is the main lever.** The config that
29
29
  `/fusion init` writes sets no `model`. By default you therefore get one model in
30
30
  three roles. Give each member its own `model` to get the real benefit.
31
31
 
32
- Older runs created as a single `pi-subagents` chain remain supported when restored.
32
+ Fusion launches new panels through `pi-subagents` `workflowScript`; the panel and judge remain separate durable runs. Older runs created as a single `pi-subagents` chain remain supported when restored.
33
33
 
34
34
  The base Pi session stays in control. Fusion is a tool for decisions, not a replacement for normal coding.
35
35
 
@@ -149,7 +149,7 @@ Profile:
149
149
 
150
150
  - `panel`: one or more panel members
151
151
  - `judge`: judge agent config
152
- - `concurrency`: max parallel panelists
152
+ - `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion evaluates the first two panelists before launching another batch so it can avoid work after strong agreement.
153
153
  - `timeoutMs`: async subagent timeout in milliseconds
154
154
  - `context`: `fresh` or `fork`
155
155
  - `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet. All four conditions must hold: two or more finished panelists give the same normalized recommendation, every one of them reports `high` confidence, none of them asks for more evidence, and work remains. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
@@ -451,10 +451,16 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
451
451
 
452
452
  `pi-subagents RPC is unavailable`
453
453
 
454
- - install `pi-subagents`
454
+ - install or update `pi-subagents` to 0.42.1 or later
455
455
  - reload Pi
456
456
  - retry `/fusion status`
457
457
 
458
+ `RPC spawn no longer accepts top-level chain or parallel inputs; use workflowScript.`
459
+
460
+ - update `pi-fusion` to 0.6.1 or later
461
+ - reload Pi so it loads the updated extension
462
+ - retry `/fusion`; do not change your Fusion profile
463
+
458
464
  `Unknown fusion profile`
459
465
 
460
466
  - verify `defaultProfile`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -51,6 +51,7 @@ import type { SubagentsTargetParams } from "./subagents-rpc.js";
51
51
  export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete";
52
52
 
53
53
  const RECONCILE_INTERVAL_MS = 2_000;
54
+ const WORKFLOW_RESULT_ARTIFACT_GRACE_MS = 5_000;
54
55
 
55
56
  export type FusionNotifyType = "info" | "warning" | "error";
56
57
 
@@ -105,6 +106,7 @@ interface RunLifecycleSnapshot {
105
106
  statusPayload?: unknown;
106
107
  resultPayload?: unknown;
107
108
  resultIsTerminal: boolean;
109
+ resultArtifactPending?: boolean;
108
110
  }
109
111
 
110
112
  export class FusionOrchestrator {
@@ -556,6 +558,7 @@ export class FusionOrchestrator {
556
558
  ...(active.chainAsyncDir ? { asyncDir: active.chainAsyncDir } : {}),
557
559
  eventPayload: payload,
558
560
  });
561
+ if (snapshot.resultArtifactPending) return { status: "ignored" };
559
562
  const terminalPayload =
560
563
  snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
561
564
  if (
@@ -655,8 +658,12 @@ export class FusionOrchestrator {
655
658
  : {}),
656
659
  eventPayload: payload,
657
660
  });
661
+ if (snapshot.resultArtifactPending) return { status: "ignored" };
658
662
 
659
- if (!active.panelStopReason) {
663
+ const panelIsTerminal =
664
+ snapshot.resultIsTerminal ||
665
+ isTerminalSubagentState(extractSubagentState(snapshot.statusPayload));
666
+ if (!active.panelStopReason && !panelIsTerminal) {
660
667
  const partial = extractPanelResults(snapshot.statusPayload, {
661
668
  panel: profile.panel,
662
669
  completedOnly: true,
@@ -689,12 +696,16 @@ export class FusionOrchestrator {
689
696
  return this.failActiveRun(lifecycleError);
690
697
  }
691
698
 
699
+ const workflowStoppedIndices = extractWorkflowStoppedPanelIndices(
700
+ lifecyclePayload,
701
+ );
702
+ const stoppedPanelIndices =
703
+ active.panelStoppedIndices ??
704
+ (workflowStoppedIndices.length > 0 ? workflowStoppedIndices : undefined);
692
705
  const extracted = extractPanelResults(lifecyclePayload, {
693
706
  panel: profile.panel,
694
707
  limit: profile.panel.length,
695
- ...(active.panelStoppedIndices
696
- ? { stoppedPanelIndices: active.panelStoppedIndices }
697
- : {}),
708
+ ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}),
698
709
  });
699
710
  if (!extracted.ok) {
700
711
  return this.failActiveRun(
@@ -707,11 +718,18 @@ export class FusionOrchestrator {
707
718
  snapshot.statusPayload,
708
719
  profile,
709
720
  );
710
- const updated = this.storePanelResults(
721
+ const stored = this.storePanelResults(
711
722
  active.id,
712
723
  observedPanels.outputs,
713
724
  observedPanels.failures,
714
725
  );
726
+ const updated =
727
+ workflowStoppedIndices.length > 0 && !active.panelStopReason
728
+ ? this.runStore.updateRun(stored.id, {
729
+ panelStopReason: "agreement",
730
+ panelStoppedIndices: workflowStoppedIndices,
731
+ })
732
+ : stored;
715
733
 
716
734
  return this.finishPanelCompletion(
717
735
  updated,
@@ -840,6 +858,7 @@ export class FusionOrchestrator {
840
858
  ...(active.judgeAsyncDir ? { asyncDir: active.judgeAsyncDir } : {}),
841
859
  eventPayload: payload,
842
860
  });
861
+ if (snapshot.resultArtifactPending) return { status: "ignored" };
843
862
  const terminalPayload =
844
863
  snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
845
864
  if (
@@ -952,6 +971,17 @@ export class FusionOrchestrator {
952
971
  };
953
972
  }
954
973
 
974
+ if (
975
+ statusIsTerminal &&
976
+ isWorkflowResultArtifactPending(statusPayload)
977
+ ) {
978
+ return {
979
+ statusPayload,
980
+ resultIsTerminal: false,
981
+ resultArtifactPending: true,
982
+ };
983
+ }
984
+
955
985
  if (hasResultsArray(statusPayload)) {
956
986
  return {
957
987
  statusPayload,
@@ -1608,10 +1638,46 @@ function isTerminalSubagentState(state: string | undefined): boolean {
1608
1638
  );
1609
1639
  }
1610
1640
 
1641
+ function isWorkflowResultArtifactPending(payload: unknown): boolean {
1642
+ if (!isRecord(payload)) return false;
1643
+ const details = isRecord(payload.details) ? payload.details : undefined;
1644
+ const mode = firstString(payload.mode, details?.mode);
1645
+ const endedAtValue = payload.endedAt ?? details?.endedAt;
1646
+ if (mode === "workflow" && typeof endedAtValue === "number") {
1647
+ return Date.now() - endedAtValue < WORKFLOW_RESULT_ARTIFACT_GRACE_MS;
1648
+ }
1649
+ return isRecord(payload.data)
1650
+ ? isWorkflowResultArtifactPending(payload.data)
1651
+ : false;
1652
+ }
1653
+
1611
1654
  function hasLifecycleResults(payload: unknown): boolean {
1612
1655
  return hasResultsArray(payload) || findStepsArray(payload).length > 0;
1613
1656
  }
1614
1657
 
1658
+ function extractWorkflowStoppedPanelIndices(payload: unknown): number[] {
1659
+ if (!isRecord(payload)) return [];
1660
+ const emits = isRecord(payload.workflow)
1661
+ ? unknownArray(payload.workflow.emits)
1662
+ : undefined;
1663
+ const indices = new Set<number>();
1664
+ for (const emit of emits ?? []) {
1665
+ if (!isRecord(emit) || emit.type !== "pi-fusion-panel-stop") continue;
1666
+ for (const index of unknownArray(emit.indices) ?? []) {
1667
+ if (typeof index === "number" && Number.isInteger(index) && index >= 0) {
1668
+ indices.add(index);
1669
+ }
1670
+ }
1671
+ }
1672
+ if (indices.size > 0) return [...indices].sort((left, right) => left - right);
1673
+ if (isRecord(payload.details)) {
1674
+ const nested = extractWorkflowStoppedPanelIndices(payload.details);
1675
+ if (nested.length > 0) return nested;
1676
+ }
1677
+ if (isRecord(payload.data)) return extractWorkflowStoppedPanelIndices(payload.data);
1678
+ return [];
1679
+ }
1680
+
1615
1681
  function extractSubagentFailure(payload: unknown): string | undefined {
1616
1682
  if (!isRecord(payload)) return undefined;
1617
1683
  const direct = firstNonBlankString(payload.error, payload.errorMessage);
@@ -75,6 +75,20 @@ export function extractPanelResults(
75
75
  else failures.push(child.failure);
76
76
  }
77
77
 
78
+ for (const index of options.stoppedPanelIndices ?? []) {
79
+ if (index < results.length || index >= (options.limit ?? Infinity)) continue;
80
+ const child = normalizeChildResult(
81
+ {
82
+ success: false,
83
+ error: "Stopped after strong panel agreement.",
84
+ },
85
+ index,
86
+ options,
87
+ );
88
+ if (!child.ok) return child;
89
+ if (child.status === "failed") failures.push(child.failure);
90
+ }
91
+
78
92
  const runId = firstString(container.payload.runId, container.payload.id);
79
93
  return {
80
94
  ok: true,
@@ -197,7 +211,7 @@ function normalizeChildResult(
197
211
  }
198
212
 
199
213
  const member = options.panel?.[index];
200
- const agent = firstString(rawResult.agent, member?.agent);
214
+ const agent = firstString(member?.agent, rawResult.agent);
201
215
  if (!agent) {
202
216
  return error(
203
217
  "missing-result-field",
@@ -36,45 +36,12 @@ export interface PanelSubagentTaskParams {
36
36
  model?: string;
37
37
  }
38
38
 
39
- export interface PanelChainTaskParams extends PanelSubagentTaskParams {
40
- as: string;
41
- label: string;
42
- phase: "Panel";
39
+ export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
40
+ key: string;
43
41
  }
44
42
 
45
43
  export interface PanelSpawnParams {
46
- tasks: PanelSubagentTaskParams[];
47
- async: true;
48
- clarify: false;
49
- concurrency: number;
50
- context: "fresh" | "fork";
51
- output: true;
52
- outputMode: "inline";
53
- acceptance: FusionAcceptanceDisabled;
54
- timeoutMs?: number;
55
- }
56
-
57
- export interface FusionChainParallelStepParams {
58
- parallel: PanelChainTaskParams[];
59
- concurrency: number;
60
- failFast: false;
61
- }
62
-
63
- export interface FusionChainJudgeStepParams {
64
- agent: string;
65
- task: string;
66
- label: "Judge";
67
- phase: "Judge";
68
- output: true;
69
- outputMode: "inline";
70
- skill: false;
71
- acceptance: FusionAcceptanceDisabled;
72
- model?: string;
73
- }
74
-
75
- export interface FusionChainSpawnParams {
76
- chain: [FusionChainParallelStepParams, FusionChainJudgeStepParams];
77
- task: string;
44
+ workflowScript: string;
78
45
  async: true;
79
46
  clarify: false;
80
47
  context: "fresh" | "fork";
@@ -181,57 +148,24 @@ export function buildPanelSpawnParams(
181
148
  profile: FusionProfile,
182
149
  prompt: string,
183
150
  ): PanelSpawnParams {
184
- return {
185
- tasks: profile.panel.map((member) =>
186
- buildPanelTaskParams(
151
+ const concurrency = profile.concurrency ?? profile.panel.length;
152
+ const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
153
+ (member, index) => ({
154
+ key: `panel-${index + 1}`,
155
+ ...buildPanelTaskParams(
187
156
  member,
188
157
  prompt,
189
158
  profile.stopWhenPanelAgrees === true,
190
159
  ),
191
- ),
192
- async: true,
193
- clarify: false,
194
- concurrency: profile.concurrency ?? profile.panel.length,
195
- context: profile.context ?? "fresh",
196
- output: true,
197
- outputMode: "inline",
198
- acceptance: FUSION_ACCEPTANCE_DISABLED,
199
- ...(profile.timeoutMs !== undefined
200
- ? { timeoutMs: profile.timeoutMs }
201
- : {}),
202
- };
203
- }
204
-
205
- export function buildFusionChainSpawnParams(
206
- profile: FusionProfile,
207
- prompt: string,
208
- ): FusionChainSpawnParams {
209
- const model = appendThinkingSuffix(
210
- profile.judge.model,
211
- profile.judge.thinking,
160
+ }),
212
161
  );
162
+
213
163
  return {
214
- chain: [
215
- {
216
- parallel: profile.panel.map((member, index) =>
217
- buildPanelChainTaskParams(member, index),
218
- ),
219
- concurrency: profile.concurrency ?? profile.panel.length,
220
- failFast: false,
221
- },
222
- {
223
- agent: profile.judge.agent,
224
- task: buildChainJudgeTask(profile.panel),
225
- label: "Judge",
226
- phase: "Judge",
227
- output: true,
228
- outputMode: "inline",
229
- skill: false,
230
- acceptance: FUSION_ACCEPTANCE_DISABLED,
231
- ...(model ? { model } : {}),
232
- },
233
- ],
234
- task: prompt.trim(),
164
+ workflowScript: buildPanelWorkflowScript(
165
+ tasks,
166
+ concurrency,
167
+ profile.stopWhenPanelAgrees === true,
168
+ ),
235
169
  async: true,
236
170
  clarify: false,
237
171
  context: profile.context ?? "fresh",
@@ -271,6 +205,51 @@ export function buildJudgeSpawnParams(
271
205
  };
272
206
  }
273
207
 
208
+ function buildPanelWorkflowScript(
209
+ tasks: readonly PanelWorkflowTaskParams[],
210
+ concurrency: number,
211
+ stopWhenAgrees: boolean,
212
+ ): string {
213
+ const serializedTasks = JSON.stringify(tasks);
214
+ const effectiveConcurrency = stopWhenAgrees
215
+ ? Math.min(concurrency, 2)
216
+ : concurrency;
217
+ const stopLogic = stopWhenAgrees
218
+ ? [
219
+ "const decisions = results",
220
+ " .filter((result) => result && result.ok === true)",
221
+ " .map((result) => {",
222
+ " const text = typeof result.output === \"string\" ? result.output : \"\";",
223
+ " const match = text.match(/<fusion-panel-decision>([\\s\\S]*?)<\\/fusion-panel-decision>\\s*$/);",
224
+ " if (!match) return undefined;",
225
+ " try { return JSON.parse(match[1]); } catch { return undefined; }",
226
+ " })",
227
+ " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
228
+ "if (decisions.length >= 2 && results.length < tasks.length) {",
229
+ " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
230
+ " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
231
+ " if (agrees) {",
232
+ " emit({ type: \"pi-fusion-panel-stop\", indices: tasks.slice(results.length).map((_, index) => results.length + index) });",
233
+ " return results;",
234
+ " }",
235
+ "}",
236
+ ].join("\n")
237
+ : "";
238
+
239
+ return [
240
+ `const tasks = ${serializedTasks};`,
241
+ `const concurrency = ${effectiveConcurrency};`,
242
+ "const results = [];",
243
+ "for (let index = 0; index < tasks.length; index += concurrency) {",
244
+ " results.push(...await runs.all(tasks.slice(index, index + concurrency)));",
245
+ stopWhenAgrees ? " " + stopLogic.replaceAll("\n", "\n ") : "",
246
+ "}",
247
+ "return results;",
248
+ ]
249
+ .filter(Boolean)
250
+ .join("\n");
251
+ }
252
+
274
253
  function buildPanelTaskParams(
275
254
  member: PanelMemberConfig,
276
255
  prompt: string,
@@ -289,26 +268,6 @@ function buildPanelTaskParams(
289
268
  };
290
269
  }
291
270
 
292
- function buildPanelChainTaskParams(
293
- member: PanelMemberConfig,
294
- index: number,
295
- ): PanelChainTaskParams {
296
- const model = appendThinkingSuffix(member.model, member.thinking);
297
- return {
298
- agent: member.agent,
299
- task: buildPanelTask(member, "{task}", false),
300
- as: chainOutputName(member, index),
301
- label: memberLabel(member),
302
- phase: "Panel",
303
- output: true,
304
- outputMode: "inline",
305
- progress: true,
306
- skill: false,
307
- acceptance: FUSION_ACCEPTANCE_DISABLED,
308
- ...(model ? { model } : {}),
309
- };
310
- }
311
-
312
271
  function buildPanelTask(
313
272
  member: PanelMemberConfig,
314
273
  prompt: string,
@@ -461,30 +420,6 @@ function formatFacetAssignments(
461
420
  });
462
421
  }
463
422
 
464
- function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
465
- return [
466
- "You are the fusion judge.",
467
- "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
468
- "Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
469
- "",
470
- "Original task:",
471
- "{task}",
472
- "",
473
- "All listed panelists completed successfully.",
474
- "",
475
- "Panel outputs:",
476
- ...panel.flatMap((member, index) => [
477
- `## ${memberLabel(member)} (${member.id})`,
478
- `Agent: ${member.agent}`,
479
- "",
480
- `{outputs.${chainOutputName(member, index)}}`,
481
- "",
482
- ]),
483
- "Output contract:",
484
- ...JUDGE_OUTPUT_CONTRACT,
485
- ].join("\n");
486
- }
487
-
488
423
  function formatPanelStatus(
489
424
  outputs: readonly PanelOutput[],
490
425
  failures: readonly FailedPanelSummary[],
@@ -625,12 +560,6 @@ function firstLine(value: string): string {
625
560
  return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
626
561
  }
627
562
 
628
- function chainOutputName(member: PanelMemberConfig, index: number): string {
629
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(member.id)
630
- ? member.id
631
- : `panel_${index + 1}`;
632
- }
633
-
634
563
  function hasThinkingSuffix(model: string): boolean {
635
564
  const colonIndex = model.lastIndexOf(":");
636
565
  if (colonIndex === -1) return false;