@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,3 +1,9 @@
1
+ import {
2
+ callerOutputContractInstructions,
3
+ detectCallerOutputContract,
4
+ } from "./caller-contract.js";
5
+ import { FusionArgsError } from "./errors.js";
6
+ import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
1
7
  import {
2
8
  PANEL_DECISION_CLOSE,
3
9
  PANEL_DECISION_OPEN,
@@ -8,6 +14,9 @@ import {
8
14
  memberLabel,
9
15
  panelItemLabel,
10
16
  resolveSynthesisMode,
17
+ type CallerOutputContract,
18
+ type EffectiveFusionTimeouts,
19
+ type FusionTimeoutOverrides,
11
20
  THINKING_LEVELS,
12
21
  type FailedPanelSummary,
13
22
  type FusionProfile,
@@ -25,6 +34,15 @@ export const FUSION_ACCEPTANCE_DISABLED = {
25
34
 
26
35
  export type FusionAcceptanceDisabled = typeof FUSION_ACCEPTANCE_DISABLED;
27
36
 
37
+ const DEFAULT_STAGE_TIMEOUT_MS = 900_000;
38
+ const DEFAULT_PANELIST_TIMEOUT_MS = 840_000;
39
+ const DEFAULT_PANEL_GRACE_MS = 5_000;
40
+ const DEFAULT_TOOL_BUDGET: ToolBudget = {
41
+ soft: 8,
42
+ hard: 12,
43
+ block: "*",
44
+ };
45
+
28
46
  export interface PanelSubagentTaskParams {
29
47
  agent: string;
30
48
  task: string;
@@ -34,6 +52,10 @@ export interface PanelSubagentTaskParams {
34
52
  skill: false;
35
53
  acceptance: FusionAcceptanceDisabled;
36
54
  model?: string;
55
+ /** Per-task cap so a panelist finalises before the workflow timeout. */
56
+ toolBudget: ToolBudget;
57
+ /** Child deadline, always shorter than the enclosing panel workflow. */
58
+ timeoutMs?: number;
37
59
  }
38
60
 
39
61
  export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
@@ -85,6 +107,10 @@ export interface BuildJudgeSpawnParamsInput {
85
107
  * and its position bias.
86
108
  */
87
109
  runId: string;
110
+ callerContract?: CallerOutputContract;
111
+ timeoutOverrides?: FusionTimeoutOverrides;
112
+ /** Persisted at panel start so restored fallback judges keep their deadline. */
113
+ effectiveTimeouts?: EffectiveFusionTimeouts;
88
114
  }
89
115
 
90
116
  const PANEL_OUTPUT_CONTRACT = [
@@ -152,7 +178,10 @@ export function appendThinkingSuffix(
152
178
  export function buildPanelSpawnParams(
153
179
  profile: FusionProfile,
154
180
  prompt: string,
181
+ callerContract?: CallerOutputContract,
182
+ timeoutOverrides?: FusionTimeoutOverrides,
155
183
  ): PanelSpawnParams {
184
+ const timeouts = resolveEffectiveTimeouts(profile, timeoutOverrides);
156
185
  const concurrency = profile.concurrency ?? profile.panel.length;
157
186
  const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
158
187
  (member, index) => ({
@@ -161,24 +190,31 @@ export function buildPanelSpawnParams(
161
190
  member,
162
191
  prompt,
163
192
  profile.stopWhenPanelAgrees === true,
193
+ profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
194
+ callerContract,
195
+ timeouts.panelistTimeoutMs,
164
196
  ),
165
197
  }),
166
198
  );
167
199
 
200
+ const requiredSuccessfulPanelists = resolveMinimumSuccessfulPanelists(
201
+ profile.minimumSuccessfulPanelists,
202
+ profile.panel.length,
203
+ );
204
+
168
205
  return {
169
206
  workflowScript: buildPanelWorkflowScript(
170
207
  tasks,
171
208
  concurrency,
172
209
  profile.stopWhenPanelAgrees === true,
210
+ requiredSuccessfulPanelists,
173
211
  ),
174
212
  async: true,
175
213
  context: profile.context ?? "fresh",
176
214
  output: true,
177
215
  outputMode: "inline",
178
216
  acceptance: FUSION_ACCEPTANCE_DISABLED,
179
- ...(profile.timeoutMs !== undefined
180
- ? { timeoutMs: profile.timeoutMs }
181
- : {}),
217
+ timeoutMs: timeouts.panelTimeoutMs,
182
218
  };
183
219
  }
184
220
 
@@ -197,9 +233,7 @@ export function buildJudgeSpawnParams(
197
233
  skill: false,
198
234
  acceptance: FUSION_ACCEPTANCE_DISABLED,
199
235
  ...(model ? { model } : {}),
200
- ...(input.profile.judgeToolBudget
201
- ? { toolBudget: input.profile.judgeToolBudget }
202
- : {}),
236
+ toolBudget: input.profile.judgeToolBudget ?? DEFAULT_TOOL_BUDGET,
203
237
  };
204
238
 
205
239
  return {
@@ -209,9 +243,9 @@ export function buildJudgeSpawnParams(
209
243
  output: true,
210
244
  outputMode: "inline",
211
245
  acceptance: FUSION_ACCEPTANCE_DISABLED,
212
- ...(input.profile.timeoutMs !== undefined
213
- ? { timeoutMs: input.profile.timeoutMs }
214
- : {}),
246
+ timeoutMs:
247
+ input.effectiveTimeouts?.judgeTimeoutMs ??
248
+ resolveEffectiveTimeouts(input.profile, input.timeoutOverrides).judgeTimeoutMs,
215
249
  };
216
250
  }
217
251
 
@@ -219,13 +253,18 @@ function buildPanelWorkflowScript(
219
253
  tasks: readonly PanelWorkflowTaskParams[],
220
254
  concurrency: number,
221
255
  stopWhenAgrees: boolean,
256
+ requiredSuccessfulPanelists: number,
222
257
  ): string {
223
258
  const serializedTasks = JSON.stringify(tasks);
259
+ // Start no more work than the resolved quorum requires. This preserves the
260
+ // two-at-a-time majority behavior while allowing a larger configured quorum
261
+ // to be observed before agreement can stop the remaining panelists.
224
262
  const effectiveConcurrency = stopWhenAgrees
225
- ? Math.min(concurrency, 2)
263
+ ? Math.min(concurrency, requiredSuccessfulPanelists)
226
264
  : concurrency;
227
265
  const stopLogic = stopWhenAgrees
228
266
  ? [
267
+ `const requiredSuccessfulPanelists = ${requiredSuccessfulPanelists};`,
229
268
  "const decisions = results",
230
269
  " .filter((result) => result && result.ok === true)",
231
270
  " .map((result) => {",
@@ -235,7 +274,7 @@ function buildPanelWorkflowScript(
235
274
  " try { return JSON.parse(match[1]); } catch { return undefined; }",
236
275
  " })",
237
276
  " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
238
- "if (decisions.length >= 2 && results.length < tasks.length) {",
277
+ "if (decisions.length >= requiredSuccessfulPanelists && results.length < tasks.length) {",
239
278
  " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
240
279
  " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
241
280
  " if (agrees) {",
@@ -260,20 +299,87 @@ function buildPanelWorkflowScript(
260
299
  .join("\n");
261
300
  }
262
301
 
302
+ function resolveStageTimeout(
303
+ stageTimeoutMs: number | undefined,
304
+ legacyTimeoutMs: number | undefined,
305
+ defaultTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
306
+ ): number {
307
+ return stageTimeoutMs ?? legacyTimeoutMs ?? defaultTimeoutMs;
308
+ }
309
+
310
+ /** Resolves and records the deadline precedence used for one start attempt. */
311
+ export function resolveEffectiveTimeouts(
312
+ profile: FusionProfile,
313
+ overrides: FusionTimeoutOverrides | undefined = undefined,
314
+ ): EffectiveFusionTimeouts {
315
+ const panelTimeoutMs = resolveStageTimeout(
316
+ overrides?.panelTimeoutMs ?? profile.panelTimeoutMs,
317
+ profile.timeoutMs,
318
+ );
319
+ const panelGraceMs = resolveStageTimeout(
320
+ overrides?.panelGraceMs ?? profile.panelGraceMs,
321
+ undefined,
322
+ DEFAULT_PANEL_GRACE_MS,
323
+ );
324
+ const requestedPanelistTimeoutMs = resolveStageTimeout(
325
+ overrides?.panelistTimeoutMs ?? profile.panelistTimeoutMs,
326
+ profile.timeoutMs,
327
+ DEFAULT_PANELIST_TIMEOUT_MS,
328
+ );
329
+ if (panelGraceMs >= panelTimeoutMs) {
330
+ throw new FusionArgsError(
331
+ `panelGraceMs (${panelGraceMs}ms) must be shorter than panelTimeoutMs (${panelTimeoutMs}ms).`,
332
+ );
333
+ }
334
+ // A child must conclude before the enclosing workflow. The validated grace
335
+ // interval guarantees this cap remains a meaningful deadline.
336
+ const panelistTimeoutMs = Math.min(
337
+ requestedPanelistTimeoutMs,
338
+ panelTimeoutMs - panelGraceMs,
339
+ );
340
+ return {
341
+ panelistTimeoutMs,
342
+ panelTimeoutMs,
343
+ panelGraceMs,
344
+ judgeTimeoutMs: resolveStageTimeout(
345
+ overrides?.judgeTimeoutMs ?? profile.judgeTimeoutMs,
346
+ profile.timeoutMs,
347
+ ),
348
+ usesLegacyTimeout:
349
+ profile.timeoutMs !== undefined &&
350
+ (overrides?.panelistTimeoutMs === undefined &&
351
+ profile.panelistTimeoutMs === undefined ||
352
+ overrides?.panelTimeoutMs === undefined &&
353
+ profile.panelTimeoutMs === undefined ||
354
+ overrides?.judgeTimeoutMs === undefined &&
355
+ profile.judgeTimeoutMs === undefined),
356
+ };
357
+ }
358
+
263
359
  function buildPanelTaskParams(
264
360
  member: PanelMemberConfig,
265
361
  prompt: string,
266
362
  includeDecisionRecord: boolean,
363
+ toolBudget: ToolBudget,
364
+ callerContract?: CallerOutputContract,
365
+ timeoutMs?: number,
267
366
  ): PanelSubagentTaskParams {
268
367
  const model = appendThinkingSuffix(member.model, member.thinking);
269
368
  return {
270
369
  agent: member.agent,
271
- task: buildPanelTask(member, prompt, includeDecisionRecord),
370
+ task: buildPanelTask(
371
+ member,
372
+ prompt,
373
+ includeDecisionRecord,
374
+ callerContract,
375
+ ),
272
376
  output: true,
273
377
  outputMode: "inline",
274
378
  progress: true,
275
379
  skill: false,
276
380
  acceptance: FUSION_ACCEPTANCE_DISABLED,
381
+ toolBudget,
382
+ ...(timeoutMs ? { timeoutMs } : {}),
277
383
  ...(model ? { model } : {}),
278
384
  };
279
385
  }
@@ -282,8 +388,11 @@ function buildPanelTask(
282
388
  member: PanelMemberConfig,
283
389
  prompt: string,
284
390
  includeDecisionRecord: boolean,
391
+ callerContractOverride?: CallerOutputContract,
285
392
  ): string {
286
393
  const role = member.role?.trim() || "independent analysis and critique";
394
+ const callerContract =
395
+ callerContractForPrompt(prompt, callerContractOverride);
287
396
  return [
288
397
  `Panel member: ${memberLabel(member)} (${member.id})`,
289
398
  `Role: ${role}`,
@@ -299,8 +408,10 @@ function buildPanelTask(
299
408
  "- Be concise and cite evidence when you inspect files.",
300
409
  "",
301
410
  "Output contract:",
302
- ...PANEL_OUTPUT_CONTRACT,
303
- ...(includeDecisionRecord
411
+ ...(callerContract
412
+ ? callerOutputContractInstructions(callerContract)
413
+ : PANEL_OUTPUT_CONTRACT),
414
+ ...(includeDecisionRecord && !callerContract
304
415
  ? [
305
416
  "",
306
417
  "Decision record:",
@@ -356,6 +467,13 @@ function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
356
467
  ];
357
468
  }
358
469
 
470
+ function callerContractForPrompt(
471
+ prompt: string,
472
+ explicit?: CallerOutputContract,
473
+ ): CallerOutputContract | undefined {
474
+ return explicit ?? detectCallerOutputContract(prompt);
475
+ }
476
+
359
477
  function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
360
478
  const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
361
479
  const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
@@ -366,6 +484,10 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
366
484
  ? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
367
485
  : undefined;
368
486
  const merging = resolveSynthesisMode(input.profile) === "merge";
487
+ const callerContract = callerContractForPrompt(
488
+ input.prompt,
489
+ input.callerContract,
490
+ );
369
491
  return [
370
492
  ...(merging
371
493
  ? COMPOSER_INSTRUCTIONS
@@ -399,7 +521,11 @@ function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
399
521
  // right" wording contradicts the composer's "do not rank panelists".
400
522
  ...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
401
523
  "Output contract:",
402
- ...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
524
+ ...(callerContract
525
+ ? callerOutputContractInstructions(callerContract)
526
+ : merging
527
+ ? COMPOSER_OUTPUT_CONTRACT
528
+ : JUDGE_OUTPUT_CONTRACT),
403
529
  ].join("\n");
404
530
  }
405
531