@alexeiled/pi-fusion 0.6.2 → 0.8.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.
@@ -1,9 +1,19 @@
1
- import { renderPanelFailureReport, renderSinglePanelReport } from "./report.js";
1
+ import {
2
+ detectCallerOutputContract,
3
+ validateCallerOutput,
4
+ } from "./caller-contract.js";
5
+ import {
6
+ renderFailureReport,
7
+ renderPanelFailureReport,
8
+ renderPartialPanelReport,
9
+ renderSinglePanelReport,
10
+ } from "./report.js";
2
11
  import {
3
12
  appendThinkingSuffix,
4
13
  buildJudgeSpawnParams,
5
14
  type JudgeSpawnParams,
6
15
  } from "./run-builder.js";
16
+ import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
7
17
  import {
8
18
  resolveSynthesisMode,
9
19
  type FailedPanelSummary,
@@ -12,6 +22,8 @@ import {
12
22
  type PanelOutput,
13
23
  } from "./types.js";
14
24
 
25
+ export { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
26
+
15
27
  export type PanelCompletionDecision =
16
28
  | { kind: "fail"; error: string; report: string }
17
29
  | { kind: "complete"; report: string }
@@ -50,14 +62,48 @@ export function decidePanelCompletion(
50
62
  };
51
63
  }
52
64
 
53
- // Under `select` every panelist answered the whole question, so a lone
54
- // survivor is a complete if thin answer. Under `merge` it answered ONE facet:
55
- // returning it as the answer would be wrong, not thin. Run the composer so
56
- // the report names the facets nobody covered.
65
+ const intentionalStops =
66
+ input.profile.stopWhenPanelAgrees === true &&
67
+ input.panelOutputs.length >= 2 &&
68
+ input.panelFailures.length > 0 &&
69
+ input.panelFailures.every(
70
+ ({ reason }) => reason === "stopped-after-agreement",
71
+ );
72
+ const synthesis = resolveSynthesisMode(input.profile);
73
+ const required = resolveMinimumSuccessfulPanelists(
74
+ input.run.minimumSuccessfulPanelists ??
75
+ input.profile.minimumSuccessfulPanelists,
76
+ input.profile.panel.length,
77
+ );
78
+ // A configured one-member panel is still validated as an exact caller
79
+ // contract, but does not need a synthetic comparison.
57
80
  if (
58
- input.panelOutputs.length === 1 &&
59
- resolveSynthesisMode(input.profile) !== "merge"
81
+ synthesis !== "merge" &&
82
+ input.profile.panel.length === 1 &&
83
+ input.panelOutputs.length === 1
60
84
  ) {
85
+ const callerContract =
86
+ input.run.outputContract ??
87
+ detectCallerOutputContract(input.run.prompt);
88
+ if (callerContract) {
89
+ const validation = validateCallerOutput(
90
+ callerContract,
91
+ input.panelOutputs[0]!.output,
92
+ );
93
+ if (!validation.ok) {
94
+ const report = renderFailureReport({
95
+ run: input.run,
96
+ error: validation.error,
97
+ panelOutputs: input.panelOutputs,
98
+ failures: input.panelFailures,
99
+ ...withJudgeModel(judgeModel),
100
+ synthesis,
101
+ panel: input.profile.panel,
102
+ });
103
+ return { kind: "fail", error: validation.error, report };
104
+ }
105
+ }
106
+
61
107
  const report = renderSinglePanelReport({
62
108
  run: input.run,
63
109
  output: input.panelOutputs[0]!,
@@ -67,6 +113,49 @@ export function decidePanelCompletion(
67
113
  return { kind: "complete", report };
68
114
  }
69
115
 
116
+ // Synthesis needs two candidates for select mode. A lower configured quorum
117
+ // still produces a useful, explicitly unsynthesized partial report instead
118
+ // of pretending that one panelist is a panel.
119
+ if (
120
+ (input.panelOutputs.length < required && !intentionalStops) ||
121
+ input.panelOutputs.length < 2
122
+ ) {
123
+ const report = renderPartialPanelReport({
124
+ run: { ...input.run, completionQuality: "partial" },
125
+ panelOutputs: input.panelOutputs,
126
+ failures: input.panelFailures,
127
+ required,
128
+ synthesis,
129
+ panel: input.profile.panel,
130
+ ...withJudgeModel(judgeModel),
131
+ });
132
+ const callerContract =
133
+ input.run.outputContract ?? detectCallerOutputContract(input.run.prompt);
134
+ if (callerContract) {
135
+ // A partial report must disclose its incomplete coverage, but that prose
136
+ // is forbidden by exact caller contracts. Do not publish a report that
137
+ // merely looks successful while violating the caller's protocol.
138
+ const validation = validateCallerOutput(callerContract, report);
139
+ if (!validation.ok) {
140
+ const error = `${validation.error} Fusion could not synthesize a contract-compliant result from below-quorum panel coverage.`;
141
+ return {
142
+ kind: "fail",
143
+ error,
144
+ report: renderFailureReport({
145
+ run: input.run,
146
+ error,
147
+ panelOutputs: input.panelOutputs,
148
+ failures: input.panelFailures,
149
+ ...withJudgeModel(judgeModel),
150
+ synthesis,
151
+ panel: input.profile.panel,
152
+ }),
153
+ };
154
+ }
155
+ }
156
+ return { kind: "complete", report };
157
+ }
158
+
70
159
  return {
71
160
  kind: "judge",
72
161
  params: buildJudgeSpawnParams({
@@ -75,6 +164,15 @@ export function decidePanelCompletion(
75
164
  panelOutputs: input.panelOutputs,
76
165
  failedPanelists: input.panelFailures,
77
166
  runId: input.run.id,
167
+ ...(input.run.outputContract
168
+ ? { callerContract: input.run.outputContract }
169
+ : {}),
170
+ ...(input.run.timeoutOverrides
171
+ ? { timeoutOverrides: input.run.timeoutOverrides }
172
+ : {}),
173
+ ...(input.run.effectiveTimeouts
174
+ ? { effectiveTimeouts: input.run.effectiveTimeouts }
175
+ : {}),
78
176
  }),
79
177
  missingRunIdError: input.fallbackJudge
80
178
  ? "pi-subagents spawn did not return a fallback judge run ID."
@@ -0,0 +1,22 @@
1
+ import type { MinimumSuccessfulPanelists } from "./types.js";
2
+
3
+ /**
4
+ * Resolves a configured panel-success policy to the number of successful
5
+ * panelists required for synthesis and agreement stopping.
6
+ */
7
+ export function resolveMinimumSuccessfulPanelists(
8
+ policy: MinimumSuccessfulPanelists | undefined,
9
+ panelSize: number,
10
+ ): number {
11
+ if (policy === "all") return panelSize;
12
+ if (typeof policy === "number") {
13
+ // A multi-member synthesis cannot truthfully claim a panel conclusion from
14
+ // one answer. Preserve one-member panels while making legacy numeric `1`
15
+ // behave as the minimum meaningful two-candidate quorum.
16
+ return panelSize > 1 ? Math.max(2, Math.min(policy, panelSize)) : 1;
17
+ }
18
+ // Fusion uses a quorum (half rounded up), not an absolute strict-majority
19
+ // vote: two independent completed answers are enough to synthesize a
20
+ // four-member panel while still requiring two of three.
21
+ return Math.ceil(panelSize / 2);
22
+ }
package/src/report.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ detectCallerOutputContract,
3
+ validateCallerOutput,
4
+ } from "./caller-contract.js";
1
5
  import {
2
6
  buildBlindLabelMap,
3
7
  type FailedPanelSummary,
@@ -23,6 +27,10 @@ type ReportRun = Pick<
23
27
  | "panelRunId"
24
28
  | "judgeRunId"
25
29
  | "panelStopReason"
30
+ | "outputContract"
31
+ | "completionQuality"
32
+ | "minimumSuccessfulPanelists"
33
+ | "effectiveTimeouts"
26
34
  > &
27
35
  Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
28
36
 
@@ -43,6 +51,16 @@ export interface RenderSinglePanelReportInput {
43
51
  judgeModel?: string;
44
52
  }
45
53
 
54
+ export interface RenderPartialPanelReportInput {
55
+ run: ReportRun;
56
+ panelOutputs: readonly PanelOutput[];
57
+ failures: readonly FailedPanelSummary[];
58
+ required: number;
59
+ synthesis: FusionSynthesisMode;
60
+ panel: readonly PanelMemberConfig[];
61
+ judgeModel?: string;
62
+ }
63
+
46
64
  export interface RenderJudgeReportInput {
47
65
  run: ReportRun;
48
66
  judgeOutput: string;
@@ -118,9 +136,13 @@ interface AgentStatusOptions {
118
136
  /** Lists the facets that no panelist covered, for a merge-mode failure. */
119
137
  function formatUncoveredFacets(
120
138
  panel?: readonly PanelMemberConfig[],
139
+ outputs: readonly PanelOutput[] = [],
121
140
  ): string | string[] {
122
141
  if (!panel?.length) return "Every configured facet is uncovered.";
123
- return panel.map((member) => {
142
+ const covered = new Set(outputs.map((output) => output.index));
143
+ const missing = panel.filter((_member, index) => !covered.has(index));
144
+ if (missing.length === 0) return "No configured facets are uncovered.";
145
+ return missing.map((member) => {
124
146
  const facet =
125
147
  member.question?.trim() ?? member.role?.trim() ?? "the whole task";
126
148
  return `- ${memberLabel(member)}: ${facet} (uncovered)`;
@@ -227,6 +249,9 @@ export function renderPanelFailureReport(
227
249
  export function renderSinglePanelReport(
228
250
  input: RenderSinglePanelReportInput,
229
251
  ): string {
252
+ const exactOutput = validExactCallerOutput(input.run, input.output.output);
253
+ if (exactOutput) return exactOutput;
254
+
230
255
  const panelName = formatPanelName(input.output);
231
256
  const sections: ReportSection[] = [
232
257
  {
@@ -291,6 +316,15 @@ export function renderSinglePanelReport(
291
316
  * Rewrites the neutral names the judge saw back to the configured member names.
292
317
  * Longest label first so "Candidate AA" is not partly replaced by "Candidate A".
293
318
  */
319
+ function validExactCallerOutput(
320
+ run: ReportRun,
321
+ output: string,
322
+ ): string | undefined {
323
+ const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
324
+ if (!contract) return undefined;
325
+ return validateCallerOutput(contract, output).ok ? output.trim() : undefined;
326
+ }
327
+
294
328
  function restoreBlindLabels(
295
329
  judgeOutput: string,
296
330
  panelOutputs: readonly PanelOutput[],
@@ -314,9 +348,68 @@ function restoreBlindLabels(
314
348
  return restored;
315
349
  }
316
350
 
351
+ export function renderPartialPanelReport(
352
+ input: RenderPartialPanelReportInput,
353
+ ): string {
354
+ const succeeded = input.panelOutputs.length;
355
+ const merger = input.synthesis === "merge";
356
+ const partial = `Partial panel coverage: ${succeeded} successful panelist(s), below the required quorum of ${input.required}. No ${merger ? "composer" : "judge"} synthesis was run.`;
357
+ const candidateText = input.panelOutputs
358
+ .map((output) => `### ${formatPanelName(output)}\n${output.output.trim()}`)
359
+ .join("\n\n");
360
+ const sections: ReportSection[] = [
361
+ { title: "Summary", content: partial },
362
+ {
363
+ title: "Agent Status",
364
+ content: formatAgentStatus({
365
+ panelOutputs: input.panelOutputs,
366
+ failures: input.failures,
367
+ judgeStatus: `not run - below quorum (${succeeded}/${input.required})`,
368
+ ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
369
+ synthesis: input.synthesis,
370
+ extra: ["- Completion quality: partial"],
371
+ }),
372
+ },
373
+ ...(merger
374
+ ? [
375
+ { title: "Coverage Map" as const, content: "Partial coverage only; surviving facet outputs are listed below." },
376
+ { title: "Combined Answer" as const, content: candidateText || "No usable panel output." },
377
+ {
378
+ title: "Gaps" as const,
379
+ content: formatUncoveredFacets(input.panel, input.panelOutputs),
380
+ },
381
+ { title: "Conflicts At Seams" as const, content: "Not synthesized because the composer quorum was not met." },
382
+ ]
383
+ : [
384
+ { title: "Consensus" as const, content: "Not synthesized because the panel quorum was not met." },
385
+ { title: "Disagreements" as const, content: "Not synthesized because the judge did not run." },
386
+ { title: "Unique Insights" as const, content: candidateText || "No usable panel output." },
387
+ { title: "Blind Spots" as const, content: "Unavailable perspectives and absent cross-panel synthesis can hide important issues." },
388
+ ]),
389
+ { title: "Recommendation", content: "Use the surviving panel output as incomplete evidence, not a final fusion recommendation." },
390
+ {
391
+ title: "Risks",
392
+ content: `Coverage is incomplete; ${input.failures.length} panelist(s) were unavailable${input.failures.some((failure) => failure.reason === "timeout") ? " (including timeout failures)" : ""}. Fusion did not retry any panelist.`,
393
+ },
394
+ { title: "Next Step", content: "Inspect the unavailable perspectives; after this terminal run, manually start a new /fusion run if full coverage is needed." },
395
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
396
+ ];
397
+ const runDetails = formatRunDetails({
398
+ panelOutputs: input.panelOutputs,
399
+ failures: input.failures,
400
+ ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
401
+ synthesis: input.synthesis,
402
+ });
403
+ if (runDetails) sections.splice(-1, 0, runDetails);
404
+ return renderReport(sections);
405
+ }
406
+
317
407
  export function renderJudgeReport(input: RenderJudgeReportInput): string {
318
408
  const panelOutputs = input.panelOutputs ?? [];
319
409
  const failures = input.failures ?? [];
410
+ const exactOutput = validExactCallerOutput(input.run, input.judgeOutput);
411
+ if (exactOutput) return exactOutput;
412
+
320
413
  const judgeOutput = input.blindPanelLabels
321
414
  ? restoreBlindLabels(input.judgeOutput, panelOutputs, failures)
322
415
  : input.judgeOutput;
@@ -382,7 +475,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
382
475
  const reportSections: ReportSection[] = [
383
476
  {
384
477
  title: "Summary",
385
- content: sections.get("Summary") ?? judgeSummary(panelOutputs, failures),
478
+ content:
479
+ input.run.completionQuality === "partial"
480
+ ? `Partial panel coverage: ${panelOutputs.length} successful panelist(s) and ${failures.length} unavailable perspective(s) were synthesized. ${sections.get("Summary") ?? ""}`.trim()
481
+ : (sections.get("Summary") ?? judgeSummary(panelOutputs, failures)),
386
482
  },
387
483
  {
388
484
  title: "Agent Status",
@@ -392,6 +488,9 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
392
488
  judgeStatus: "succeeded",
393
489
  ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
394
490
  ...(input.synthesis ? { synthesis: input.synthesis } : {}),
491
+ ...(input.run.completionQuality === "partial"
492
+ ? { extra: ["- Completion quality: partial (incomplete coverage)"] }
493
+ : {}),
395
494
  }),
396
495
  },
397
496
  ...synthesisSections,
@@ -401,7 +500,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
401
500
  },
402
501
  {
403
502
  title: "Risks",
404
- content: sections.get("Risks") ?? "Not specified by the judge.",
503
+ content:
504
+ input.run.completionQuality === "partial"
505
+ ? `Incomplete coverage: unavailable panel perspectives${failures.some((failure) => failure.reason === "timeout") ? " include timeout failures" : ""}. ${sections.get("Risks") ?? ""}`.trim()
506
+ : (sections.get("Risks") ?? "Not specified by the judge."),
405
507
  },
406
508
  {
407
509
  title: "Next Step",
@@ -22,6 +22,10 @@ export interface ExtractPanelResultsOptions {
22
22
  panel?: readonly PanelMemberConfig[];
23
23
  limit?: number;
24
24
  completedOnly?: boolean;
25
+ /** On a terminal workflow deadline, running slots become typed failures. */
26
+ terminalizeRunning?: boolean;
27
+ /** Compact events need an explicit public workflow slot, never array order. */
28
+ requireStableSlotIdentity?: boolean;
25
29
  stoppedPanelIndices?: readonly number[];
26
30
  }
27
31
 
@@ -63,20 +67,50 @@ export function extractPanelResults(
63
67
 
64
68
  const outputs: PanelOutput[] = [];
65
69
  const failures: FailedPanelSummary[] = [];
70
+ const fallbackFailureReason = failureReason(container.payload);
66
71
  const results =
67
72
  options.limit === undefined
68
73
  ? container.results
69
74
  : container.results.slice(0, options.limit);
70
- for (const [index, rawResult] of results.entries()) {
75
+ const seenSlots = new Set<number>();
76
+ for (const [arrayIndex, rawResult] of results.entries()) {
71
77
  if (options.completedOnly && !isCompletedResult(rawResult)) continue;
72
- const child = normalizeChildResult(rawResult, index, options);
78
+ const index = workflowSlotIndex(rawResult, arrayIndex, options);
79
+ if (index === undefined) {
80
+ return error(
81
+ "missing-result-field",
82
+ "Compact subagents result omitted a stable workflow slot identity.",
83
+ `${container.path}[${arrayIndex}]`,
84
+ );
85
+ }
86
+ if (options.limit !== undefined && index >= options.limit) {
87
+ return error(
88
+ "unknown-result-shape",
89
+ "Subagents result workflow slot is outside the configured panel.",
90
+ `${container.path}[${arrayIndex}]`,
91
+ );
92
+ }
93
+ if (seenSlots.has(index)) {
94
+ return error(
95
+ "unknown-result-shape",
96
+ "Subagents result repeated a workflow slot identity.",
97
+ `${container.path}[${arrayIndex}]`,
98
+ );
99
+ }
100
+ seenSlots.add(index);
101
+ const child = normalizeChildResult(
102
+ rawResult,
103
+ index,
104
+ options,
105
+ fallbackFailureReason,
106
+ );
73
107
  if (!child.ok) return child;
74
108
  if (child.status === "success") outputs.push(child.output);
75
109
  else failures.push(child.failure);
76
110
  }
77
111
 
78
112
  for (const index of options.stoppedPanelIndices ?? []) {
79
- if (index < results.length || index >= (options.limit ?? Infinity)) continue;
113
+ if (seenSlots.has(index) || index >= (options.limit ?? Infinity)) continue;
80
114
  const child = normalizeChildResult(
81
115
  {
82
116
  success: false,
@@ -84,12 +118,17 @@ export function extractPanelResults(
84
118
  },
85
119
  index,
86
120
  options,
121
+ fallbackFailureReason,
87
122
  );
88
123
  if (!child.ok) return child;
89
124
  if (child.status === "failed") failures.push(child.failure);
90
125
  }
91
126
 
92
127
  const runId = firstString(container.payload.runId, container.payload.id);
128
+ // Stable slot identity also gives consumers configuration order independent
129
+ // of compact-event ordering.
130
+ outputs.sort((left, right) => left.index - right.index);
131
+ failures.sort((left, right) => left.index - right.index);
93
132
  return {
94
133
  ok: true,
95
134
  outputs,
@@ -193,10 +232,36 @@ function findResultsContainer(
193
232
  );
194
233
  }
195
234
 
235
+ function workflowSlotIndex(
236
+ rawResult: unknown,
237
+ fallback: number,
238
+ options: ExtractPanelResultsOptions,
239
+ ): number | undefined {
240
+ if (!options.requireStableSlotIdentity) return fallback;
241
+ if (!isRecord(rawResult)) return undefined;
242
+ for (const candidate of [rawResult.index, rawResult.taskIndex, rawResult.stepIndex]) {
243
+ if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
244
+ return candidate;
245
+ }
246
+ }
247
+ const key = firstString(
248
+ rawResult.key,
249
+ rawResult.taskKey,
250
+ rawResult.stepKey,
251
+ rawResult.agent,
252
+ );
253
+ // Workflow scripts name their public child slots panel-1, panel-2, etc.
254
+ // Never infer an omitted compact-event slot from its array position: compact
255
+ // completion events can be reordered or omit failed children.
256
+ const match = key?.match(/^panel-([1-9]\d*)$/);
257
+ return match ? Number(match[1]) - 1 : undefined;
258
+ }
259
+
196
260
  function normalizeChildResult(
197
261
  rawResult: unknown,
198
262
  index: number,
199
263
  options: ExtractPanelResultsOptions,
264
+ fallbackFailureReason?: FailedPanelSummary["reason"],
200
265
  ):
201
266
  | { ok: true; status: "success"; output: PanelOutput }
202
267
  | { ok: true; status: "failed"; failure: FailedPanelSummary }
@@ -222,7 +287,9 @@ function normalizeChildResult(
222
287
 
223
288
  const artifactPath = extractArtifactPath(rawResult);
224
289
  const sessionPath = firstString(rawResult.sessionPath, rawResult.sessionFile);
225
- const status = classifyChildStatus(rawResult);
290
+ const terminalizedRunning =
291
+ options.terminalizeRunning === true && !isCompletedResult(rawResult);
292
+ const status = terminalizedRunning ? "failed" : classifyChildStatus(rawResult);
226
293
 
227
294
  if (status === "success") {
228
295
  const rawOutput = firstNonBlankString(
@@ -274,8 +341,13 @@ function normalizeChildResult(
274
341
  agent,
275
342
  summary: stoppedAfterAgreement
276
343
  ? "Stopped after strong panel agreement."
277
- : failureSummary(rawResult, artifactPath),
278
- reason: failureReason(rawResult, stoppedAfterAgreement),
344
+ : terminalizedRunning
345
+ ? "Panelist did not finish before the workflow deadline."
346
+ : failureSummary(rawResult, artifactPath),
347
+ reason:
348
+ (terminalizedRunning ? "timeout" : undefined) ??
349
+ failureReason(rawResult, stoppedAfterAgreement) ??
350
+ fallbackFailureReason,
279
351
  observation,
280
352
  artifactPath,
281
353
  sessionPath,
@@ -392,7 +464,12 @@ function failureReason(
392
464
  stoppedAfterAgreement = false,
393
465
  ): FailedPanelSummary["reason"] {
394
466
  if (stoppedAfterAgreement) return "stopped-after-agreement";
395
- if (result.timedOut === true) return "timeout";
467
+ if (
468
+ result.timedOut === true ||
469
+ /(?:timed out|timeout)/i.test(firstNonBlankString(result.error) ?? "")
470
+ ) {
471
+ return "timeout";
472
+ }
396
473
  if (result.interrupted === true) return "interrupted";
397
474
  return undefined;
398
475
  }