@alexeiled/pi-fusion 0.5.2 → 0.6.1
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 +24 -11
- 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 +208 -21
- 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 +146 -19
- package/src/panel-completion.ts +17 -6
- package/src/report.ts +233 -78
- package/src/result-extract.ts +15 -1
- package/src/run-builder.ts +298 -153
- 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 = {
|
|
@@ -30,45 +36,12 @@ export interface PanelSubagentTaskParams {
|
|
|
30
36
|
model?: string;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
export interface
|
|
34
|
-
|
|
35
|
-
label: string;
|
|
36
|
-
phase: "Panel";
|
|
39
|
+
export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
|
|
40
|
+
key: string;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface PanelSpawnParams {
|
|
40
|
-
|
|
41
|
-
async: true;
|
|
42
|
-
clarify: false;
|
|
43
|
-
concurrency: number;
|
|
44
|
-
context: "fresh" | "fork";
|
|
45
|
-
output: true;
|
|
46
|
-
outputMode: "inline";
|
|
47
|
-
acceptance: FusionAcceptanceDisabled;
|
|
48
|
-
timeoutMs?: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface FusionChainParallelStepParams {
|
|
52
|
-
parallel: PanelChainTaskParams[];
|
|
53
|
-
concurrency: number;
|
|
54
|
-
failFast: false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface FusionChainJudgeStepParams {
|
|
58
|
-
agent: string;
|
|
59
|
-
task: string;
|
|
60
|
-
label: "Judge";
|
|
61
|
-
phase: "Judge";
|
|
62
|
-
output: true;
|
|
63
|
-
outputMode: "inline";
|
|
64
|
-
skill: false;
|
|
65
|
-
acceptance: FusionAcceptanceDisabled;
|
|
66
|
-
model?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface FusionChainSpawnParams {
|
|
70
|
-
chain: [FusionChainParallelStepParams, FusionChainJudgeStepParams];
|
|
71
|
-
task: string;
|
|
44
|
+
workflowScript: string;
|
|
72
45
|
async: true;
|
|
73
46
|
clarify: false;
|
|
74
47
|
context: "fresh" | "fork";
|
|
@@ -90,6 +63,8 @@ export interface JudgeSpawnParams {
|
|
|
90
63
|
acceptance: FusionAcceptanceDisabled;
|
|
91
64
|
model?: string;
|
|
92
65
|
timeoutMs?: number;
|
|
66
|
+
/** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
|
|
67
|
+
toolBudget?: ToolBudget;
|
|
93
68
|
}
|
|
94
69
|
|
|
95
70
|
export type { FailedPanelSummary, PanelOutput } from "./types.js";
|
|
@@ -99,6 +74,12 @@ export interface BuildJudgeSpawnParamsInput {
|
|
|
99
74
|
prompt: string;
|
|
100
75
|
panelOutputs: readonly PanelOutput[];
|
|
101
76
|
failedPanelists: readonly FailedPanelSummary[];
|
|
77
|
+
/**
|
|
78
|
+
* Seeds the order panel answers are presented to the judge. Required rather
|
|
79
|
+
* than optional: a missing seed would silently restore the fixed index order
|
|
80
|
+
* and its position bias.
|
|
81
|
+
*/
|
|
82
|
+
runId: string;
|
|
102
83
|
}
|
|
103
84
|
|
|
104
85
|
const PANEL_OUTPUT_CONTRACT = [
|
|
@@ -116,6 +97,7 @@ const JUDGE_OUTPUT_CONTRACT = [
|
|
|
116
97
|
"## Agent Status",
|
|
117
98
|
"## Consensus",
|
|
118
99
|
"## Disagreements",
|
|
100
|
+
"## Contested Claims",
|
|
119
101
|
"## Unique Insights",
|
|
120
102
|
"## Blind Spots",
|
|
121
103
|
"## Recommendation",
|
|
@@ -123,6 +105,36 @@ const JUDGE_OUTPUT_CONTRACT = [
|
|
|
123
105
|
"## Next Step",
|
|
124
106
|
] as const;
|
|
125
107
|
|
|
108
|
+
const COMPOSER_OUTPUT_CONTRACT = [
|
|
109
|
+
"# Fusion Report",
|
|
110
|
+
"## Summary",
|
|
111
|
+
"## Coverage Map",
|
|
112
|
+
"## Combined Answer",
|
|
113
|
+
"## Gaps",
|
|
114
|
+
"## Conflicts At Seams",
|
|
115
|
+
"## Agent Status",
|
|
116
|
+
"## Risks",
|
|
117
|
+
"## Next Step",
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
const COMPOSER_INSTRUCTIONS = [
|
|
121
|
+
"You are the fusion composer.",
|
|
122
|
+
"Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
|
|
123
|
+
"The panelists answered DIFFERENT facets of one task. Merge their answers; do not pick a winner.",
|
|
124
|
+
"- Do not rank panelists. They were not competing.",
|
|
125
|
+
"- Report a conflict only where facets genuinely overlap and disagree. Different subject matter is not disagreement.",
|
|
126
|
+
"- Name facets nobody covered, or covered only in passing.",
|
|
127
|
+
"- 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.",
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
const CONTESTED_CLAIMS_INSTRUCTIONS = [
|
|
131
|
+
"Contested claims:",
|
|
132
|
+
"- Where panelists state conflicting facts about this codebase, do not pick the more confident wording.",
|
|
133
|
+
"- Check the claim yourself with your read tools and cite file:line.",
|
|
134
|
+
"- Report each contested claim as: the claim, what you found, and which panelist was right.",
|
|
135
|
+
"- If you could not verify a claim, say so explicitly rather than choosing.",
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
126
138
|
export function appendThinkingSuffix(
|
|
127
139
|
model: string | undefined,
|
|
128
140
|
thinking: ThinkingLevel | undefined,
|
|
@@ -136,57 +148,24 @@ export function buildPanelSpawnParams(
|
|
|
136
148
|
profile: FusionProfile,
|
|
137
149
|
prompt: string,
|
|
138
150
|
): PanelSpawnParams {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
const concurrency = profile.concurrency ?? profile.panel.length;
|
|
152
|
+
const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
|
|
153
|
+
(member, index) => ({
|
|
154
|
+
key: `panel-${index + 1}`,
|
|
155
|
+
...buildPanelTaskParams(
|
|
142
156
|
member,
|
|
143
157
|
prompt,
|
|
144
158
|
profile.stopWhenPanelAgrees === true,
|
|
145
159
|
),
|
|
146
|
-
),
|
|
147
|
-
async: true,
|
|
148
|
-
clarify: false,
|
|
149
|
-
concurrency: profile.concurrency ?? profile.panel.length,
|
|
150
|
-
context: profile.context ?? "fresh",
|
|
151
|
-
output: true,
|
|
152
|
-
outputMode: "inline",
|
|
153
|
-
acceptance: FUSION_ACCEPTANCE_DISABLED,
|
|
154
|
-
...(profile.timeoutMs !== undefined
|
|
155
|
-
? { timeoutMs: profile.timeoutMs }
|
|
156
|
-
: {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function buildFusionChainSpawnParams(
|
|
161
|
-
profile: FusionProfile,
|
|
162
|
-
prompt: string,
|
|
163
|
-
): FusionChainSpawnParams {
|
|
164
|
-
const model = appendThinkingSuffix(
|
|
165
|
-
profile.judge.model,
|
|
166
|
-
profile.judge.thinking,
|
|
160
|
+
}),
|
|
167
161
|
);
|
|
162
|
+
|
|
168
163
|
return {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
concurrency: profile.concurrency ?? profile.panel.length,
|
|
175
|
-
failFast: false,
|
|
176
|
-
},
|
|
177
|
-
{
|
|
178
|
-
agent: profile.judge.agent,
|
|
179
|
-
task: buildChainJudgeTask(profile.panel),
|
|
180
|
-
label: "Judge",
|
|
181
|
-
phase: "Judge",
|
|
182
|
-
output: true,
|
|
183
|
-
outputMode: "inline",
|
|
184
|
-
skill: false,
|
|
185
|
-
acceptance: FUSION_ACCEPTANCE_DISABLED,
|
|
186
|
-
...(model ? { model } : {}),
|
|
187
|
-
},
|
|
188
|
-
],
|
|
189
|
-
task: prompt.trim(),
|
|
164
|
+
workflowScript: buildPanelWorkflowScript(
|
|
165
|
+
tasks,
|
|
166
|
+
concurrency,
|
|
167
|
+
profile.stopWhenPanelAgrees === true,
|
|
168
|
+
),
|
|
190
169
|
async: true,
|
|
191
170
|
clarify: false,
|
|
192
171
|
context: profile.context ?? "fresh",
|
|
@@ -207,7 +186,7 @@ export function buildJudgeSpawnParams(
|
|
|
207
186
|
input.profile.judge.thinking,
|
|
208
187
|
);
|
|
209
188
|
return {
|
|
210
|
-
agent: input.profile
|
|
189
|
+
agent: resolveSynthesisAgent(input.profile),
|
|
211
190
|
task: buildJudgeTask(input),
|
|
212
191
|
async: true,
|
|
213
192
|
clarify: false,
|
|
@@ -217,12 +196,60 @@ export function buildJudgeSpawnParams(
|
|
|
217
196
|
skill: false,
|
|
218
197
|
acceptance: FUSION_ACCEPTANCE_DISABLED,
|
|
219
198
|
...(model ? { model } : {}),
|
|
199
|
+
...(input.profile.judgeToolBudget
|
|
200
|
+
? { toolBudget: input.profile.judgeToolBudget }
|
|
201
|
+
: {}),
|
|
220
202
|
...(input.profile.timeoutMs !== undefined
|
|
221
203
|
? { timeoutMs: input.profile.timeoutMs }
|
|
222
204
|
: {}),
|
|
223
205
|
};
|
|
224
206
|
}
|
|
225
207
|
|
|
208
|
+
function buildPanelWorkflowScript(
|
|
209
|
+
tasks: readonly PanelWorkflowTaskParams[],
|
|
210
|
+
concurrency: number,
|
|
211
|
+
stopWhenAgrees: boolean,
|
|
212
|
+
): string {
|
|
213
|
+
const serializedTasks = JSON.stringify(tasks);
|
|
214
|
+
const effectiveConcurrency = stopWhenAgrees
|
|
215
|
+
? Math.min(concurrency, 2)
|
|
216
|
+
: concurrency;
|
|
217
|
+
const stopLogic = stopWhenAgrees
|
|
218
|
+
? [
|
|
219
|
+
"const decisions = results",
|
|
220
|
+
" .filter((result) => result && result.ok === true)",
|
|
221
|
+
" .map((result) => {",
|
|
222
|
+
" const text = typeof result.output === \"string\" ? result.output : \"\";",
|
|
223
|
+
" const match = text.match(/<fusion-panel-decision>([\\s\\S]*?)<\\/fusion-panel-decision>\\s*$/);",
|
|
224
|
+
" if (!match) return undefined;",
|
|
225
|
+
" try { return JSON.parse(match[1]); } catch { return undefined; }",
|
|
226
|
+
" })",
|
|
227
|
+
" .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
|
|
228
|
+
"if (decisions.length >= 2 && results.length < tasks.length) {",
|
|
229
|
+
" const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
|
|
230
|
+
" agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
|
|
231
|
+
" if (agrees) {",
|
|
232
|
+
" emit({ type: \"pi-fusion-panel-stop\", indices: tasks.slice(results.length).map((_, index) => results.length + index) });",
|
|
233
|
+
" return results;",
|
|
234
|
+
" }",
|
|
235
|
+
"}",
|
|
236
|
+
].join("\n")
|
|
237
|
+
: "";
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
`const tasks = ${serializedTasks};`,
|
|
241
|
+
`const concurrency = ${effectiveConcurrency};`,
|
|
242
|
+
"const results = [];",
|
|
243
|
+
"for (let index = 0; index < tasks.length; index += concurrency) {",
|
|
244
|
+
" results.push(...await runs.all(tasks.slice(index, index + concurrency)));",
|
|
245
|
+
stopWhenAgrees ? " " + stopLogic.replaceAll("\n", "\n ") : "",
|
|
246
|
+
"}",
|
|
247
|
+
"return results;",
|
|
248
|
+
]
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
.join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
226
253
|
function buildPanelTaskParams(
|
|
227
254
|
member: PanelMemberConfig,
|
|
228
255
|
prompt: string,
|
|
@@ -241,26 +268,6 @@ function buildPanelTaskParams(
|
|
|
241
268
|
};
|
|
242
269
|
}
|
|
243
270
|
|
|
244
|
-
function buildPanelChainTaskParams(
|
|
245
|
-
member: PanelMemberConfig,
|
|
246
|
-
index: number,
|
|
247
|
-
): PanelChainTaskParams {
|
|
248
|
-
const model = appendThinkingSuffix(member.model, member.thinking);
|
|
249
|
-
return {
|
|
250
|
-
agent: member.agent,
|
|
251
|
-
task: buildPanelTask(member, "{task}", false),
|
|
252
|
-
as: chainOutputName(member, index),
|
|
253
|
-
label: member.label,
|
|
254
|
-
phase: "Panel",
|
|
255
|
-
output: true,
|
|
256
|
-
outputMode: "inline",
|
|
257
|
-
progress: true,
|
|
258
|
-
skill: false,
|
|
259
|
-
acceptance: FUSION_ACCEPTANCE_DISABLED,
|
|
260
|
-
...(model ? { model } : {}),
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
271
|
function buildPanelTask(
|
|
265
272
|
member: PanelMemberConfig,
|
|
266
273
|
prompt: string,
|
|
@@ -268,11 +275,10 @@ function buildPanelTask(
|
|
|
268
275
|
): string {
|
|
269
276
|
const role = member.role?.trim() || "independent analysis and critique";
|
|
270
277
|
return [
|
|
271
|
-
`Panel member: ${member
|
|
278
|
+
`Panel member: ${memberLabel(member)} (${member.id})`,
|
|
272
279
|
`Role: ${role}`,
|
|
273
280
|
"",
|
|
274
|
-
|
|
275
|
-
prompt.trim(),
|
|
281
|
+
...formatMemberTask(member, prompt),
|
|
276
282
|
"",
|
|
277
283
|
"Instructions:",
|
|
278
284
|
"- Work independently from the other panelists.",
|
|
@@ -300,81 +306,156 @@ function buildPanelTask(
|
|
|
300
306
|
].join("\n");
|
|
301
307
|
}
|
|
302
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Merge mode swaps the synthesis agent, not the run slot. It still spawns into
|
|
311
|
+
* `judgeRunId`/`judgeAsyncDir` under phase `judge`, so `fusion:rpc:v1` consumers
|
|
312
|
+
* see no new phase value.
|
|
313
|
+
*
|
|
314
|
+
* An explicitly configured judge agent still wins: a user who names their own
|
|
315
|
+
* synthesis agent means it.
|
|
316
|
+
*/
|
|
317
|
+
function resolveSynthesisAgent(profile: FusionProfile): string {
|
|
318
|
+
if (resolveSynthesisMode(profile) !== "merge") return profile.judge.agent;
|
|
319
|
+
return profile.judge.agent === JUDGE_AGENT
|
|
320
|
+
? COMPOSER_AGENT
|
|
321
|
+
: profile.judge.agent;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const TASK_PLACEHOLDER = "{task}";
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A member with a `question` answers that facet instead of the whole prompt.
|
|
328
|
+
* If the template omits `{task}` the original prompt is still appended: dropping
|
|
329
|
+
* it would silently strip the context the panelist needs.
|
|
330
|
+
*/
|
|
331
|
+
function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
|
|
332
|
+
const question = member.question?.trim();
|
|
333
|
+
const task = prompt.trim();
|
|
334
|
+
if (!question) return ["Original task:", task];
|
|
335
|
+
|
|
336
|
+
const facet = question.replaceAll(TASK_PLACEHOLDER, task);
|
|
337
|
+
if (question.includes(TASK_PLACEHOLDER)) {
|
|
338
|
+
return ["Your assigned facet of the task:", facet];
|
|
339
|
+
}
|
|
340
|
+
return [
|
|
341
|
+
"Your assigned facet of the task:",
|
|
342
|
+
facet,
|
|
343
|
+
"",
|
|
344
|
+
"Original task:",
|
|
345
|
+
task,
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
|
|
303
349
|
function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
|
|
304
350
|
const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
|
|
305
351
|
const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
|
|
352
|
+
// Status and failure lists stay in configuration order so the reader can map
|
|
353
|
+
// them to the profile. Only the answers the judge weighs are shuffled.
|
|
354
|
+
const presentedOutputs = shufflePanelItems(sortedOutputs, input.runId);
|
|
355
|
+
const blindLabels = input.profile.blindPanelLabels
|
|
356
|
+
? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
|
|
357
|
+
: undefined;
|
|
358
|
+
const merging = resolveSynthesisMode(input.profile) === "merge";
|
|
306
359
|
return [
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
360
|
+
...(merging
|
|
361
|
+
? COMPOSER_INSTRUCTIONS
|
|
362
|
+
: [
|
|
363
|
+
"You are the fusion judge.",
|
|
364
|
+
"Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
|
|
365
|
+
"Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
|
|
366
|
+
]),
|
|
310
367
|
"",
|
|
311
368
|
"Original task:",
|
|
312
369
|
input.prompt.trim(),
|
|
313
370
|
"",
|
|
314
371
|
"Panel status:",
|
|
315
|
-
...formatPanelStatus(sortedOutputs, sortedFailures),
|
|
372
|
+
...formatPanelStatus(sortedOutputs, sortedFailures, blindLabels),
|
|
316
373
|
"",
|
|
374
|
+
...(merging
|
|
375
|
+
? [
|
|
376
|
+
"Facet assignments:",
|
|
377
|
+
...formatFacetAssignments(input.profile, input.prompt, blindLabels),
|
|
378
|
+
"",
|
|
379
|
+
]
|
|
380
|
+
: []),
|
|
317
381
|
"Successful panel outputs:",
|
|
318
|
-
...formatPanelOutputs(
|
|
382
|
+
...formatPanelOutputs(presentedOutputs, blindLabels),
|
|
319
383
|
"",
|
|
320
384
|
"Failed panelists:",
|
|
321
|
-
...formatFailedPanelists(sortedFailures),
|
|
385
|
+
...formatFailedPanelists(sortedFailures, blindLabels),
|
|
322
386
|
"",
|
|
387
|
+
// Judge-only. The composer has no Contested Claims section, so this block
|
|
388
|
+
// produces content the report silently drops, and its "which panelist was
|
|
389
|
+
// right" wording contradicts the composer's "do not rank panelists".
|
|
390
|
+
...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
|
|
323
391
|
"Output contract:",
|
|
324
|
-
...JUDGE_OUTPUT_CONTRACT,
|
|
392
|
+
...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
|
|
325
393
|
].join("\n");
|
|
326
394
|
}
|
|
327
395
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
""
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Lists what each member was asked to cover, so the composer can name gaps
|
|
398
|
+
* without reverse-engineering them from the answers it did receive.
|
|
399
|
+
*/
|
|
400
|
+
function formatFacetAssignments(
|
|
401
|
+
profile: FusionProfile,
|
|
402
|
+
prompt: string,
|
|
403
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
404
|
+
): string[] {
|
|
405
|
+
return profile.panel.map((member, index) => {
|
|
406
|
+
// Substituted the same way the panelist saw it; the raw template would put
|
|
407
|
+
// a literal "{task}" in front of the composer.
|
|
408
|
+
const question = member.question?.trim();
|
|
409
|
+
const facet = question
|
|
410
|
+
? question.replaceAll(TASK_PLACEHOLDER, prompt.trim())
|
|
411
|
+
: (member.role?.trim() ?? "the whole task");
|
|
412
|
+
// The blind map only covers members that produced an output or a failure.
|
|
413
|
+
// A member stopped early by `stopWhenPanelAgrees` is in neither, and
|
|
414
|
+
// falling back to `member.label` would leak the name into the very prompt
|
|
415
|
+
// that is meant to hide it.
|
|
416
|
+
const name = blindLabels
|
|
417
|
+
? (blindLabels.get(index) ?? "Candidate (did not report)")
|
|
418
|
+
: memberLabel(member);
|
|
419
|
+
return `- ${name}: ${facet}`;
|
|
420
|
+
});
|
|
350
421
|
}
|
|
351
422
|
|
|
352
423
|
function formatPanelStatus(
|
|
353
424
|
outputs: readonly PanelOutput[],
|
|
354
425
|
failures: readonly FailedPanelSummary[],
|
|
426
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
355
427
|
): string[] {
|
|
356
428
|
const lines = [
|
|
357
429
|
`- Successful panelists: ${outputs.length}`,
|
|
358
430
|
`- Failed panelists: ${failures.length}`,
|
|
359
431
|
];
|
|
360
432
|
for (const output of outputs) {
|
|
361
|
-
lines.push(`- ${formatPanelName(output)}: succeeded`);
|
|
433
|
+
lines.push(`- ${formatPanelName(output, blindLabels)}: succeeded`);
|
|
362
434
|
}
|
|
363
435
|
for (const failure of failures) {
|
|
364
436
|
lines.push(
|
|
365
|
-
`- ${formatPanelName(failure)}: failed - ${firstLine(failure.summary)}`,
|
|
437
|
+
`- ${formatPanelName(failure, blindLabels)}: failed - ${firstLine(failure.summary)}`,
|
|
366
438
|
);
|
|
367
439
|
}
|
|
368
440
|
return lines;
|
|
369
441
|
}
|
|
370
442
|
|
|
371
|
-
function formatPanelOutputs(
|
|
443
|
+
function formatPanelOutputs(
|
|
444
|
+
outputs: readonly PanelOutput[],
|
|
445
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
446
|
+
): string[] {
|
|
372
447
|
if (outputs.length === 0) return ["(none)"];
|
|
448
|
+
// Agent names and artifact paths carry the member id, so they are withheld
|
|
449
|
+
// when blinding. They are debugging aids for the reader, not judging inputs.
|
|
373
450
|
return outputs.flatMap((output) => [
|
|
374
|
-
`## ${formatPanelName(output)}`,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
451
|
+
`## ${formatPanelName(output, blindLabels)}`,
|
|
452
|
+
...(blindLabels
|
|
453
|
+
? []
|
|
454
|
+
: [
|
|
455
|
+
`Agent: ${output.agent}`,
|
|
456
|
+
...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
|
|
457
|
+
...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
|
|
458
|
+
]),
|
|
378
459
|
"",
|
|
379
460
|
output.output,
|
|
380
461
|
"",
|
|
@@ -383,19 +464,56 @@ function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
|
|
|
383
464
|
|
|
384
465
|
function formatFailedPanelists(
|
|
385
466
|
failures: readonly FailedPanelSummary[],
|
|
467
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
386
468
|
): string[] {
|
|
387
469
|
if (failures.length === 0) return ["(none)"];
|
|
388
|
-
return failures.flatMap((failure) =>
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
470
|
+
return failures.flatMap((failure) =>
|
|
471
|
+
blindLabels
|
|
472
|
+
? [`- ${formatPanelName(failure, blindLabels)}: ${failure.summary}`]
|
|
473
|
+
: [
|
|
474
|
+
`- ${formatPanelName(failure)} (${failure.agent}): ${failure.summary}`,
|
|
475
|
+
...(failure.artifactPath
|
|
476
|
+
? [` Artifact: ${failure.artifactPath}`]
|
|
477
|
+
: []),
|
|
478
|
+
...(failure.sessionPath ? [` Session: ${failure.sessionPath}`] : []),
|
|
479
|
+
],
|
|
480
|
+
);
|
|
393
481
|
}
|
|
394
482
|
|
|
395
483
|
function formatPanelName(
|
|
396
484
|
item: Pick<PanelOutput, "index" | "id" | "label">,
|
|
485
|
+
blindLabels?: ReadonlyMap<number, string>,
|
|
397
486
|
): string {
|
|
398
|
-
return item.
|
|
487
|
+
return blindLabels?.get(item.index) ?? panelItemLabel(item);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Maps panel indices to neutral "Candidate X" names. Assigned by index rather
|
|
492
|
+
* than presentation order so the mapping is stable and the report can restore
|
|
493
|
+
* real names without persisting extra run state.
|
|
494
|
+
*/
|
|
495
|
+
export function buildBlindLabelMap(
|
|
496
|
+
items: readonly Pick<PanelOutput, "index">[],
|
|
497
|
+
): Map<number, string> {
|
|
498
|
+
const labels = new Map<number, string>();
|
|
499
|
+
const indices = [...new Set(items.map((item) => item.index))].sort(
|
|
500
|
+
(left, right) => left - right,
|
|
501
|
+
);
|
|
502
|
+
indices.forEach((index, position) => {
|
|
503
|
+
labels.set(index, `Candidate ${blindLabelFor(position)}`);
|
|
504
|
+
});
|
|
505
|
+
return labels;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function blindLabelFor(position: number): string {
|
|
509
|
+
// A..Z, then AA, AB, ... for panels larger than the alphabet.
|
|
510
|
+
let remaining = position;
|
|
511
|
+
let label = "";
|
|
512
|
+
do {
|
|
513
|
+
label = String.fromCharCode(65 + (remaining % 26)) + label;
|
|
514
|
+
remaining = Math.floor(remaining / 26) - 1;
|
|
515
|
+
} while (remaining >= 0);
|
|
516
|
+
return label;
|
|
399
517
|
}
|
|
400
518
|
|
|
401
519
|
function comparePanelItems(
|
|
@@ -405,14 +523,41 @@ function comparePanelItems(
|
|
|
405
523
|
return left.index - right.index;
|
|
406
524
|
}
|
|
407
525
|
|
|
408
|
-
|
|
409
|
-
|
|
526
|
+
/**
|
|
527
|
+
* LLM judges favour whichever candidate is presented first or last. A fixed
|
|
528
|
+
* order therefore advantages the same panel member on every run. Shuffling
|
|
529
|
+
* removes the bias; seeding it from the run id keeps a persisted run rendering
|
|
530
|
+
* identically when it is replayed through `fusion:rpc:v1` adopt.
|
|
531
|
+
*/
|
|
532
|
+
export function shufflePanelItems<T>(items: readonly T[], seed: string): T[] {
|
|
533
|
+
const shuffled = [...items];
|
|
534
|
+
const nextRandom = createSeededRandom(seed);
|
|
535
|
+
for (let index = shuffled.length - 1; index > 0; index--) {
|
|
536
|
+
const swap = Math.floor(nextRandom() * (index + 1));
|
|
537
|
+
[shuffled[index], shuffled[swap]] = [shuffled[swap]!, shuffled[index]!];
|
|
538
|
+
}
|
|
539
|
+
return shuffled;
|
|
410
540
|
}
|
|
411
541
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
542
|
+
function createSeededRandom(seed: string): () => number {
|
|
543
|
+
// FNV-1a over the seed, then mulberry32. Small, dependency-free, and stable
|
|
544
|
+
// across Node versions - the reproducibility guarantee depends on that.
|
|
545
|
+
let hash = 0x811c9dc5;
|
|
546
|
+
for (let index = 0; index < seed.length; index++) {
|
|
547
|
+
hash ^= seed.charCodeAt(index);
|
|
548
|
+
hash = Math.imul(hash, 0x01000193);
|
|
549
|
+
}
|
|
550
|
+
let state = hash >>> 0;
|
|
551
|
+
return () => {
|
|
552
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
553
|
+
let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
554
|
+
drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
|
|
555
|
+
return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296;
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function firstLine(value: string): string {
|
|
560
|
+
return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
|
|
416
561
|
}
|
|
417
562
|
|
|
418
563
|
function hasThinkingSuffix(model: string): boolean {
|
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;
|