@alexeiled/pi-fusion 0.5.2 → 0.6.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.
- package/README.md +22 -9
- package/agents/fusion-composer.md +49 -0
- package/agents/fusion-judge.md +7 -0
- package/agents/fusion-panelist-full.md +53 -0
- package/agents/fusion-panelist-web.md +41 -0
- package/agents/fusion-panelist.md +2 -0
- package/docs/user-guide.md +200 -19
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +69 -18
- package/src/commands.ts +1 -0
- package/src/config.ts +167 -5
- package/src/fusion-args.ts +36 -2
- package/src/index.ts +11 -2
- package/src/orchestrator.ts +75 -14
- package/src/panel-completion.ts +17 -6
- package/src/report.ts +233 -78
- package/src/run-builder.ts +242 -26
- package/src/run-store.ts +31 -0
- package/src/types.ts +89 -1
package/src/run-builder.ts
CHANGED
|
@@ -3,12 +3,18 @@ import {
|
|
|
3
3
|
PANEL_DECISION_OPEN,
|
|
4
4
|
} from "./run-observations.js";
|
|
5
5
|
import {
|
|
6
|
+
COMPOSER_AGENT,
|
|
7
|
+
JUDGE_AGENT,
|
|
8
|
+
memberLabel,
|
|
9
|
+
panelItemLabel,
|
|
10
|
+
resolveSynthesisMode,
|
|
6
11
|
THINKING_LEVELS,
|
|
7
12
|
type FailedPanelSummary,
|
|
8
13
|
type FusionProfile,
|
|
9
14
|
type PanelMemberConfig,
|
|
10
15
|
type PanelOutput,
|
|
11
16
|
type ThinkingLevel,
|
|
17
|
+
type ToolBudget,
|
|
12
18
|
} from "./types.js";
|
|
13
19
|
|
|
14
20
|
export const FUSION_ACCEPTANCE_DISABLED = {
|
|
@@ -90,6 +96,8 @@ export interface JudgeSpawnParams {
|
|
|
90
96
|
acceptance: FusionAcceptanceDisabled;
|
|
91
97
|
model?: string;
|
|
92
98
|
timeoutMs?: number;
|
|
99
|
+
/** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
|
|
100
|
+
toolBudget?: ToolBudget;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
export type { FailedPanelSummary, PanelOutput } from "./types.js";
|
|
@@ -99,6 +107,12 @@ export interface BuildJudgeSpawnParamsInput {
|
|
|
99
107
|
prompt: string;
|
|
100
108
|
panelOutputs: readonly PanelOutput[];
|
|
101
109
|
failedPanelists: readonly FailedPanelSummary[];
|
|
110
|
+
/**
|
|
111
|
+
* Seeds the order panel answers are presented to the judge. Required rather
|
|
112
|
+
* than optional: a missing seed would silently restore the fixed index order
|
|
113
|
+
* and its position bias.
|
|
114
|
+
*/
|
|
115
|
+
runId: string;
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
const PANEL_OUTPUT_CONTRACT = [
|
|
@@ -116,6 +130,7 @@ const JUDGE_OUTPUT_CONTRACT = [
|
|
|
116
130
|
"## Agent Status",
|
|
117
131
|
"## Consensus",
|
|
118
132
|
"## Disagreements",
|
|
133
|
+
"## Contested Claims",
|
|
119
134
|
"## Unique Insights",
|
|
120
135
|
"## Blind Spots",
|
|
121
136
|
"## Recommendation",
|
|
@@ -123,6 +138,36 @@ const JUDGE_OUTPUT_CONTRACT = [
|
|
|
123
138
|
"## Next Step",
|
|
124
139
|
] as const;
|
|
125
140
|
|
|
141
|
+
const COMPOSER_OUTPUT_CONTRACT = [
|
|
142
|
+
"# Fusion Report",
|
|
143
|
+
"## Summary",
|
|
144
|
+
"## Coverage Map",
|
|
145
|
+
"## Combined Answer",
|
|
146
|
+
"## Gaps",
|
|
147
|
+
"## Conflicts At Seams",
|
|
148
|
+
"## Agent Status",
|
|
149
|
+
"## Risks",
|
|
150
|
+
"## Next Step",
|
|
151
|
+
] as const;
|
|
152
|
+
|
|
153
|
+
const COMPOSER_INSTRUCTIONS = [
|
|
154
|
+
"You are the fusion composer.",
|
|
155
|
+
"Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
|
|
156
|
+
"The panelists answered DIFFERENT facets of one task. Merge their answers; do not pick a winner.",
|
|
157
|
+
"- Do not rank panelists. They were not competing.",
|
|
158
|
+
"- Report a conflict only where facets genuinely overlap and disagree. Different subject matter is not disagreement.",
|
|
159
|
+
"- Name facets nobody covered, or covered only in passing.",
|
|
160
|
+
"- Where facets overlap and state conflicting facts about this codebase, check the claim with your read tools and cite file:line under Conflicts At Seams. Do not settle it by whose wording sounds more confident.",
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
const CONTESTED_CLAIMS_INSTRUCTIONS = [
|
|
164
|
+
"Contested claims:",
|
|
165
|
+
"- Where panelists state conflicting facts about this codebase, do not pick the more confident wording.",
|
|
166
|
+
"- Check the claim yourself with your read tools and cite file:line.",
|
|
167
|
+
"- Report each contested claim as: the claim, what you found, and which panelist was right.",
|
|
168
|
+
"- If you could not verify a claim, say so explicitly rather than choosing.",
|
|
169
|
+
] as const;
|
|
170
|
+
|
|
126
171
|
export function appendThinkingSuffix(
|
|
127
172
|
model: string | undefined,
|
|
128
173
|
thinking: ThinkingLevel | undefined,
|
|
@@ -207,7 +252,7 @@ export function buildJudgeSpawnParams(
|
|
|
207
252
|
input.profile.judge.thinking,
|
|
208
253
|
);
|
|
209
254
|
return {
|
|
210
|
-
agent: input.profile
|
|
255
|
+
agent: resolveSynthesisAgent(input.profile),
|
|
211
256
|
task: buildJudgeTask(input),
|
|
212
257
|
async: true,
|
|
213
258
|
clarify: false,
|
|
@@ -217,6 +262,9 @@ export function buildJudgeSpawnParams(
|
|
|
217
262
|
skill: false,
|
|
218
263
|
acceptance: FUSION_ACCEPTANCE_DISABLED,
|
|
219
264
|
...(model ? { model } : {}),
|
|
265
|
+
...(input.profile.judgeToolBudget
|
|
266
|
+
? { toolBudget: input.profile.judgeToolBudget }
|
|
267
|
+
: {}),
|
|
220
268
|
...(input.profile.timeoutMs !== undefined
|
|
221
269
|
? { timeoutMs: input.profile.timeoutMs }
|
|
222
270
|
: {}),
|
|
@@ -250,7 +298,7 @@ function buildPanelChainTaskParams(
|
|
|
250
298
|
agent: member.agent,
|
|
251
299
|
task: buildPanelTask(member, "{task}", false),
|
|
252
300
|
as: chainOutputName(member, index),
|
|
253
|
-
label: member
|
|
301
|
+
label: memberLabel(member),
|
|
254
302
|
phase: "Panel",
|
|
255
303
|
output: true,
|
|
256
304
|
outputMode: "inline",
|
|
@@ -268,11 +316,10 @@ function buildPanelTask(
|
|
|
268
316
|
): string {
|
|
269
317
|
const role = member.role?.trim() || "independent analysis and critique";
|
|
270
318
|
return [
|
|
271
|
-
`Panel member: ${member
|
|
319
|
+
`Panel member: ${memberLabel(member)} (${member.id})`,
|
|
272
320
|
`Role: ${role}`,
|
|
273
321
|
"",
|
|
274
|
-
|
|
275
|
-
prompt.trim(),
|
|
322
|
+
...formatMemberTask(member, prompt),
|
|
276
323
|
"",
|
|
277
324
|
"Instructions:",
|
|
278
325
|
"- Work independently from the other panelists.",
|
|
@@ -300,31 +347,120 @@ function buildPanelTask(
|
|
|
300
347
|
].join("\n");
|
|
301
348
|
}
|
|
302
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Merge mode swaps the synthesis agent, not the run slot. It still spawns into
|
|
352
|
+
* `judgeRunId`/`judgeAsyncDir` under phase `judge`, so `fusion:rpc:v1` consumers
|
|
353
|
+
* see no new phase value.
|
|
354
|
+
*
|
|
355
|
+
* An explicitly configured judge agent still wins: a user who names their own
|
|
356
|
+
* synthesis agent means it.
|
|
357
|
+
*/
|
|
358
|
+
function resolveSynthesisAgent(profile: FusionProfile): string {
|
|
359
|
+
if (resolveSynthesisMode(profile) !== "merge") return profile.judge.agent;
|
|
360
|
+
return profile.judge.agent === JUDGE_AGENT
|
|
361
|
+
? COMPOSER_AGENT
|
|
362
|
+
: profile.judge.agent;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const TASK_PLACEHOLDER = "{task}";
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* A member with a `question` answers that facet instead of the whole prompt.
|
|
369
|
+
* If the template omits `{task}` the original prompt is still appended: dropping
|
|
370
|
+
* it would silently strip the context the panelist needs.
|
|
371
|
+
*/
|
|
372
|
+
function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
|
|
373
|
+
const question = member.question?.trim();
|
|
374
|
+
const task = prompt.trim();
|
|
375
|
+
if (!question) return ["Original task:", task];
|
|
376
|
+
|
|
377
|
+
const facet = question.replaceAll(TASK_PLACEHOLDER, task);
|
|
378
|
+
if (question.includes(TASK_PLACEHOLDER)) {
|
|
379
|
+
return ["Your assigned facet of the task:", facet];
|
|
380
|
+
}
|
|
381
|
+
return [
|
|
382
|
+
"Your assigned facet of the task:",
|
|
383
|
+
facet,
|
|
384
|
+
"",
|
|
385
|
+
"Original task:",
|
|
386
|
+
task,
|
|
387
|
+
];
|
|
388
|
+
}
|
|
389
|
+
|
|
303
390
|
function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
|
|
304
391
|
const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
|
|
305
392
|
const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
|
|
393
|
+
// Status and failure lists stay in configuration order so the reader can map
|
|
394
|
+
// them to the profile. Only the answers the judge weighs are shuffled.
|
|
395
|
+
const presentedOutputs = shufflePanelItems(sortedOutputs, input.runId);
|
|
396
|
+
const blindLabels = input.profile.blindPanelLabels
|
|
397
|
+
? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
|
|
398
|
+
: undefined;
|
|
399
|
+
const merging = resolveSynthesisMode(input.profile) === "merge";
|
|
306
400
|
return [
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
401
|
+
...(merging
|
|
402
|
+
? COMPOSER_INSTRUCTIONS
|
|
403
|
+
: [
|
|
404
|
+
"You are the fusion judge.",
|
|
405
|
+
"Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
|
|
406
|
+
"Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
|
|
407
|
+
]),
|
|
310
408
|
"",
|
|
311
409
|
"Original task:",
|
|
312
410
|
input.prompt.trim(),
|
|
313
411
|
"",
|
|
314
412
|
"Panel status:",
|
|
315
|
-
...formatPanelStatus(sortedOutputs, sortedFailures),
|
|
413
|
+
...formatPanelStatus(sortedOutputs, sortedFailures, blindLabels),
|
|
316
414
|
"",
|
|
415
|
+
...(merging
|
|
416
|
+
? [
|
|
417
|
+
"Facet assignments:",
|
|
418
|
+
...formatFacetAssignments(input.profile, input.prompt, blindLabels),
|
|
419
|
+
"",
|
|
420
|
+
]
|
|
421
|
+
: []),
|
|
317
422
|
"Successful panel outputs:",
|
|
318
|
-
...formatPanelOutputs(
|
|
423
|
+
...formatPanelOutputs(presentedOutputs, blindLabels),
|
|
319
424
|
"",
|
|
320
425
|
"Failed panelists:",
|
|
321
|
-
...formatFailedPanelists(sortedFailures),
|
|
426
|
+
...formatFailedPanelists(sortedFailures, blindLabels),
|
|
322
427
|
"",
|
|
428
|
+
// Judge-only. The composer has no Contested Claims section, so this block
|
|
429
|
+
// produces content the report silently drops, and its "which panelist was
|
|
430
|
+
// right" wording contradicts the composer's "do not rank panelists".
|
|
431
|
+
...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
|
|
323
432
|
"Output contract:",
|
|
324
|
-
...JUDGE_OUTPUT_CONTRACT,
|
|
433
|
+
...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
|
|
325
434
|
].join("\n");
|
|
326
435
|
}
|
|
327
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Lists what each member was asked to cover, so the composer can name gaps
|
|
439
|
+
* without reverse-engineering them from the answers it did receive.
|
|
440
|
+
*/
|
|
441
|
+
function formatFacetAssignments(
|
|
442
|
+
profile: FusionProfile,
|
|
443
|
+
prompt: string,
|
|
444
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
445
|
+
): string[] {
|
|
446
|
+
return profile.panel.map((member, index) => {
|
|
447
|
+
// Substituted the same way the panelist saw it; the raw template would put
|
|
448
|
+
// a literal "{task}" in front of the composer.
|
|
449
|
+
const question = member.question?.trim();
|
|
450
|
+
const facet = question
|
|
451
|
+
? question.replaceAll(TASK_PLACEHOLDER, prompt.trim())
|
|
452
|
+
: (member.role?.trim() ?? "the whole task");
|
|
453
|
+
// The blind map only covers members that produced an output or a failure.
|
|
454
|
+
// A member stopped early by `stopWhenPanelAgrees` is in neither, and
|
|
455
|
+
// falling back to `member.label` would leak the name into the very prompt
|
|
456
|
+
// that is meant to hide it.
|
|
457
|
+
const name = blindLabels
|
|
458
|
+
? (blindLabels.get(index) ?? "Candidate (did not report)")
|
|
459
|
+
: memberLabel(member);
|
|
460
|
+
return `- ${name}: ${facet}`;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
328
464
|
function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
|
|
329
465
|
return [
|
|
330
466
|
"You are the fusion judge.",
|
|
@@ -338,7 +474,7 @@ function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
|
|
|
338
474
|
"",
|
|
339
475
|
"Panel outputs:",
|
|
340
476
|
...panel.flatMap((member, index) => [
|
|
341
|
-
`## ${member
|
|
477
|
+
`## ${memberLabel(member)} (${member.id})`,
|
|
342
478
|
`Agent: ${member.agent}`,
|
|
343
479
|
"",
|
|
344
480
|
`{outputs.${chainOutputName(member, index)}}`,
|
|
@@ -352,29 +488,39 @@ function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
|
|
|
352
488
|
function formatPanelStatus(
|
|
353
489
|
outputs: readonly PanelOutput[],
|
|
354
490
|
failures: readonly FailedPanelSummary[],
|
|
491
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
355
492
|
): string[] {
|
|
356
493
|
const lines = [
|
|
357
494
|
`- Successful panelists: ${outputs.length}`,
|
|
358
495
|
`- Failed panelists: ${failures.length}`,
|
|
359
496
|
];
|
|
360
497
|
for (const output of outputs) {
|
|
361
|
-
lines.push(`- ${formatPanelName(output)}: succeeded`);
|
|
498
|
+
lines.push(`- ${formatPanelName(output, blindLabels)}: succeeded`);
|
|
362
499
|
}
|
|
363
500
|
for (const failure of failures) {
|
|
364
501
|
lines.push(
|
|
365
|
-
`- ${formatPanelName(failure)}: failed - ${firstLine(failure.summary)}`,
|
|
502
|
+
`- ${formatPanelName(failure, blindLabels)}: failed - ${firstLine(failure.summary)}`,
|
|
366
503
|
);
|
|
367
504
|
}
|
|
368
505
|
return lines;
|
|
369
506
|
}
|
|
370
507
|
|
|
371
|
-
function formatPanelOutputs(
|
|
508
|
+
function formatPanelOutputs(
|
|
509
|
+
outputs: readonly PanelOutput[],
|
|
510
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
511
|
+
): string[] {
|
|
372
512
|
if (outputs.length === 0) return ["(none)"];
|
|
513
|
+
// Agent names and artifact paths carry the member id, so they are withheld
|
|
514
|
+
// when blinding. They are debugging aids for the reader, not judging inputs.
|
|
373
515
|
return outputs.flatMap((output) => [
|
|
374
|
-
`## ${formatPanelName(output)}`,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
516
|
+
`## ${formatPanelName(output, blindLabels)}`,
|
|
517
|
+
...(blindLabels
|
|
518
|
+
? []
|
|
519
|
+
: [
|
|
520
|
+
`Agent: ${output.agent}`,
|
|
521
|
+
...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
|
|
522
|
+
...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
|
|
523
|
+
]),
|
|
378
524
|
"",
|
|
379
525
|
output.output,
|
|
380
526
|
"",
|
|
@@ -383,19 +529,56 @@ function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
|
|
|
383
529
|
|
|
384
530
|
function formatFailedPanelists(
|
|
385
531
|
failures: readonly FailedPanelSummary[],
|
|
532
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
386
533
|
): string[] {
|
|
387
534
|
if (failures.length === 0) return ["(none)"];
|
|
388
|
-
return failures.flatMap((failure) =>
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
535
|
+
return failures.flatMap((failure) =>
|
|
536
|
+
blindLabels
|
|
537
|
+
? [`- ${formatPanelName(failure, blindLabels)}: ${failure.summary}`]
|
|
538
|
+
: [
|
|
539
|
+
`- ${formatPanelName(failure)} (${failure.agent}): ${failure.summary}`,
|
|
540
|
+
...(failure.artifactPath
|
|
541
|
+
? [` Artifact: ${failure.artifactPath}`]
|
|
542
|
+
: []),
|
|
543
|
+
...(failure.sessionPath ? [` Session: ${failure.sessionPath}`] : []),
|
|
544
|
+
],
|
|
545
|
+
);
|
|
393
546
|
}
|
|
394
547
|
|
|
395
548
|
function formatPanelName(
|
|
396
549
|
item: Pick<PanelOutput, "index" | "id" | "label">,
|
|
550
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
397
551
|
): string {
|
|
398
|
-
return item.
|
|
552
|
+
return blindLabels?.get(item.index) ?? panelItemLabel(item);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Maps panel indices to neutral "Candidate X" names. Assigned by index rather
|
|
557
|
+
* than presentation order so the mapping is stable and the report can restore
|
|
558
|
+
* real names without persisting extra run state.
|
|
559
|
+
*/
|
|
560
|
+
export function buildBlindLabelMap(
|
|
561
|
+
items: readonly Pick<PanelOutput, "index">[],
|
|
562
|
+
): Map<number, string> {
|
|
563
|
+
const labels = new Map<number, string>();
|
|
564
|
+
const indices = [...new Set(items.map((item) => item.index))].sort(
|
|
565
|
+
(left, right) => left - right,
|
|
566
|
+
);
|
|
567
|
+
indices.forEach((index, position) => {
|
|
568
|
+
labels.set(index, `Candidate ${blindLabelFor(position)}`);
|
|
569
|
+
});
|
|
570
|
+
return labels;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function blindLabelFor(position: number): string {
|
|
574
|
+
// A..Z, then AA, AB, ... for panels larger than the alphabet.
|
|
575
|
+
let remaining = position;
|
|
576
|
+
let label = "";
|
|
577
|
+
do {
|
|
578
|
+
label = String.fromCharCode(65 + (remaining % 26)) + label;
|
|
579
|
+
remaining = Math.floor(remaining / 26) - 1;
|
|
580
|
+
} while (remaining >= 0);
|
|
581
|
+
return label;
|
|
399
582
|
}
|
|
400
583
|
|
|
401
584
|
function comparePanelItems(
|
|
@@ -405,6 +588,39 @@ function comparePanelItems(
|
|
|
405
588
|
return left.index - right.index;
|
|
406
589
|
}
|
|
407
590
|
|
|
591
|
+
/**
|
|
592
|
+
* LLM judges favour whichever candidate is presented first or last. A fixed
|
|
593
|
+
* order therefore advantages the same panel member on every run. Shuffling
|
|
594
|
+
* removes the bias; seeding it from the run id keeps a persisted run rendering
|
|
595
|
+
* identically when it is replayed through `fusion:rpc:v1` adopt.
|
|
596
|
+
*/
|
|
597
|
+
export function shufflePanelItems<T>(items: readonly T[], seed: string): T[] {
|
|
598
|
+
const shuffled = [...items];
|
|
599
|
+
const nextRandom = createSeededRandom(seed);
|
|
600
|
+
for (let index = shuffled.length - 1; index > 0; index--) {
|
|
601
|
+
const swap = Math.floor(nextRandom() * (index + 1));
|
|
602
|
+
[shuffled[index], shuffled[swap]] = [shuffled[swap]!, shuffled[index]!];
|
|
603
|
+
}
|
|
604
|
+
return shuffled;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function createSeededRandom(seed: string): () => number {
|
|
608
|
+
// FNV-1a over the seed, then mulberry32. Small, dependency-free, and stable
|
|
609
|
+
// across Node versions - the reproducibility guarantee depends on that.
|
|
610
|
+
let hash = 0x811c9dc5;
|
|
611
|
+
for (let index = 0; index < seed.length; index++) {
|
|
612
|
+
hash ^= seed.charCodeAt(index);
|
|
613
|
+
hash = Math.imul(hash, 0x01000193);
|
|
614
|
+
}
|
|
615
|
+
let state = hash >>> 0;
|
|
616
|
+
return () => {
|
|
617
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
618
|
+
let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
619
|
+
drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
|
|
620
|
+
return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296;
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
408
624
|
function firstLine(value: string): string {
|
|
409
625
|
return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
|
|
410
626
|
}
|
package/src/run-store.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface FusionRunStartInput {
|
|
|
40
40
|
id?: string;
|
|
41
41
|
prompt: string;
|
|
42
42
|
profileName: string;
|
|
43
|
+
inlinePanel?: string[];
|
|
44
|
+
baseProfileName?: string;
|
|
43
45
|
operationId?: string;
|
|
44
46
|
phase?: Exclude<FusionPhase, FusionTerminalPhase>;
|
|
45
47
|
createdAt?: number;
|
|
@@ -149,6 +151,14 @@ export class FusionRunStore {
|
|
|
149
151
|
id: input.id ?? this.idFactory(),
|
|
150
152
|
prompt: input.prompt,
|
|
151
153
|
profileName: input.profileName,
|
|
154
|
+
...(input.inlinePanel?.length
|
|
155
|
+
? {
|
|
156
|
+
inlinePanel: input.inlinePanel,
|
|
157
|
+
...(input.baseProfileName
|
|
158
|
+
? { baseProfileName: input.baseProfileName }
|
|
159
|
+
: {}),
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
152
162
|
...(input.operationId !== undefined
|
|
153
163
|
? { operationId: input.operationId }
|
|
154
164
|
: {}),
|
|
@@ -385,6 +395,14 @@ function cloneRun(run: FusionRun): FusionRun {
|
|
|
385
395
|
id: run.id,
|
|
386
396
|
prompt: run.prompt,
|
|
387
397
|
profileName: run.profileName,
|
|
398
|
+
// cloneRun is a strict field allowlist: a new FusionRun field is dropped on
|
|
399
|
+
// both write and read until it is listed here.
|
|
400
|
+
...(run.inlinePanel !== undefined
|
|
401
|
+
? { inlinePanel: [...run.inlinePanel] }
|
|
402
|
+
: {}),
|
|
403
|
+
...(run.baseProfileName !== undefined
|
|
404
|
+
? { baseProfileName: run.baseProfileName }
|
|
405
|
+
: {}),
|
|
388
406
|
...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
|
|
389
407
|
phase: run.phase,
|
|
390
408
|
createdAt: run.createdAt,
|
|
@@ -444,6 +462,19 @@ function isFusionRunState(value: unknown): value is FusionRun {
|
|
|
444
462
|
if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) {
|
|
445
463
|
return false;
|
|
446
464
|
}
|
|
465
|
+
if (
|
|
466
|
+
value.inlinePanel !== undefined &&
|
|
467
|
+
(!Array.isArray(value.inlinePanel) ||
|
|
468
|
+
!value.inlinePanel.every(isNonEmptyString))
|
|
469
|
+
) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
if (
|
|
473
|
+
value.baseProfileName !== undefined &&
|
|
474
|
+
!isNonEmptyString(value.baseProfileName)
|
|
475
|
+
) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
447
478
|
if (!isFusionPhase(value.phase)) return false;
|
|
448
479
|
if (!isFiniteNumber(value.createdAt)) return false;
|
|
449
480
|
if (!isFiniteNumber(value.updatedAt)) return false;
|
package/src/types.ts
CHANGED
|
@@ -8,15 +8,34 @@ export const THINKING_LEVELS = [
|
|
|
8
8
|
] as const;
|
|
9
9
|
|
|
10
10
|
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
11
|
+
export const PANEL_AGENT = "pi-fusion.fusion-panelist";
|
|
12
|
+
export const PANEL_AGENT_WEB = "pi-fusion.fusion-panelist-web";
|
|
13
|
+
export const PANEL_AGENT_FULL = "pi-fusion.fusion-panelist-full";
|
|
14
|
+
export const JUDGE_AGENT = "pi-fusion.fusion-judge";
|
|
15
|
+
export const COMPOSER_AGENT = "pi-fusion.fusion-composer";
|
|
16
|
+
|
|
11
17
|
export type FusionContextMode = "fresh" | "fork";
|
|
12
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How panel answers become one report.
|
|
21
|
+
* `select` — panelists answered the same question; the judge picks or reconciles.
|
|
22
|
+
* `merge` — panelists answered different facets; the composer unions them.
|
|
23
|
+
*/
|
|
24
|
+
export type FusionSynthesisMode = "select" | "merge";
|
|
25
|
+
|
|
13
26
|
export interface PanelMemberConfig {
|
|
14
27
|
id: string;
|
|
15
|
-
label
|
|
28
|
+
/** Report label. Defaults to `id` — set it only when they should differ. */
|
|
29
|
+
label?: string;
|
|
16
30
|
agent: string;
|
|
17
31
|
model?: string;
|
|
18
32
|
thinking?: ThinkingLevel;
|
|
19
33
|
role?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Facet prompt sent instead of the raw task. `{task}` is substituted with the
|
|
36
|
+
* original prompt. Turns a redundant panel into one that divides the work.
|
|
37
|
+
*/
|
|
38
|
+
question?: string;
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export interface JudgeConfig {
|
|
@@ -32,6 +51,65 @@ export interface FusionProfile {
|
|
|
32
51
|
timeoutMs?: number;
|
|
33
52
|
context?: FusionContextMode;
|
|
34
53
|
stopWhenPanelAgrees?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Present panel answers to the judge as "Candidate A/B/C" instead of the
|
|
56
|
+
* configured labels. Role names read as authority cues before a word of
|
|
57
|
+
* content is compared. The report always restores the real labels.
|
|
58
|
+
*/
|
|
59
|
+
blindPanelLabels?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Caps the tool calls the judge may spend verifying contested claims.
|
|
62
|
+
* `soft` nudges, `hard` blocks further tool use so the judge still finalises.
|
|
63
|
+
*/
|
|
64
|
+
judgeToolBudget?: ToolBudget;
|
|
65
|
+
/**
|
|
66
|
+
* Usually omit this. It is inferred: a panel where any member has a
|
|
67
|
+
* `question` is answering facets, so it merges; otherwise it selects.
|
|
68
|
+
* Set it only to override that — most often `"select"` on a faceted panel
|
|
69
|
+
* when you deliberately want the judge to pick rather than union.
|
|
70
|
+
*
|
|
71
|
+
* Under `merge` the synthesis spawn uses the composer agent and contract
|
|
72
|
+
* instead of the judge's. It reuses the judge run slot, so no new
|
|
73
|
+
* `FusionPhase` is introduced and `fusion:rpc:v1` stays compatible.
|
|
74
|
+
*/
|
|
75
|
+
synthesis?: FusionSynthesisMode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Facets are the thing that actually decides how answers combine, so the mode
|
|
80
|
+
* follows them by default. Requiring `synthesis` and `question` to agree made
|
|
81
|
+
* two ways to get a silently wrong report: facets judged for consensus they
|
|
82
|
+
* cannot have, or a composer told to merge facets that do not exist.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Report label for a panel item. `label` is optional; it falls back to `id`, and
|
|
86
|
+
* then to the position. One definition so report and prompt never disagree.
|
|
87
|
+
*/
|
|
88
|
+
export function panelItemLabel(
|
|
89
|
+
item: Pick<PanelOutput, "index" | "id" | "label">,
|
|
90
|
+
): string {
|
|
91
|
+
return item.label?.trim() || item.id?.trim() || `Panelist ${item.index + 1}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Report label for a member; `label` is optional and falls back to `id`. */
|
|
95
|
+
export function memberLabel(
|
|
96
|
+
member: Pick<PanelMemberConfig, "id" | "label">,
|
|
97
|
+
): string {
|
|
98
|
+
return member.label?.trim() || member.id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveSynthesisMode(
|
|
102
|
+
profile: Pick<FusionProfile, "panel" | "synthesis">,
|
|
103
|
+
): FusionSynthesisMode {
|
|
104
|
+
if (profile.synthesis) return profile.synthesis;
|
|
105
|
+
return profile.panel.some((member) => member.question?.trim())
|
|
106
|
+
? "merge"
|
|
107
|
+
: "select";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ToolBudget {
|
|
111
|
+
soft?: number;
|
|
112
|
+
hard?: number;
|
|
35
113
|
}
|
|
36
114
|
|
|
37
115
|
export type PanelConfidence = "low" | "medium" | "high";
|
|
@@ -79,6 +157,8 @@ export interface ParsedFusionArgs {
|
|
|
79
157
|
prompt: string;
|
|
80
158
|
profile?: string;
|
|
81
159
|
operationId?: string;
|
|
160
|
+
/** Inline panel entries from `--panel`: `<model>` or `<agent>:<model>`. */
|
|
161
|
+
panel?: string[];
|
|
82
162
|
}
|
|
83
163
|
|
|
84
164
|
export interface PanelOutput {
|
|
@@ -121,6 +201,14 @@ export interface FusionRun {
|
|
|
121
201
|
id: string;
|
|
122
202
|
prompt: string;
|
|
123
203
|
profileName: string;
|
|
204
|
+
/**
|
|
205
|
+
* Inline `--panel` entries, when the run used them. `profileName` then carries
|
|
206
|
+
* a display name that no config defines, so restore rebuilds the profile from
|
|
207
|
+
* `baseProfileName` plus these entries instead of looking the display name up.
|
|
208
|
+
*/
|
|
209
|
+
inlinePanel?: string[];
|
|
210
|
+
/** Name of the config profile the inline panel was layered onto. */
|
|
211
|
+
baseProfileName?: string;
|
|
124
212
|
operationId?: string;
|
|
125
213
|
phase: FusionPhase;
|
|
126
214
|
createdAt: number;
|