@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
|
@@ -1,34 +1,85 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fusion-review
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
Run a pi-fusion review: several models answer in parallel, then one synthesis
|
|
5
|
+
step returns a single report. Use when the user says "invoke fusion", "run a
|
|
6
|
+
fusion panel", "discuss this through fusion", "get multi-model opinions",
|
|
7
|
+
"panel review", "compare <model> and <model> on this", or asks to cover
|
|
8
|
+
something from several angles ("what did we miss", "audit this release").
|
|
9
|
+
Call the start_fusion_review tool with the topic. NOT for routine edits,
|
|
10
|
+
formatting, or one-step fixes. NOT for arguing one claim from both sides (use
|
|
11
|
+
dialectic) and NOT for open-ended idea generation (use brainstorming) —
|
|
12
|
+
Fusion panelists work independently and never talk to each other.
|
|
8
13
|
---
|
|
9
14
|
|
|
10
15
|
# Fusion Review
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
Call `start_fusion_review` so the user does not have to type `/fusion`.
|
|
13
18
|
|
|
14
19
|
## When to use
|
|
15
20
|
|
|
16
|
-
Use Fusion
|
|
21
|
+
Use Fusion when independent perspectives are worth the extra latency:
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
- a hard decision or a design tradeoff
|
|
24
|
+
- risk or release review
|
|
25
|
+
- tricky debugging
|
|
26
|
+
- a research-heavy question
|
|
27
|
+
- a breadth sweep, such as an audit or "what did we miss"
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- "get a panel review of …"
|
|
23
|
-
- "use multi-model review for …"
|
|
24
|
-
- "discuss this through fusion"
|
|
25
|
-
- Any prompt that implies parallel model review or multi-perspective analysis
|
|
29
|
+
Do not use Fusion for routine edits, formatting, obvious one-step fixes, or
|
|
30
|
+
simple questions. Keep those on the normal Pi path.
|
|
26
31
|
|
|
27
|
-
##
|
|
32
|
+
## Not this skill
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
- The user wants one claim argued for and against. That is `dialectic`.
|
|
35
|
+
- The user wants options generated, or a draft plan stress-tested. That is
|
|
36
|
+
`brainstorming-ideas`.
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
Fusion differs from both. Panelists run in isolation and never see the answers of
|
|
39
|
+
the others. The value is uncorrelated errors, not debate.
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
## How to call it
|
|
42
|
+
|
|
43
|
+
| Parameter | Required | Use |
|
|
44
|
+
| --------- | -------- | ------------------------------------------------------------- |
|
|
45
|
+
| `prompt` | yes | The topic, question, or code excerpt |
|
|
46
|
+
| `profile` | no | A named profile that the user already has. Omit it otherwise. |
|
|
47
|
+
| `panel` | no | Models for this run only. Use it when the user names models. |
|
|
48
|
+
|
|
49
|
+
Each `panel` entry is `<model>` or `<agent>:<model>`. For example: `opus`, or
|
|
50
|
+
`pi-fusion.fusion-panelist-web:gpt-5.5`.
|
|
51
|
+
|
|
52
|
+
Pass `panel` only when the user names the models. If the user asks for a kind of
|
|
53
|
+
review but names no model, omit `panel` and let the profile decide.
|
|
54
|
+
|
|
55
|
+
Do not pass a `profile` name that you have not seen in the config of the user.
|
|
56
|
+
An unknown name fails the run.
|
|
57
|
+
|
|
58
|
+
## Two shapes of report
|
|
59
|
+
|
|
60
|
+
The shape follows the profile. You do not select it, and there is no flag.
|
|
61
|
+
|
|
62
|
+
**Select** is the default. Every panelist answers the whole question. The judge
|
|
63
|
+
compares the answers and reports consensus, disagreements, contested claims, and
|
|
64
|
+
blind spots. This fits a decision question, where the failure mode is the bad
|
|
65
|
+
reasoning path of one model.
|
|
66
|
+
|
|
67
|
+
**Merge** happens when the profile gives its members a `question` field. Each
|
|
68
|
+
panelist then answers a different facet. A composer unions the answers into a
|
|
69
|
+
coverage map, a combined answer, and a list of gaps. This fits a breadth
|
|
70
|
+
question, where the failure mode is incomplete coverage.
|
|
71
|
+
|
|
72
|
+
If the user asks for a multi-angle audit and has no faceted profile, run the
|
|
73
|
+
default panel. Then tell them that a profile with `question` fields covers this
|
|
74
|
+
better.
|
|
75
|
+
|
|
76
|
+
## After the call
|
|
77
|
+
|
|
78
|
+
The tool returns at once. The panel and the synthesis step run in the background,
|
|
79
|
+
and Fusion posts the report when they finish.
|
|
80
|
+
|
|
81
|
+
- Do not call the tool again while a run is active. It returns a conflict with
|
|
82
|
+
the id of the active run.
|
|
83
|
+
- Do not summarize or predict the report. Wait for it.
|
|
84
|
+
- To show progress or stop a run, tell the user to type `/fusion status` or
|
|
85
|
+
`/fusion stop`. No tool does this.
|
package/src/commands.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { applyClaudeAliasShorthand } from "./claude-aliases.js";
|
|
5
5
|
import { FusionConfigError } from "./errors.js";
|
|
6
6
|
import {
|
|
7
|
+
JUDGE_AGENT,
|
|
8
|
+
PANEL_AGENT,
|
|
7
9
|
THINKING_LEVELS,
|
|
8
10
|
type FusionConfig,
|
|
9
11
|
type FusionContextMode,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
type JudgeConfig,
|
|
12
14
|
type PanelMemberConfig,
|
|
13
15
|
type ThinkingLevel,
|
|
16
|
+
type ToolBudget,
|
|
14
17
|
} from "./types.js";
|
|
15
18
|
import {
|
|
16
19
|
isNodeErrorCode,
|
|
@@ -21,8 +24,43 @@ import {
|
|
|
21
24
|
|
|
22
25
|
export const FUSION_CONFIG_FILE = "fusion.json";
|
|
23
26
|
export const DEFAULT_PROFILE_NAME = "quality";
|
|
24
|
-
export
|
|
25
|
-
|
|
27
|
+
export {
|
|
28
|
+
COMPOSER_AGENT,
|
|
29
|
+
JUDGE_AGENT,
|
|
30
|
+
PANEL_AGENT,
|
|
31
|
+
PANEL_AGENT_FULL,
|
|
32
|
+
PANEL_AGENT_WEB,
|
|
33
|
+
} from "./types.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tool names Fusion's bundled agents may declare: Pi core child tools plus the
|
|
37
|
+
* tools contributed by `pi-web-providers`. A name outside this set resolves to
|
|
38
|
+
* nothing at runtime, so a typo is only discovered after a panel run is spent.
|
|
39
|
+
* Single source of truth for `test/unit/agents.test.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export const KNOWN_TOOL_NAMES: readonly string[] = [
|
|
42
|
+
"read",
|
|
43
|
+
"bash",
|
|
44
|
+
"edit",
|
|
45
|
+
"write",
|
|
46
|
+
"grep",
|
|
47
|
+
"find",
|
|
48
|
+
"ls",
|
|
49
|
+
"web_search",
|
|
50
|
+
"web_contents",
|
|
51
|
+
"web_answer",
|
|
52
|
+
"web_research",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Agent references are dot-separated (`<package>.<name>`, or a bare local name).
|
|
57
|
+
* This deliberately does not enforce the lowercase `IDENTIFIER_PATTERN` that
|
|
58
|
+
* pi-subagents applies to package names: that pattern does not cover the
|
|
59
|
+
* frontmatter `name`, and rejecting a config that would actually run is worse
|
|
60
|
+
* than accepting a malformed one. It catches the real typo classes only —
|
|
61
|
+
* embedded whitespace, empty segments, leading or trailing dots.
|
|
62
|
+
*/
|
|
63
|
+
const AGENT_REFERENCE_PATTERN = /^[^\s.]+(?:\.[^\s.]+)*$/;
|
|
26
64
|
|
|
27
65
|
export interface FusionConfigLoadContext {
|
|
28
66
|
cwd: string;
|
|
@@ -138,6 +176,89 @@ export function resolveProfile(
|
|
|
138
176
|
return { name, profile };
|
|
139
177
|
}
|
|
140
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Splits an inline `--panel` entry into agent and model.
|
|
181
|
+
*
|
|
182
|
+
* Two shapes collide on `:` — an agent-qualified entry
|
|
183
|
+
* (`pi-fusion.fusion-panelist-web:gpt-5.5`) and a model with a thinking suffix
|
|
184
|
+
* (`gpt-4.1:high`). A dot in the prefix is not enough to tell them apart:
|
|
185
|
+
* dotted model versions like `gpt-4.1`, `claude-3.5-haiku`, and
|
|
186
|
+
* `gemini-2.5-pro` are common. The suffix decides — if it is a thinking level,
|
|
187
|
+
* the whole entry is a model.
|
|
188
|
+
*/
|
|
189
|
+
export function splitInlinePanelEntry(entry: string): {
|
|
190
|
+
agent: string;
|
|
191
|
+
model: string;
|
|
192
|
+
} {
|
|
193
|
+
const trimmed = entry.trim();
|
|
194
|
+
const separator = trimmed.indexOf(":");
|
|
195
|
+
if (separator > 0) {
|
|
196
|
+
const prefix = trimmed.slice(0, separator);
|
|
197
|
+
const rest = trimmed.slice(separator + 1).trim();
|
|
198
|
+
if (
|
|
199
|
+
prefix.includes(".") &&
|
|
200
|
+
!prefix.includes("/") &&
|
|
201
|
+
rest &&
|
|
202
|
+
!isThinkingLevel(rest)
|
|
203
|
+
) {
|
|
204
|
+
return { agent: prefix, model: rest };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { agent: PANEL_AGENT, model: trimmed };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Builds an ephemeral profile from `--panel`. The resolved profile still
|
|
212
|
+
* supplies the judge and every other setting; only the panel is replaced.
|
|
213
|
+
*/
|
|
214
|
+
export function buildInlinePanelProfile(
|
|
215
|
+
base: FusionProfile,
|
|
216
|
+
entries: readonly string[],
|
|
217
|
+
): FusionProfile {
|
|
218
|
+
const usedIds = new Set<string>();
|
|
219
|
+
const panel = entries.map((entry, index) => {
|
|
220
|
+
const { agent, model } = splitInlinePanelEntry(entry);
|
|
221
|
+
if (!model) {
|
|
222
|
+
throw new FusionConfigError(
|
|
223
|
+
`Inline panel entry "${entry}" has no model.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (!isAgentReference(agent)) {
|
|
227
|
+
throw new FusionConfigError(
|
|
228
|
+
`Inline panel entry "${entry}" has a malformed agent reference.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
id: uniqueInlineId(model, index, usedIds),
|
|
233
|
+
label: model,
|
|
234
|
+
agent,
|
|
235
|
+
model,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
// Inline members have no `question`, so they all answer the whole task.
|
|
239
|
+
// Carrying `synthesis: "merge"` over from the base profile would tell the
|
|
240
|
+
// composer to merge facets that do not exist.
|
|
241
|
+
const { synthesis: _dropped, ...rest } = base;
|
|
242
|
+
return { ...rest, panel };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function uniqueInlineId(
|
|
246
|
+
model: string,
|
|
247
|
+
index: number,
|
|
248
|
+
used: Set<string>,
|
|
249
|
+
): string {
|
|
250
|
+
const base =
|
|
251
|
+
model
|
|
252
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
253
|
+
.replace(/^_+|_+$/g, "")
|
|
254
|
+
.toLowerCase() || `panel_${index + 1}`;
|
|
255
|
+
let candidate = base;
|
|
256
|
+
let suffix = 2;
|
|
257
|
+
while (used.has(candidate)) candidate = `${base}_${suffix++}`;
|
|
258
|
+
used.add(candidate);
|
|
259
|
+
return candidate;
|
|
260
|
+
}
|
|
261
|
+
|
|
141
262
|
export async function writeProjectFusionConfigTemplate(
|
|
142
263
|
cwd: string,
|
|
143
264
|
deps: FileWriteDeps = {},
|
|
@@ -209,24 +330,65 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
209
330
|
) {
|
|
210
331
|
return false;
|
|
211
332
|
}
|
|
333
|
+
if (
|
|
334
|
+
value.blindPanelLabels !== undefined &&
|
|
335
|
+
typeof value.blindPanelLabels !== "boolean"
|
|
336
|
+
) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
value.judgeToolBudget !== undefined &&
|
|
341
|
+
!isToolBudget(value.judgeToolBudget)
|
|
342
|
+
) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
value.synthesis !== undefined &&
|
|
347
|
+
value.synthesis !== "select" &&
|
|
348
|
+
value.synthesis !== "merge"
|
|
349
|
+
) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isToolBudget(value: unknown): value is ToolBudget {
|
|
356
|
+
if (!isRecord(value)) return false;
|
|
357
|
+
if (value.soft !== undefined && !isPositiveInteger(value.soft)) return false;
|
|
358
|
+
if (value.hard !== undefined && !isPositiveInteger(value.hard)) return false;
|
|
359
|
+
if (value.soft === undefined && value.hard === undefined) return false;
|
|
360
|
+
if (
|
|
361
|
+
isPositiveInteger(value.soft) &&
|
|
362
|
+
isPositiveInteger(value.hard) &&
|
|
363
|
+
value.soft > value.hard
|
|
364
|
+
) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
212
367
|
return true;
|
|
213
368
|
}
|
|
214
369
|
|
|
370
|
+
export function isAgentReference(value: unknown): value is string {
|
|
371
|
+
return isNonEmptyString(value) && AGENT_REFERENCE_PATTERN.test(value.trim());
|
|
372
|
+
}
|
|
373
|
+
|
|
215
374
|
function isPanelMemberConfig(value: unknown): value is PanelMemberConfig {
|
|
216
375
|
if (!isRecord(value)) return false;
|
|
217
376
|
if (!isNonEmptyString(value.id)) return false;
|
|
218
|
-
if (!isNonEmptyString(value.label)) return false;
|
|
219
|
-
if (!
|
|
377
|
+
if (value.label !== undefined && !isNonEmptyString(value.label)) return false;
|
|
378
|
+
if (!isAgentReference(value.agent)) return false;
|
|
220
379
|
if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
|
|
221
380
|
if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
|
|
222
381
|
return false;
|
|
223
382
|
if (value.role !== undefined && typeof value.role !== "string") return false;
|
|
383
|
+
if (value.question !== undefined && !isNonEmptyString(value.question)) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
224
386
|
return true;
|
|
225
387
|
}
|
|
226
388
|
|
|
227
389
|
function isJudgeConfig(value: unknown): value is JudgeConfig {
|
|
228
390
|
if (!isRecord(value)) return false;
|
|
229
|
-
if (!
|
|
391
|
+
if (!isAgentReference(value.agent)) return false;
|
|
230
392
|
if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
|
|
231
393
|
if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
|
|
232
394
|
return false;
|
package/src/fusion-args.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { FusionArgsError } from "./errors.js";
|
|
|
2
2
|
import type { ParsedFusionArgs } from "./types.js";
|
|
3
3
|
|
|
4
4
|
const FUSION_USAGE =
|
|
5
|
-
"Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion status | /fusion stop | /fusion init.";
|
|
5
|
+
"Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> | /fusion status | /fusion stop | /fusion init.";
|
|
6
6
|
|
|
7
7
|
export type FusionInlineCommand = "init" | "status" | "stop";
|
|
8
8
|
|
|
@@ -27,12 +27,31 @@ export function parseFusionArgs(
|
|
|
27
27
|
if (tokens[0] === "/fusion" || tokens[0] === "fusion") tokens.shift();
|
|
28
28
|
|
|
29
29
|
let profile: string | undefined;
|
|
30
|
+
let panel: string[] | undefined;
|
|
30
31
|
const promptTokens: string[] = [];
|
|
31
32
|
|
|
32
33
|
for (let index = 0; index < tokens.length; index++) {
|
|
33
34
|
const token = tokens[index];
|
|
34
35
|
if (!token) continue;
|
|
35
36
|
|
|
37
|
+
if (promptTokens.length === 0 && token === "--panel") {
|
|
38
|
+
const value = tokens[index + 1];
|
|
39
|
+
if (!value || value.startsWith("-")) {
|
|
40
|
+
throw new FusionArgsError(`Missing value for --panel. ${FUSION_USAGE}`);
|
|
41
|
+
}
|
|
42
|
+
if (panel) throw new FusionArgsError("Panel can only be provided once.");
|
|
43
|
+
panel = parsePanelEntries(value);
|
|
44
|
+
index++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (promptTokens.length === 0 && token.startsWith("--panel=")) {
|
|
49
|
+
const value = token.slice("--panel=".length);
|
|
50
|
+
if (panel) throw new FusionArgsError("Panel can only be provided once.");
|
|
51
|
+
panel = parsePanelEntries(value);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
if (
|
|
37
56
|
promptTokens.length === 0 &&
|
|
38
57
|
(token === "--profile" || token === "-p")
|
|
@@ -72,7 +91,22 @@ export function parseFusionArgs(
|
|
|
72
91
|
|
|
73
92
|
const prompt = promptTokens.join(" ").trim();
|
|
74
93
|
if (!prompt) throw new FusionArgsError(FUSION_USAGE);
|
|
75
|
-
return
|
|
94
|
+
return {
|
|
95
|
+
prompt,
|
|
96
|
+
...(profile ? { profile } : {}),
|
|
97
|
+
...(panel ? { panel } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parsePanelEntries(value: string): string[] {
|
|
102
|
+
const entries = value
|
|
103
|
+
.split(",")
|
|
104
|
+
.map((entry) => entry.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (entries.length === 0) {
|
|
107
|
+
throw new FusionArgsError(`Missing value for --panel. ${FUSION_USAGE}`);
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
76
110
|
}
|
|
77
111
|
|
|
78
112
|
export function tokenizeCommandArgs(input: string): string[] {
|
package/src/index.ts
CHANGED
|
@@ -18,22 +18,30 @@ function registerFusionTool(
|
|
|
18
18
|
name: "start_fusion_review",
|
|
19
19
|
label: "Fusion Review",
|
|
20
20
|
description:
|
|
21
|
-
"Start a pi-fusion
|
|
21
|
+
"Start a pi-fusion review. Several models answer in parallel, then one synthesis step returns a single report. Use it for a hard decision, a design tradeoff, a risk or release review, tricky debugging, a research-heavy question, or a breadth sweep such as an audit or 'what did we miss'. Do not use it for routine edits, formatting, or obvious one-step fixes.",
|
|
22
22
|
promptSnippet: "Start a fusion panel review for a topic or code",
|
|
23
23
|
promptGuidelines: [
|
|
24
|
-
"Use start_fusion_review only for hard decisions, design tradeoffs, risk review, tricky debugging,
|
|
24
|
+
"Use start_fusion_review only for hard decisions, design tradeoffs, risk review, tricky debugging, research-heavy questions, or breadth sweeps such as audits. Do not use it for routine edits, formatting, or obvious one-step fixes.",
|
|
25
|
+
"Pass panel only when the user names the models to compare. Otherwise omit it and let the profile decide.",
|
|
25
26
|
],
|
|
26
27
|
parameters: Type.Object({
|
|
27
28
|
prompt: Type.String({ description: "What to review or discuss" }),
|
|
28
29
|
profile: Type.Optional(
|
|
29
30
|
Type.String({ description: "Fusion profile name (optional)" }),
|
|
30
31
|
),
|
|
32
|
+
panel: Type.Optional(
|
|
33
|
+
Type.Array(Type.String(), {
|
|
34
|
+
description:
|
|
35
|
+
"Models to use for this run, overriding the profile panel. Each entry is <model> or <agent>:<model>. Use only when the user names specific models.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
31
38
|
}),
|
|
32
39
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
33
40
|
const result = await orchestrator.startRun(
|
|
34
41
|
{
|
|
35
42
|
prompt: params.prompt,
|
|
36
43
|
...(params.profile ? { profile: params.profile } : {}),
|
|
44
|
+
...(params.panel?.length ? { panel: params.panel } : {}),
|
|
37
45
|
},
|
|
38
46
|
ctx,
|
|
39
47
|
);
|
|
@@ -48,6 +56,7 @@ function registerFusionTool(
|
|
|
48
56
|
details: {
|
|
49
57
|
prompt: params.prompt,
|
|
50
58
|
profile: params.profile,
|
|
59
|
+
panel: params.panel,
|
|
51
60
|
status: result.status,
|
|
52
61
|
},
|
|
53
62
|
};
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { applyClaudeAliasShorthand } from "./claude-aliases.js";
|
|
1
2
|
import {
|
|
3
|
+
buildInlinePanelProfile,
|
|
2
4
|
loadFusionConfig,
|
|
3
5
|
resolveProfile as resolveFusionProfile,
|
|
4
6
|
type ResolvedFusionProfile,
|
|
@@ -30,12 +32,14 @@ import {
|
|
|
30
32
|
readSubagentResultArtifact,
|
|
31
33
|
readSubagentStatusArtifact,
|
|
32
34
|
} from "./subagent-artifacts.js";
|
|
33
|
-
import
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
import {
|
|
36
|
+
memberLabel,
|
|
37
|
+
resolveSynthesisMode,
|
|
38
|
+
type FailedPanelSummary,
|
|
39
|
+
type FusionProfile,
|
|
40
|
+
type FusionRun,
|
|
41
|
+
type PanelOutput,
|
|
42
|
+
type ParsedFusionArgs,
|
|
39
43
|
} from "./types.js";
|
|
40
44
|
import {
|
|
41
45
|
extractRunObservation,
|
|
@@ -150,9 +154,30 @@ export class FusionOrchestrator {
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
let resolved: ResolvedFusionProfile;
|
|
157
|
+
let baseProfileName: string | undefined;
|
|
153
158
|
try {
|
|
154
159
|
const config = await this.loadConfig(ctx);
|
|
155
160
|
resolved = this.resolveProfile(config, args.profile);
|
|
161
|
+
baseProfileName = resolved.name;
|
|
162
|
+
if (args.panel?.length) {
|
|
163
|
+
// The named profile still supplies the judge and every other setting;
|
|
164
|
+
// only the panel is replaced. Inline models skip the alias pass that
|
|
165
|
+
// runs at config load, so re-run it over the assembled profile.
|
|
166
|
+
const inlineName = `${resolved.name} (inline panel)`;
|
|
167
|
+
const aliased = await applyClaudeAliasShorthand(
|
|
168
|
+
{
|
|
169
|
+
defaultProfile: inlineName,
|
|
170
|
+
profiles: {
|
|
171
|
+
[inlineName]: buildInlinePanelProfile(
|
|
172
|
+
resolved.profile,
|
|
173
|
+
args.panel,
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
ctx,
|
|
178
|
+
);
|
|
179
|
+
resolved = this.resolveProfile(aliased, inlineName);
|
|
180
|
+
}
|
|
156
181
|
this.configWarning = undefined;
|
|
157
182
|
} catch (error: unknown) {
|
|
158
183
|
const message = errorMessage(error);
|
|
@@ -166,6 +191,9 @@ export class FusionOrchestrator {
|
|
|
166
191
|
run = this.runStore.startRun({
|
|
167
192
|
prompt: args.prompt,
|
|
168
193
|
profileName: resolved.name,
|
|
194
|
+
...(args.panel?.length
|
|
195
|
+
? { inlinePanel: args.panel, baseProfileName: baseProfileName }
|
|
196
|
+
: {}),
|
|
169
197
|
...(args.operationId !== undefined
|
|
170
198
|
? { operationId: args.operationId }
|
|
171
199
|
: {}),
|
|
@@ -350,6 +378,12 @@ export class FusionOrchestrator {
|
|
|
350
378
|
? configuredJudgeModel(this.activeProfile)
|
|
351
379
|
: undefined,
|
|
352
380
|
),
|
|
381
|
+
...(this.activeProfile
|
|
382
|
+
? {
|
|
383
|
+
synthesis: resolveSynthesisMode(this.activeProfile),
|
|
384
|
+
panel: this.activeProfile.panel,
|
|
385
|
+
}
|
|
386
|
+
: {}),
|
|
353
387
|
});
|
|
354
388
|
const cancelled = this.runStore.cancelRun(active.id, {
|
|
355
389
|
...(active.chainRunId ? { chainRunId: active.chainRunId } : {}),
|
|
@@ -381,10 +415,19 @@ export class FusionOrchestrator {
|
|
|
381
415
|
|
|
382
416
|
try {
|
|
383
417
|
const config = await this.loadConfig(ctx);
|
|
384
|
-
|
|
418
|
+
// An inline run's profileName is a display name that no config defines,
|
|
419
|
+
// so rebuild it from the base profile plus the persisted entries. Looking
|
|
420
|
+
// the display name up throws, which used to leave activeProfile undefined
|
|
421
|
+
// and fail the run the moment its panel completed.
|
|
422
|
+
const base = this.resolveProfile(
|
|
385
423
|
config,
|
|
386
|
-
active.
|
|
424
|
+
active.inlinePanel?.length
|
|
425
|
+
? active.baseProfileName
|
|
426
|
+
: active.profileName,
|
|
387
427
|
).profile;
|
|
428
|
+
this.activeProfile = active.inlinePanel?.length
|
|
429
|
+
? buildInlinePanelProfile(base, active.inlinePanel)
|
|
430
|
+
: base;
|
|
388
431
|
this.configWarning = undefined;
|
|
389
432
|
} catch (error: unknown) {
|
|
390
433
|
const message = `Could not restore fusion profile "${active.profileName}": ${errorMessage(error)}`;
|
|
@@ -576,6 +619,8 @@ export class FusionOrchestrator {
|
|
|
576
619
|
failures: storedPanelFailures(observed),
|
|
577
620
|
...withJudgeModel(configuredJudgeModel(profile)),
|
|
578
621
|
judgeObservation,
|
|
622
|
+
...(profile.blindPanelLabels ? { blindPanelLabels: true } : {}),
|
|
623
|
+
synthesis: resolveSynthesisMode(profile),
|
|
579
624
|
});
|
|
580
625
|
return this.completeActiveRun(report);
|
|
581
626
|
}
|
|
@@ -814,9 +859,17 @@ export class FusionOrchestrator {
|
|
|
814
859
|
const output = extractJudgeOutput(lifecyclePayload);
|
|
815
860
|
if (!output.ok) return this.failActiveRun(output.error);
|
|
816
861
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
862
|
+
// The panel and chain handlers already treat a missing profile as fatal.
|
|
863
|
+
// Without the same guard here the run "succeeds" with a degraded report:
|
|
864
|
+
// blind labels are never restored, and the judge model is dropped.
|
|
865
|
+
const profile = this.activeProfile;
|
|
866
|
+
if (!profile) {
|
|
867
|
+
return this.failActiveRun(
|
|
868
|
+
"Fusion judge completed, but the active profile was not available.",
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const judgeModel = configuredJudgeModel(profile);
|
|
820
873
|
const judgeObservation = mergeRunObservations(
|
|
821
874
|
extractRunObservation(
|
|
822
875
|
findStepsArray(snapshot.statusPayload)[0] ?? snapshot.statusPayload,
|
|
@@ -837,6 +890,8 @@ export class FusionOrchestrator {
|
|
|
837
890
|
failures: storedPanelFailures(observed),
|
|
838
891
|
...withJudgeModel(judgeModel),
|
|
839
892
|
judgeObservation,
|
|
893
|
+
...(profile.blindPanelLabels ? { blindPanelLabels: true } : {}),
|
|
894
|
+
synthesis: resolveSynthesisMode(profile),
|
|
840
895
|
});
|
|
841
896
|
return this.completeActiveRun(report);
|
|
842
897
|
}
|
|
@@ -989,6 +1044,12 @@ export class FusionOrchestrator {
|
|
|
989
1044
|
? configuredJudgeModel(this.activeProfile)
|
|
990
1045
|
: undefined,
|
|
991
1046
|
),
|
|
1047
|
+
...(this.activeProfile
|
|
1048
|
+
? {
|
|
1049
|
+
synthesis: resolveSynthesisMode(this.activeProfile),
|
|
1050
|
+
panel: this.activeProfile.panel,
|
|
1051
|
+
}
|
|
1052
|
+
: {}),
|
|
992
1053
|
});
|
|
993
1054
|
}
|
|
994
1055
|
|
|
@@ -1269,7 +1330,7 @@ function buildFusionStatusDetails(
|
|
|
1269
1330
|
const activity = describeStepActivity(step);
|
|
1270
1331
|
const metrics = describeStepMetrics(step);
|
|
1271
1332
|
return {
|
|
1272
|
-
label: member
|
|
1333
|
+
label: memberLabel(member),
|
|
1273
1334
|
...(member.role ? { role: member.role } : {}),
|
|
1274
1335
|
...(model ? { model } : {}),
|
|
1275
1336
|
status: describePanelStatus(step),
|
|
@@ -1334,7 +1395,7 @@ function buildCompletedPanelStatusLines(
|
|
|
1334
1395
|
);
|
|
1335
1396
|
if (output) {
|
|
1336
1397
|
return {
|
|
1337
|
-
label: member
|
|
1398
|
+
label: memberLabel(member),
|
|
1338
1399
|
...(member.role ? { role: member.role } : {}),
|
|
1339
1400
|
...(model ? { model } : {}),
|
|
1340
1401
|
status: "completed",
|
|
@@ -1344,7 +1405,7 @@ function buildCompletedPanelStatusLines(
|
|
|
1344
1405
|
(item) => item.id === member.id || item.index === index,
|
|
1345
1406
|
);
|
|
1346
1407
|
return {
|
|
1347
|
-
label: member
|
|
1408
|
+
label: memberLabel(member),
|
|
1348
1409
|
...(member.role ? { role: member.role } : {}),
|
|
1349
1410
|
...(model ? { model } : {}),
|
|
1350
1411
|
status: failure ? "failed" : "unknown",
|
package/src/panel-completion.ts
CHANGED
|
@@ -4,11 +4,12 @@ import {
|
|
|
4
4
|
buildJudgeSpawnParams,
|
|
5
5
|
type JudgeSpawnParams,
|
|
6
6
|
} from "./run-builder.js";
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import {
|
|
8
|
+
resolveSynthesisMode,
|
|
9
|
+
type FailedPanelSummary,
|
|
10
|
+
type FusionProfile,
|
|
11
|
+
type FusionRun,
|
|
12
|
+
type PanelOutput,
|
|
12
13
|
} from "./types.js";
|
|
13
14
|
|
|
14
15
|
export type PanelCompletionDecision =
|
|
@@ -39,6 +40,8 @@ export function decidePanelCompletion(
|
|
|
39
40
|
run: input.run,
|
|
40
41
|
failures: input.panelFailures,
|
|
41
42
|
...withJudgeModel(judgeModel),
|
|
43
|
+
synthesis: resolveSynthesisMode(input.profile),
|
|
44
|
+
panel: input.profile.panel,
|
|
42
45
|
});
|
|
43
46
|
return {
|
|
44
47
|
kind: "fail",
|
|
@@ -47,7 +50,14 @@ export function decidePanelCompletion(
|
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
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.
|
|
57
|
+
if (
|
|
58
|
+
input.panelOutputs.length === 1 &&
|
|
59
|
+
resolveSynthesisMode(input.profile) !== "merge"
|
|
60
|
+
) {
|
|
51
61
|
const report = renderSinglePanelReport({
|
|
52
62
|
run: input.run,
|
|
53
63
|
output: input.panelOutputs[0]!,
|
|
@@ -64,6 +74,7 @@ export function decidePanelCompletion(
|
|
|
64
74
|
prompt: input.run.prompt,
|
|
65
75
|
panelOutputs: input.panelOutputs,
|
|
66
76
|
failedPanelists: input.panelFailures,
|
|
77
|
+
runId: input.run.id,
|
|
67
78
|
}),
|
|
68
79
|
missingRunIdError: input.fallbackJudge
|
|
69
80
|
? "pi-subagents spawn did not return a fallback judge run ID."
|