@alexeiled/pi-fusion 0.6.0 → 0.6.2

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.43.0 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.43.0 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.2",
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,17 +36,13 @@ 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[];
44
+ workflowScript: string;
47
45
  async: true;
48
- clarify: false;
49
- concurrency: number;
50
46
  context: "fresh" | "fork";
51
47
  output: true;
52
48
  outputMode: "inline";
@@ -54,50 +50,26 @@ export interface PanelSpawnParams {
54
50
  timeoutMs?: number;
55
51
  }
56
52
 
57
- export interface FusionChainParallelStepParams {
58
- parallel: PanelChainTaskParams[];
59
- concurrency: number;
60
- failFast: false;
61
- }
62
-
63
- export interface FusionChainJudgeStepParams {
53
+ export interface JudgeWorkflowTaskParams {
64
54
  agent: string;
65
55
  task: string;
66
- label: "Judge";
67
- phase: "Judge";
68
56
  output: true;
69
57
  outputMode: "inline";
70
58
  skill: false;
71
59
  acceptance: FusionAcceptanceDisabled;
72
60
  model?: string;
73
- }
74
-
75
- export interface FusionChainSpawnParams {
76
- chain: [FusionChainParallelStepParams, FusionChainJudgeStepParams];
77
- task: string;
78
- async: true;
79
- clarify: false;
80
- context: "fresh" | "fork";
81
- output: true;
82
- outputMode: "inline";
83
- acceptance: FusionAcceptanceDisabled;
84
- timeoutMs?: number;
61
+ /** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
62
+ toolBudget?: ToolBudget;
85
63
  }
86
64
 
87
65
  export interface JudgeSpawnParams {
88
- agent: string;
89
- task: string;
66
+ workflowScript: string;
90
67
  async: true;
91
- clarify: false;
92
68
  context: "fresh" | "fork";
93
69
  output: true;
94
70
  outputMode: "inline";
95
- skill: false;
96
71
  acceptance: FusionAcceptanceDisabled;
97
- model?: string;
98
72
  timeoutMs?: number;
99
- /** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
100
- toolBudget?: ToolBudget;
101
73
  }
102
74
 
103
75
  export type { FailedPanelSummary, PanelOutput } from "./types.js";
@@ -181,59 +153,25 @@ export function buildPanelSpawnParams(
181
153
  profile: FusionProfile,
182
154
  prompt: string,
183
155
  ): PanelSpawnParams {
184
- return {
185
- tasks: profile.panel.map((member) =>
186
- buildPanelTaskParams(
156
+ const concurrency = profile.concurrency ?? profile.panel.length;
157
+ const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
158
+ (member, index) => ({
159
+ key: `panel-${index + 1}`,
160
+ ...buildPanelTaskParams(
187
161
  member,
188
162
  prompt,
189
163
  profile.stopWhenPanelAgrees === true,
190
164
  ),
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,
165
+ }),
212
166
  );
167
+
213
168
  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(),
169
+ workflowScript: buildPanelWorkflowScript(
170
+ tasks,
171
+ concurrency,
172
+ profile.stopWhenPanelAgrees === true,
173
+ ),
235
174
  async: true,
236
- clarify: false,
237
175
  context: profile.context ?? "fresh",
238
176
  output: true,
239
177
  outputMode: "inline",
@@ -251,12 +189,9 @@ export function buildJudgeSpawnParams(
251
189
  input.profile.judge.model,
252
190
  input.profile.judge.thinking,
253
191
  );
254
- return {
192
+ const task: JudgeWorkflowTaskParams = {
255
193
  agent: resolveSynthesisAgent(input.profile),
256
194
  task: buildJudgeTask(input),
257
- async: true,
258
- clarify: false,
259
- context: input.profile.context ?? "fresh",
260
195
  output: true,
261
196
  outputMode: "inline",
262
197
  skill: false,
@@ -265,12 +200,66 @@ export function buildJudgeSpawnParams(
265
200
  ...(input.profile.judgeToolBudget
266
201
  ? { toolBudget: input.profile.judgeToolBudget }
267
202
  : {}),
203
+ };
204
+
205
+ return {
206
+ workflowScript: `return runs.run("judge", ${JSON.stringify(task)});`,
207
+ async: true,
208
+ context: input.profile.context ?? "fresh",
209
+ output: true,
210
+ outputMode: "inline",
211
+ acceptance: FUSION_ACCEPTANCE_DISABLED,
268
212
  ...(input.profile.timeoutMs !== undefined
269
213
  ? { timeoutMs: input.profile.timeoutMs }
270
214
  : {}),
271
215
  };
272
216
  }
273
217
 
218
+ function buildPanelWorkflowScript(
219
+ tasks: readonly PanelWorkflowTaskParams[],
220
+ concurrency: number,
221
+ stopWhenAgrees: boolean,
222
+ ): string {
223
+ const serializedTasks = JSON.stringify(tasks);
224
+ const effectiveConcurrency = stopWhenAgrees
225
+ ? Math.min(concurrency, 2)
226
+ : concurrency;
227
+ const stopLogic = stopWhenAgrees
228
+ ? [
229
+ "const decisions = results",
230
+ " .filter((result) => result && result.ok === true)",
231
+ " .map((result) => {",
232
+ " const text = typeof result.output === \"string\" ? result.output : \"\";",
233
+ " const match = text.match(/<fusion-panel-decision>([\\s\\S]*?)<\\/fusion-panel-decision>\\s*$/);",
234
+ " if (!match) return undefined;",
235
+ " try { return JSON.parse(match[1]); } catch { return undefined; }",
236
+ " })",
237
+ " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
238
+ "if (decisions.length >= 2 && results.length < tasks.length) {",
239
+ " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
240
+ " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
241
+ " if (agrees) {",
242
+ " emit({ type: \"pi-fusion-panel-stop\", indices: tasks.slice(results.length).map((_, index) => results.length + index) });",
243
+ " return results;",
244
+ " }",
245
+ "}",
246
+ ].join("\n")
247
+ : "";
248
+
249
+ return [
250
+ `const tasks = ${serializedTasks};`,
251
+ `const concurrency = ${effectiveConcurrency};`,
252
+ "const results = [];",
253
+ "for (let index = 0; index < tasks.length; index += concurrency) {",
254
+ " results.push(...await runs.all(tasks.slice(index, index + concurrency)));",
255
+ stopWhenAgrees ? " " + stopLogic.replaceAll("\n", "\n ") : "",
256
+ "}",
257
+ "return results;",
258
+ ]
259
+ .filter(Boolean)
260
+ .join("\n");
261
+ }
262
+
274
263
  function buildPanelTaskParams(
275
264
  member: PanelMemberConfig,
276
265
  prompt: string,
@@ -289,26 +278,6 @@ function buildPanelTaskParams(
289
278
  };
290
279
  }
291
280
 
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
281
  function buildPanelTask(
313
282
  member: PanelMemberConfig,
314
283
  prompt: string,
@@ -461,30 +430,6 @@ function formatFacetAssignments(
461
430
  });
462
431
  }
463
432
 
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
433
  function formatPanelStatus(
489
434
  outputs: readonly PanelOutput[],
490
435
  failures: readonly FailedPanelSummary[],
@@ -625,12 +570,6 @@ function firstLine(value: string): string {
625
570
  return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
626
571
  }
627
572
 
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
573
  function hasThinkingSuffix(model: string): boolean {
635
574
  const colonIndex = model.lastIndexOf(":");
636
575
  if (colonIndex === -1) return false;