@alexeiled/pi-fusion 0.6.1 → 0.7.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,4 +1,8 @@
1
1
  import { applyClaudeAliasShorthand } from "./claude-aliases.js";
2
+ import {
3
+ detectCallerOutputContract,
4
+ validateCallerOutput,
5
+ } from "./caller-contract.js";
2
6
  import {
3
7
  buildInlinePanelProfile,
4
8
  loadFusionConfig,
@@ -17,6 +21,10 @@ import {
17
21
  extractPanelResults,
18
22
  type ExtractPanelResultsSuccess,
19
23
  } from "./result-extract.js";
24
+ import {
25
+ reconcileIndexedLifecycleResult,
26
+ reconcilePanelResults,
27
+ } from "./lifecycle-reconcile.js";
20
28
  import { appendThinkingSuffix, buildPanelSpawnParams } from "./run-builder.js";
21
29
  import { FusionRunStore, FusionRunStoreError } from "./run-store.js";
22
30
  import {
@@ -188,6 +196,8 @@ export class FusionOrchestrator {
188
196
  return { status: "failed", error: message };
189
197
  }
190
198
 
199
+ const outputContract =
200
+ args.outputContract ?? detectCallerOutputContract(args.prompt);
191
201
  let run: FusionRun;
192
202
  try {
193
203
  run = this.runStore.startRun({
@@ -199,6 +209,7 @@ export class FusionOrchestrator {
199
209
  ...(args.operationId !== undefined
200
210
  ? { operationId: args.operationId }
201
211
  : {}),
212
+ ...(outputContract ? { outputContract } : {}),
202
213
  phase: "panel",
203
214
  });
204
215
  } catch (error: unknown) {
@@ -219,7 +230,7 @@ export class FusionOrchestrator {
219
230
 
220
231
  try {
221
232
  const spawnResult = await this.rpc.spawn(
222
- buildPanelSpawnParams(resolved.profile, args.prompt),
233
+ buildPanelSpawnParams(resolved.profile, args.prompt, outputContract),
223
234
  );
224
235
  const spawnError = extractSubagentFailure(spawnResult);
225
236
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -585,19 +596,37 @@ export class FusionOrchestrator {
585
596
  );
586
597
  }
587
598
 
588
- const observedPanels = mergePanelObservations(
599
+ const observedPanels = reconcilePanelResults(
589
600
  extracted,
590
601
  snapshot.statusPayload,
591
602
  profile,
603
+ lifecyclePayload,
604
+ { allowedTrailingResults: 1 },
592
605
  );
606
+ if (!observedPanels.ok) {
607
+ return this.failActiveRun(
608
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
609
+ );
610
+ }
593
611
  const updated = this.storePanelResults(
594
612
  active.id,
595
613
  observedPanels.outputs,
596
614
  observedPanels.failures,
597
615
  );
598
616
 
599
- if (hasJudgeResult(snapshot.resultPayload, profile.panel.length)) {
600
- const output = extractJudgeOutput(snapshot.resultPayload, {
617
+ const judgePayload =
618
+ hasJudgeResult(snapshot.resultPayload, profile.panel.length)
619
+ ? snapshot.resultPayload
620
+ : snapshot.statusPayload;
621
+ if (hasJudgeResult(judgePayload, profile.panel.length)) {
622
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
623
+ snapshot.resultPayload,
624
+ snapshot.statusPayload,
625
+ profile.panel.length,
626
+ "judge",
627
+ );
628
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
629
+ const output = extractJudgeOutput(judgePayload, {
601
630
  resultIndex: profile.panel.length,
602
631
  });
603
632
  if (!output.ok) return this.failActiveRun(output.error);
@@ -608,13 +637,18 @@ export class FusionOrchestrator {
608
637
  snapshot.statusPayload,
609
638
  ),
610
639
  extractRunObservation(
611
- findResult(snapshot.resultPayload, profile.panel.length) ??
612
- snapshot.resultPayload,
640
+ findResult(judgePayload, profile.panel.length) ?? judgePayload,
613
641
  ),
614
642
  );
615
643
  const observed = this.runStore.updateRun(updated.id, {
616
644
  judgeObservation,
617
645
  });
646
+ const callerContract =
647
+ observed.outputContract ?? detectCallerOutputContract(observed.prompt);
648
+ if (callerContract) {
649
+ const validation = validateCallerOutput(callerContract, output.output);
650
+ if (!validation.ok) return this.failActiveRun(validation.error);
651
+ }
618
652
  const report = renderJudgeReport({
619
653
  run: observed,
620
654
  judgeOutput: output.output,
@@ -713,11 +747,18 @@ export class FusionOrchestrator {
713
747
  );
714
748
  }
715
749
 
716
- const observedPanels = mergePanelObservations(
750
+ const observedPanels = reconcilePanelResults(
717
751
  extracted,
718
752
  snapshot.statusPayload,
719
753
  profile,
754
+ lifecyclePayload,
755
+ { ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}) },
720
756
  );
757
+ if (!observedPanels.ok) {
758
+ return this.failActiveRun(
759
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
760
+ );
761
+ }
721
762
  const stored = this.storePanelResults(
722
763
  active.id,
723
764
  observedPanels.outputs,
@@ -875,8 +916,25 @@ export class FusionOrchestrator {
875
916
  return this.failActiveRun(lifecycleError);
876
917
  }
877
918
 
919
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
920
+ snapshot.resultPayload,
921
+ snapshot.statusPayload,
922
+ 0,
923
+ "judge",
924
+ );
925
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
926
+
878
927
  const output = extractJudgeOutput(lifecyclePayload);
879
- if (!output.ok) return this.failActiveRun(output.error);
928
+ if (!output.ok) {
929
+ const stepError = extractSubagentFailure(
930
+ findStepsArray(snapshot.statusPayload)[0],
931
+ );
932
+ const errors = [lifecycleError, stepError, output.error].filter(
933
+ (error, index, all): error is string =>
934
+ Boolean(error) && all.indexOf(error) === index,
935
+ );
936
+ return this.failActiveRun(errors.join(" "));
937
+ }
880
938
 
881
939
  // The panel and chain handlers already treat a missing profile as fatal.
882
940
  // Without the same guard here the run "succeeds" with a degraded report:
@@ -888,6 +946,13 @@ export class FusionOrchestrator {
888
946
  );
889
947
  }
890
948
 
949
+ const callerContract =
950
+ active.outputContract ?? detectCallerOutputContract(active.prompt);
951
+ if (callerContract) {
952
+ const validation = validateCallerOutput(callerContract, output.output);
953
+ if (!validation.ok) return this.failActiveRun(validation.error);
954
+ }
955
+
891
956
  const judgeModel = configuredJudgeModel(profile);
892
957
  const judgeObservation = mergeRunObservations(
893
958
  extractRunObservation(
@@ -1575,15 +1640,17 @@ function hasResultsArray(payload: unknown): boolean {
1575
1640
  }
1576
1641
 
1577
1642
  function hasJudgeResult(payload: unknown, panelCount: number): boolean {
1578
- return (findResultsArray(payload)?.length ?? 0) > panelCount;
1643
+ const count =
1644
+ findResultsArray(payload)?.length ?? findStepsArray(payload).length;
1645
+ return count > panelCount;
1579
1646
  }
1580
1647
 
1581
1648
  function findResult(
1582
1649
  payload: unknown,
1583
1650
  resultIndex?: number,
1584
1651
  ): Record<string, unknown> | undefined {
1585
- const results = findResultsArray(payload);
1586
- if (!results || results.length === 0) return undefined;
1652
+ const results = findResultsArray(payload) ?? findStepsArray(payload);
1653
+ if (results.length === 0) return undefined;
1587
1654
  if (resultIndex !== undefined) {
1588
1655
  const indexed = results[resultIndex];
1589
1656
  return isRecord(indexed) ? indexed : undefined;
@@ -1752,56 +1819,6 @@ function extractArtifactPath(
1752
1819
  return undefined;
1753
1820
  }
1754
1821
 
1755
- function mergePanelObservations(
1756
- result: ExtractPanelResultsSuccess,
1757
- statusPayload: unknown,
1758
- profile: FusionProfile,
1759
- ): ExtractPanelResultsSuccess {
1760
- const status = extractPanelResults(statusPayload, {
1761
- panel: profile.panel,
1762
- completedOnly: true,
1763
- limit: profile.panel.length,
1764
- });
1765
- if (!status.ok) return result;
1766
-
1767
- const observations = new Map<number, PanelOutput["observation"]>();
1768
- for (const output of status.outputs) {
1769
- observations.set(output.index, output.observation);
1770
- }
1771
- for (const failure of status.failures) {
1772
- observations.set(failure.index, failure.observation);
1773
- }
1774
-
1775
- return {
1776
- ...result,
1777
- outputs: result.outputs.map((output) =>
1778
- withMergedObservation(output, observations.get(output.index)),
1779
- ),
1780
- failures: result.failures.map((failure) =>
1781
- withMergedObservation(failure, observations.get(failure.index)),
1782
- ),
1783
- };
1784
- }
1785
-
1786
- function withMergedObservation<T extends PanelOutput | FailedPanelSummary>(
1787
- item: T,
1788
- statusObservation: PanelOutput["observation"] | undefined,
1789
- ): T {
1790
- const observation = mergeRunObservations(statusObservation, item.observation);
1791
- return hasObservationData(observation) ? { ...item, observation } : item;
1792
- }
1793
-
1794
- function hasObservationData(observation: PanelOutput["observation"]): boolean {
1795
- return Boolean(
1796
- observation &&
1797
- (observation.model ||
1798
- observation.durationMs !== undefined ||
1799
- observation.usage ||
1800
- observation.attempts ||
1801
- observation.providerFailures),
1802
- );
1803
- }
1804
-
1805
1822
  function shouldStopWhenPanelAgrees(
1806
1823
  profile: FusionProfile,
1807
1824
  outputs: readonly PanelOutput[],
@@ -1,4 +1,12 @@
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
+ renderSinglePanelReport,
9
+ } from "./report.js";
2
10
  import {
3
11
  appendThinkingSuffix,
4
12
  buildJudgeSpawnParams,
@@ -50,14 +58,53 @@ export function decidePanelCompletion(
50
58
  };
51
59
  }
52
60
 
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.
61
+ const intentionalStops =
62
+ input.profile.stopWhenPanelAgrees === true &&
63
+ input.panelOutputs.length >= 2 &&
64
+ input.panelFailures.length > 0 &&
65
+ input.panelFailures.every(
66
+ ({ reason }) => reason === "stopped-after-agreement",
67
+ );
57
68
  if (
58
- input.panelOutputs.length === 1 &&
59
- resolveSynthesisMode(input.profile) !== "merge"
69
+ input.panelOutputs.length < input.profile.panel.length &&
70
+ !intentionalStops
60
71
  ) {
72
+ const error = `Only ${input.panelOutputs.length} of ${input.profile.panel.length} fusion panelists completed successfully; ${input.panelFailures.length} panelist result(s) are also missing.`;
73
+ const report = renderFailureReport({
74
+ run: input.run,
75
+ error,
76
+ panelOutputs: input.panelOutputs,
77
+ failures: input.panelFailures,
78
+ ...withJudgeModel(judgeModel),
79
+ synthesis: resolveSynthesisMode(input.profile),
80
+ panel: input.profile.panel,
81
+ });
82
+ return { kind: "fail", error, report };
83
+ }
84
+
85
+ if (input.panelOutputs.length === 1) {
86
+ const callerContract =
87
+ input.run.outputContract ??
88
+ detectCallerOutputContract(input.run.prompt);
89
+ if (callerContract) {
90
+ const validation = validateCallerOutput(
91
+ callerContract,
92
+ input.panelOutputs[0]!.output,
93
+ );
94
+ if (!validation.ok) {
95
+ const report = renderFailureReport({
96
+ run: input.run,
97
+ error: validation.error,
98
+ panelOutputs: input.panelOutputs,
99
+ failures: input.panelFailures,
100
+ ...withJudgeModel(judgeModel),
101
+ synthesis: resolveSynthesisMode(input.profile),
102
+ panel: input.profile.panel,
103
+ });
104
+ return { kind: "fail", error: validation.error, report };
105
+ }
106
+ }
107
+
61
108
  const report = renderSinglePanelReport({
62
109
  run: input.run,
63
110
  output: input.panelOutputs[0]!,
@@ -75,6 +122,9 @@ export function decidePanelCompletion(
75
122
  panelOutputs: input.panelOutputs,
76
123
  failedPanelists: input.panelFailures,
77
124
  runId: input.run.id,
125
+ ...(input.run.outputContract
126
+ ? { callerContract: input.run.outputContract }
127
+ : {}),
78
128
  }),
79
129
  missingRunIdError: input.fallbackJudge
80
130
  ? "pi-subagents spawn did not return a fallback judge run ID."
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,7 @@ type ReportRun = Pick<
23
27
  | "panelRunId"
24
28
  | "judgeRunId"
25
29
  | "panelStopReason"
30
+ | "outputContract"
26
31
  > &
27
32
  Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
28
33
 
@@ -227,6 +232,9 @@ export function renderPanelFailureReport(
227
232
  export function renderSinglePanelReport(
228
233
  input: RenderSinglePanelReportInput,
229
234
  ): string {
235
+ const exactOutput = validExactCallerOutput(input.run, input.output.output);
236
+ if (exactOutput) return exactOutput;
237
+
230
238
  const panelName = formatPanelName(input.output);
231
239
  const sections: ReportSection[] = [
232
240
  {
@@ -291,6 +299,15 @@ export function renderSinglePanelReport(
291
299
  * Rewrites the neutral names the judge saw back to the configured member names.
292
300
  * Longest label first so "Candidate AA" is not partly replaced by "Candidate A".
293
301
  */
302
+ function validExactCallerOutput(
303
+ run: ReportRun,
304
+ output: string,
305
+ ): string | undefined {
306
+ const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
307
+ if (!contract) return undefined;
308
+ return validateCallerOutput(contract, output).ok ? output.trim() : undefined;
309
+ }
310
+
294
311
  function restoreBlindLabels(
295
312
  judgeOutput: string,
296
313
  panelOutputs: readonly PanelOutput[],
@@ -317,6 +334,9 @@ function restoreBlindLabels(
317
334
  export function renderJudgeReport(input: RenderJudgeReportInput): string {
318
335
  const panelOutputs = input.panelOutputs ?? [];
319
336
  const failures = input.failures ?? [];
337
+ const exactOutput = validExactCallerOutput(input.run, input.judgeOutput);
338
+ if (exactOutput) return exactOutput;
339
+
320
340
  const judgeOutput = input.blindPanelLabels
321
341
  ? restoreBlindLabels(input.judgeOutput, panelOutputs, failures)
322
342
  : input.judgeOutput;
@@ -63,13 +63,19 @@ export function extractPanelResults(
63
63
 
64
64
  const outputs: PanelOutput[] = [];
65
65
  const failures: FailedPanelSummary[] = [];
66
+ const fallbackFailureReason = failureReason(container.payload);
66
67
  const results =
67
68
  options.limit === undefined
68
69
  ? container.results
69
70
  : container.results.slice(0, options.limit);
70
71
  for (const [index, rawResult] of results.entries()) {
71
72
  if (options.completedOnly && !isCompletedResult(rawResult)) continue;
72
- const child = normalizeChildResult(rawResult, index, options);
73
+ const child = normalizeChildResult(
74
+ rawResult,
75
+ index,
76
+ options,
77
+ fallbackFailureReason,
78
+ );
73
79
  if (!child.ok) return child;
74
80
  if (child.status === "success") outputs.push(child.output);
75
81
  else failures.push(child.failure);
@@ -84,6 +90,7 @@ export function extractPanelResults(
84
90
  },
85
91
  index,
86
92
  options,
93
+ fallbackFailureReason,
87
94
  );
88
95
  if (!child.ok) return child;
89
96
  if (child.status === "failed") failures.push(child.failure);
@@ -197,6 +204,7 @@ function normalizeChildResult(
197
204
  rawResult: unknown,
198
205
  index: number,
199
206
  options: ExtractPanelResultsOptions,
207
+ fallbackFailureReason?: FailedPanelSummary["reason"],
200
208
  ):
201
209
  | { ok: true; status: "success"; output: PanelOutput }
202
210
  | { ok: true; status: "failed"; failure: FailedPanelSummary }
@@ -275,7 +283,9 @@ function normalizeChildResult(
275
283
  summary: stoppedAfterAgreement
276
284
  ? "Stopped after strong panel agreement."
277
285
  : failureSummary(rawResult, artifactPath),
278
- reason: failureReason(rawResult, stoppedAfterAgreement),
286
+ reason:
287
+ failureReason(rawResult, stoppedAfterAgreement) ??
288
+ fallbackFailureReason,
279
289
  observation,
280
290
  artifactPath,
281
291
  sessionPath,
@@ -392,7 +402,12 @@ function failureReason(
392
402
  stoppedAfterAgreement = false,
393
403
  ): FailedPanelSummary["reason"] {
394
404
  if (stoppedAfterAgreement) return "stopped-after-agreement";
395
- if (result.timedOut === true) return "timeout";
405
+ if (
406
+ result.timedOut === true ||
407
+ /(?:timed out|timeout)/i.test(firstNonBlankString(result.error) ?? "")
408
+ ) {
409
+ return "timeout";
410
+ }
396
411
  if (result.interrupted === true) return "interrupted";
397
412
  return undefined;
398
413
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ callerOutputContractInstructions,
3
+ detectCallerOutputContract,
4
+ } from "./caller-contract.js";
1
5
  import {
2
6
  PANEL_DECISION_CLOSE,
3
7
  PANEL_DECISION_OPEN,
@@ -8,6 +12,7 @@ import {
8
12
  memberLabel,
9
13
  panelItemLabel,
10
14
  resolveSynthesisMode,
15
+ type CallerOutputContract,
11
16
  THINKING_LEVELS,
12
17
  type FailedPanelSummary,
13
18
  type FusionProfile,
@@ -25,6 +30,13 @@ export const FUSION_ACCEPTANCE_DISABLED = {
25
30
 
26
31
  export type FusionAcceptanceDisabled = typeof FUSION_ACCEPTANCE_DISABLED;
27
32
 
33
+ const DEFAULT_STAGE_TIMEOUT_MS = 900_000;
34
+ const DEFAULT_TOOL_BUDGET: ToolBudget = {
35
+ soft: 8,
36
+ hard: 12,
37
+ block: "*",
38
+ };
39
+
28
40
  export interface PanelSubagentTaskParams {
29
41
  agent: string;
30
42
  task: string;
@@ -34,6 +46,8 @@ export interface PanelSubagentTaskParams {
34
46
  skill: false;
35
47
  acceptance: FusionAcceptanceDisabled;
36
48
  model?: string;
49
+ /** Per-task cap so a panelist finalises before the workflow timeout. */
50
+ toolBudget: ToolBudget;
37
51
  }
38
52
 
39
53
  export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
@@ -43,7 +57,6 @@ export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
43
57
  export interface PanelSpawnParams {
44
58
  workflowScript: string;
45
59
  async: true;
46
- clarify: false;
47
60
  context: "fresh" | "fork";
48
61
  output: true;
49
62
  outputMode: "inline";
@@ -51,22 +64,28 @@ export interface PanelSpawnParams {
51
64
  timeoutMs?: number;
52
65
  }
53
66
 
54
- export interface JudgeSpawnParams {
67
+ export interface JudgeWorkflowTaskParams {
55
68
  agent: string;
56
69
  task: string;
57
- async: true;
58
- clarify: false;
59
- context: "fresh" | "fork";
60
70
  output: true;
61
71
  outputMode: "inline";
62
72
  skill: false;
63
73
  acceptance: FusionAcceptanceDisabled;
64
74
  model?: string;
65
- timeoutMs?: number;
66
75
  /** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
67
76
  toolBudget?: ToolBudget;
68
77
  }
69
78
 
79
+ export interface JudgeSpawnParams {
80
+ workflowScript: string;
81
+ async: true;
82
+ context: "fresh" | "fork";
83
+ output: true;
84
+ outputMode: "inline";
85
+ acceptance: FusionAcceptanceDisabled;
86
+ timeoutMs?: number;
87
+ }
88
+
70
89
  export type { FailedPanelSummary, PanelOutput } from "./types.js";
71
90
 
72
91
  export interface BuildJudgeSpawnParamsInput {
@@ -80,6 +99,7 @@ export interface BuildJudgeSpawnParamsInput {
80
99
  * and its position bias.
81
100
  */
82
101
  runId: string;
102
+ callerContract?: CallerOutputContract;
83
103
  }
84
104
 
85
105
  const PANEL_OUTPUT_CONTRACT = [
@@ -147,6 +167,7 @@ export function appendThinkingSuffix(
147
167
  export function buildPanelSpawnParams(
148
168
  profile: FusionProfile,
149
169
  prompt: string,
170
+ callerContract?: CallerOutputContract,
150
171
  ): PanelSpawnParams {
151
172
  const concurrency = profile.concurrency ?? profile.panel.length;
152
173
  const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
@@ -156,6 +177,8 @@ export function buildPanelSpawnParams(
156
177
  member,
157
178
  prompt,
158
179
  profile.stopWhenPanelAgrees === true,
180
+ profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
181
+ callerContract,
159
182
  ),
160
183
  }),
161
184
  );
@@ -167,14 +190,11 @@ export function buildPanelSpawnParams(
167
190
  profile.stopWhenPanelAgrees === true,
168
191
  ),
169
192
  async: true,
170
- clarify: false,
171
193
  context: profile.context ?? "fresh",
172
194
  output: true,
173
195
  outputMode: "inline",
174
196
  acceptance: FUSION_ACCEPTANCE_DISABLED,
175
- ...(profile.timeoutMs !== undefined
176
- ? { timeoutMs: profile.timeoutMs }
177
- : {}),
197
+ timeoutMs: resolveStageTimeout(profile.panelTimeoutMs, profile.timeoutMs),
178
198
  };
179
199
  }
180
200
 
@@ -185,23 +205,28 @@ export function buildJudgeSpawnParams(
185
205
  input.profile.judge.model,
186
206
  input.profile.judge.thinking,
187
207
  );
188
- return {
208
+ const task: JudgeWorkflowTaskParams = {
189
209
  agent: resolveSynthesisAgent(input.profile),
190
210
  task: buildJudgeTask(input),
191
- async: true,
192
- clarify: false,
193
- context: input.profile.context ?? "fresh",
194
211
  output: true,
195
212
  outputMode: "inline",
196
213
  skill: false,
197
214
  acceptance: FUSION_ACCEPTANCE_DISABLED,
198
215
  ...(model ? { model } : {}),
199
- ...(input.profile.judgeToolBudget
200
- ? { toolBudget: input.profile.judgeToolBudget }
201
- : {}),
202
- ...(input.profile.timeoutMs !== undefined
203
- ? { timeoutMs: input.profile.timeoutMs }
204
- : {}),
216
+ toolBudget: input.profile.judgeToolBudget ?? DEFAULT_TOOL_BUDGET,
217
+ };
218
+
219
+ return {
220
+ workflowScript: `return runs.run("judge", ${JSON.stringify(task)});`,
221
+ async: true,
222
+ context: input.profile.context ?? "fresh",
223
+ output: true,
224
+ outputMode: "inline",
225
+ acceptance: FUSION_ACCEPTANCE_DISABLED,
226
+ timeoutMs: resolveStageTimeout(
227
+ input.profile.judgeTimeoutMs,
228
+ input.profile.timeoutMs,
229
+ ),
205
230
  };
206
231
  }
207
232
 
@@ -250,20 +275,35 @@ function buildPanelWorkflowScript(
250
275
  .join("\n");
251
276
  }
252
277
 
278
+ function resolveStageTimeout(
279
+ stageTimeoutMs: number | undefined,
280
+ legacyTimeoutMs: number | undefined,
281
+ ): number {
282
+ return stageTimeoutMs ?? legacyTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
283
+ }
284
+
253
285
  function buildPanelTaskParams(
254
286
  member: PanelMemberConfig,
255
287
  prompt: string,
256
288
  includeDecisionRecord: boolean,
289
+ toolBudget: ToolBudget,
290
+ callerContract?: CallerOutputContract,
257
291
  ): PanelSubagentTaskParams {
258
292
  const model = appendThinkingSuffix(member.model, member.thinking);
259
293
  return {
260
294
  agent: member.agent,
261
- task: buildPanelTask(member, prompt, includeDecisionRecord),
295
+ task: buildPanelTask(
296
+ member,
297
+ prompt,
298
+ includeDecisionRecord,
299
+ callerContract,
300
+ ),
262
301
  output: true,
263
302
  outputMode: "inline",
264
303
  progress: true,
265
304
  skill: false,
266
305
  acceptance: FUSION_ACCEPTANCE_DISABLED,
306
+ toolBudget,
267
307
  ...(model ? { model } : {}),
268
308
  };
269
309
  }
@@ -272,8 +312,11 @@ function buildPanelTask(
272
312
  member: PanelMemberConfig,
273
313
  prompt: string,
274
314
  includeDecisionRecord: boolean,
315
+ callerContractOverride?: CallerOutputContract,
275
316
  ): string {
276
317
  const role = member.role?.trim() || "independent analysis and critique";
318
+ const callerContract =
319
+ callerContractForPrompt(prompt, callerContractOverride);
277
320
  return [
278
321
  `Panel member: ${memberLabel(member)} (${member.id})`,
279
322
  `Role: ${role}`,
@@ -289,8 +332,10 @@ function buildPanelTask(
289
332
  "- Be concise and cite evidence when you inspect files.",
290
333
  "",
291
334
  "Output contract:",
292
- ...PANEL_OUTPUT_CONTRACT,
293
- ...(includeDecisionRecord
335
+ ...(callerContract
336
+ ? callerOutputContractInstructions(callerContract)
337
+ : PANEL_OUTPUT_CONTRACT),
338
+ ...(includeDecisionRecord && !callerContract
294
339
  ? [
295
340
  "",
296
341
  "Decision record:",
@@ -346,6 +391,13 @@ function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
346
391
  ];
347
392
  }
348
393
 
394
+ function callerContractForPrompt(
395
+ prompt: string,
396
+ explicit?: CallerOutputContract,
397
+ ): CallerOutputContract | undefined {
398
+ return explicit ?? detectCallerOutputContract(prompt);
399
+ }
400
+
349
401
  function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
350
402
  const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
351
403
  const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
@@ -356,6 +408,10 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
356
408
  ? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
357
409
  : undefined;
358
410
  const merging = resolveSynthesisMode(input.profile) === "merge";
411
+ const callerContract = callerContractForPrompt(
412
+ input.prompt,
413
+ input.callerContract,
414
+ );
359
415
  return [
360
416
  ...(merging
361
417
  ? COMPOSER_INSTRUCTIONS
@@ -389,7 +445,11 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
389
445
  // right" wording contradicts the composer's "do not rank panelists".
390
446
  ...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
391
447
  "Output contract:",
392
- ...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
448
+ ...(callerContract
449
+ ? callerOutputContractInstructions(callerContract)
450
+ : merging
451
+ ? COMPOSER_OUTPUT_CONTRACT
452
+ : JUDGE_OUTPUT_CONTRACT),
393
453
  ].join("\n");
394
454
  }
395
455