@alexeiled/pi-fusion 0.7.0 → 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.
- package/README.md +4 -3
- package/docs/user-guide.md +15 -12
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/config.ts +26 -0
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +29 -0
- package/src/index.ts +33 -5
- package/src/lifecycle-reconcile.ts +203 -6
- package/src/orchestrator.ts +487 -71
- package/src/panel-completion.ts +65 -17
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +85 -3
- package/src/result-extract.ts +66 -4
- package/src/run-builder.ts +84 -8
- package/src/run-store.ts +360 -11
- package/src/status.ts +1 -1
- package/src/types.ts +75 -0
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ Other Pi extensions can control Fusion through the versioned event-bus contract
|
|
|
156
156
|
Methods:
|
|
157
157
|
|
|
158
158
|
- `ping` — return the RPC version and supported methods
|
|
159
|
-
- `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile
|
|
159
|
+
- `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile`, versioned `outputContract` (`plan-review-v1`), and positive-integer millisecond deadline overrides: `panelistTimeoutMs`, `panelTimeoutMs`, `panelGraceMs`, and `judgeTimeoutMs`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
|
|
160
160
|
- `status` — return structured run state by `operationId`, `runId`, or the current/last run
|
|
161
161
|
- `result` — return a terminal run and report. An active run returns `not_ready`
|
|
162
162
|
- `cancel` — cancel the selected active run, or report that the selected terminal run was not cancelled
|
|
@@ -207,12 +207,13 @@ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./
|
|
|
207
207
|
- Config is optional. Defaults work. Use `/fusion init` when you want project config.
|
|
208
208
|
- Project config lives at `.pi/fusion.json`. Global config lives at `~/.pi/agent/fusion.json`.
|
|
209
209
|
- Output appears as a Pi custom message. Active progress also uses the `fusion` status key.
|
|
210
|
-
- Active runs are reconciled from `pi-subagents` lifecycle artifacts, not only completion events.
|
|
210
|
+
- Active runs are reconciled from `pi-subagents` lifecycle artifacts, not only completion events. Verified panel outputs survive a panel deadline; unavailable perspectives and timeout failures are disclosed in a partial report when quorum is not met or coverage is incomplete.
|
|
211
|
+
- Timeouts end the current child or workflow attempt. Fusion never automatically retries panelists, restarts a panel, or extends a deadline; start a new run manually after the terminal report.
|
|
211
212
|
- `pi-fusion` does not own the footer.
|
|
212
213
|
- Fusion sends your prompt and any inspected snippets to every panel model, and to the judge, through `pi-subagents`.
|
|
213
214
|
- Reports include available per-panel and judge time, aggregate model time, usage, estimated cost, and model failure details. Missing provider usage is shown as unknown. `$0.0000` is a known zero cost.
|
|
214
215
|
- `Model` is lifecycle metadata. `Configured model` is the profile request. Both appear when the run differs from the request.
|
|
215
|
-
- `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence,
|
|
216
|
+
- `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence, initially executes only the configured synthesis quorum (not always two) so it can avoid starting later work, records skipped panelists in the report, and still runs the judge.
|
|
216
217
|
- Panel answers reach the judge in an order seeded from the run id, not in config order. A fixed order advantages the same member on every run, because judges favour whichever candidate they see first or last.
|
|
217
218
|
- Panelists can search the web by opting in to the `fusion-panelist-web` agent, which requires `pi-web-providers`. Defaults stay local-only on purpose: tool names are a strict allowlist, so an agent declaring a tool whose extension is missing fails every task that uses it.
|
|
218
219
|
- `synthesis: "merge"` switches from picking the best answer to merging answers that covered different facets, using the `fusion-composer` agent. Panel members get facets through their optional `question` field. See the user guide.
|
package/docs/user-guide.md
CHANGED
|
@@ -29,7 +29,7 @@ prompts, or both. **Mixing models is the main lever.** The config that
|
|
|
29
29
|
`/fusion init` writes sets no `model`. By default you therefore get one model in
|
|
30
30
|
three roles. Give each member its own `model` to get the real benefit.
|
|
31
31
|
|
|
32
|
-
Fusion launches new panels through `pi-subagents` `workflowScript`; the panel and judge remain separate durable runs. Older runs created
|
|
32
|
+
Fusion launches new panels through `pi-subagents` `workflowScript`; the panel and judge remain separate durable runs. At start, Fusion persists a small resolved profile snapshot (panel labels/models/roles, quorum, synthesis and judge settings) and uses it after restart, so later config edits cannot change an active run's reconciliation, report, or synthesis spawn. Older runs created before this snapshot, including single-chain runs, retain the legacy config-lookup restore fallback. Before each public RPC spawn, Fusion persists a spawn intent. If Pi crashes after that RPC might have started but before its remote run ID is saved, restore fails that local run with an explicit recovery warning instead of spawning a possible duplicate (public RPC cannot safely adopt by correlation key). A corrupt newest run snapshot is likewise refused rather than reviving an older active run.
|
|
33
33
|
|
|
34
34
|
The base Pi session stays in control. Fusion is a tool for decisions, not a replacement for normal coding.
|
|
35
35
|
|
|
@@ -152,18 +152,21 @@ Profile:
|
|
|
152
152
|
|
|
153
153
|
- `panel`: one or more panel members
|
|
154
154
|
- `judge`: judge agent config
|
|
155
|
-
- `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion
|
|
156
|
-
- `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It
|
|
157
|
-
- `
|
|
158
|
-
- `
|
|
155
|
+
- `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion initially executes only the resolved synthesis quorum (capped by `concurrency`) before launching another batch, so a non-default quorum—not always the first two—governs early agreement.
|
|
156
|
+
- `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It is a fallback only; `/fusion status` warns when it supplied an effective deadline.
|
|
157
|
+
- `panelistTimeoutMs`: per-panelist deadline. Fusion caps it below the enclosing panel deadline.
|
|
158
|
+
- `panelTimeoutMs`: panel workflow wall-clock deadline.
|
|
159
|
+
- `panelGraceMs`: reserved time between a child deadline and the enclosing panel deadline (default 5 seconds).
|
|
160
|
+
- `judgeTimeoutMs`: synthesis workflow deadline. For every deadline the precedence is per-run CLI/tool/RPC override, stage profile field, legacy `timeoutMs`, then the built-in default.
|
|
161
|
+
- `minimumSuccessfulPanelists`: `"majority"` (default), `"all"`, or a positive number. It is the panel quorum for synthesis. For a multi-member panel, numeric `1` is effectively `2`: synthesis needs two candidate answers. A one-member panel remains a direct single-panel result.
|
|
159
162
|
- `context`: `fresh` or `fork`
|
|
160
|
-
- `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet.
|
|
163
|
+
- `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet only after the configured synthesis quorum is already met. The agreement conditions also require two or more finished panelists to give the same normalized recommendation, every one to report `high` confidence, none to ask for more evidence, and work to remain. Thus an `"all"` quorum never skips unfinished panelists. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
|
|
161
164
|
- `synthesis`: rarely needed. Inferred from the panel — any member with a `question` means `merge`, otherwise `select`. Set it only to override that. See [Synthesis modes](#synthesis-modes).
|
|
162
165
|
- `blindPanelLabels`: optional boolean, default `false`. When it is on, the judge sees `Candidate A`, `Candidate B`, and so on, instead of the configured labels. Fusion also withholds agent names and artifact paths, because they contain the member id. A role label reads as an authority cue before the judge compares any content. Your report always shows the real names.
|
|
163
166
|
- `panelToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` applied to each panelist. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. After `hard`, the selected tools are blocked so the panelist can still finalise.
|
|
164
167
|
- `judgeToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` for the judge or composer. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. `soft` is a nudge. After `hard`, the selected tools are blocked so synthesis can still finalise. `soft` or `hard` must be positive integers when present, and `soft` must not be larger than `hard` when both are present. Legacy soft-only budgets remain valid.
|
|
165
168
|
|
|
166
|
-
Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion keeps completed
|
|
169
|
+
Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion durably keeps verified completed slots, turns terminal running/interrupted slots into typed failures, and fails closed when lifecycle sources genuinely disagree. A timed-out judge never becomes a panel-only success. When at least one valid panel result exists, Fusion produces either synthesis at quorum or an explicitly unsynthesized partial report below quorum; failures and timeouts are disclosed as missing coverage. Only zero successful outputs fail outright. Fusion never automatically retries a failed panelist, restarts a panel, or extends a deadline.
|
|
167
170
|
|
|
168
171
|
Panel member:
|
|
169
172
|
|
|
@@ -294,9 +297,7 @@ run does not fail, so a mismatch appears as an empty report, not as an error.
|
|
|
294
297
|
}
|
|
295
298
|
```
|
|
296
299
|
|
|
297
|
-
Under `merge`,
|
|
298
|
-
not return that answer directly. One facet is not the answer, and the report must
|
|
299
|
-
name what is missing.
|
|
300
|
+
Under `merge`, surviving outputs at quorum go to the composer even when some facets are unavailable. The composer must name uncovered facets rather than presenting complete coverage. Below quorum Fusion posts an explicitly partial coverage report; it never presents one facet as the full answer.
|
|
300
301
|
|
|
301
302
|
Judge:
|
|
302
303
|
|
|
@@ -483,7 +484,9 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
|
|
|
483
484
|
- raise `panelTimeoutMs` or `judgeTimeoutMs` for slower models
|
|
484
485
|
- keep `panelToolBudget` and `judgeToolBudget` bounded so agents finalise before the deadline
|
|
485
486
|
- inspect `/fusion status`; panel failures and the workflow timeout must both be present
|
|
486
|
-
- retry
|
|
487
|
+
- a timeout ends that child/run attempt; Fusion does not retry panelists, restart the panel, or extend deadlines
|
|
488
|
+
- failed-only retry is deliberately not exposed yet: terminal failure state persists the failed panel slot indices for recovery/provenance, but `/fusion` starts a new independent run rather than replaying only those slots
|
|
489
|
+
- retry manually only after the run is terminal
|
|
487
490
|
|
|
488
491
|
Run is stuck or no longer useful:
|
|
489
492
|
|
|
@@ -501,4 +504,4 @@ Notes:
|
|
|
501
504
|
|
|
502
505
|
- `Panel run` is the normal panel phase for new Fusion runs.
|
|
503
506
|
- `Judge run` is the normal synthesis phase for new runs. `Fallback judge run` appears only while restoring a legacy chain that completed without its judge result.
|
|
504
|
-
- If `pi-subagents` completion notifications are delayed or missed, Fusion still reconciles from lifecycle artifacts written under the subagent async run directory.
|
|
507
|
+
- If `pi-subagents` completion notifications are delayed or missed, Fusion still reconciles from lifecycle artifacts written under the subagent async run directory. The full result artifact is preferred over compact completion events.
|
package/package.json
CHANGED
|
@@ -76,10 +76,11 @@ better.
|
|
|
76
76
|
## After the call
|
|
77
77
|
|
|
78
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.
|
|
79
|
+
and Fusion posts the report when they finish. A panel deadline can produce an explicitly partial report that names unavailable perspectives and timeout coverage gaps.
|
|
80
80
|
|
|
81
81
|
- Do not call the tool again while a run is active. It returns a conflict with
|
|
82
82
|
the id of the active run.
|
|
83
83
|
- Do not summarize or predict the report. Wait for it.
|
|
84
84
|
- To show progress or stop a run, tell the user to type `/fusion status` or
|
|
85
85
|
`/fusion stop`. No tool does this.
|
|
86
|
+
- Do not invoke another review automatically after a partial report or timeout. Fusion does not retry failed panelists; users can inspect `/fusion status` and manually rerun only after the current run is terminal.
|
package/src/config.ts
CHANGED
|
@@ -325,18 +325,44 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
325
325
|
return false;
|
|
326
326
|
if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
|
|
327
327
|
return false;
|
|
328
|
+
if (
|
|
329
|
+
value.panelistTimeoutMs !== undefined &&
|
|
330
|
+
!isPositiveInteger(value.panelistTimeoutMs)
|
|
331
|
+
) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
328
334
|
if (
|
|
329
335
|
value.panelTimeoutMs !== undefined &&
|
|
330
336
|
!isPositiveInteger(value.panelTimeoutMs)
|
|
331
337
|
) {
|
|
332
338
|
return false;
|
|
333
339
|
}
|
|
340
|
+
if (
|
|
341
|
+
value.panelGraceMs !== undefined &&
|
|
342
|
+
!isPositiveInteger(value.panelGraceMs)
|
|
343
|
+
) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
const panelTimeoutMs = value.panelTimeoutMs ?? value.timeoutMs ?? 900_000;
|
|
347
|
+
const panelGraceMs = value.panelGraceMs ?? 5_000;
|
|
348
|
+
// Never accept a configuration that would leave child panelists a 1ms
|
|
349
|
+
// timeout after deadline capping. Overrides receive the same validation when
|
|
350
|
+
// their effective values are resolved at run start.
|
|
351
|
+
if (panelGraceMs >= panelTimeoutMs) return false;
|
|
334
352
|
if (
|
|
335
353
|
value.judgeTimeoutMs !== undefined &&
|
|
336
354
|
!isPositiveInteger(value.judgeTimeoutMs)
|
|
337
355
|
) {
|
|
338
356
|
return false;
|
|
339
357
|
}
|
|
358
|
+
if (
|
|
359
|
+
value.minimumSuccessfulPanelists !== undefined &&
|
|
360
|
+
value.minimumSuccessfulPanelists !== "majority" &&
|
|
361
|
+
value.minimumSuccessfulPanelists !== "all" &&
|
|
362
|
+
!isPositiveInteger(value.minimumSuccessfulPanelists)
|
|
363
|
+
) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
340
366
|
if (value.context !== undefined && !isFusionContextMode(value.context))
|
|
341
367
|
return false;
|
|
342
368
|
if (
|
package/src/fusion-args.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { FusionArgsError } from "./errors.js";
|
|
2
|
-
import type { ParsedFusionArgs } from "./types.js";
|
|
2
|
+
import type { FusionTimeoutOverrides, ParsedFusionArgs } from "./types.js";
|
|
3
3
|
|
|
4
4
|
const FUSION_USAGE =
|
|
5
|
-
"Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> | /fusion status | /fusion stop | /fusion init.";
|
|
5
|
+
"Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> [--panelist-timeout-ms n --panel-timeout-ms n --panel-grace-ms n --judge-timeout-ms n] | /fusion status | /fusion stop | /fusion init.";
|
|
6
6
|
|
|
7
7
|
export type FusionInlineCommand = "init" | "status" | "stop";
|
|
8
8
|
|
|
@@ -28,6 +28,13 @@ export function parseFusionArgs(
|
|
|
28
28
|
|
|
29
29
|
let profile: string | undefined;
|
|
30
30
|
let panel: string[] | undefined;
|
|
31
|
+
const timeoutOverrides: FusionTimeoutOverrides = {};
|
|
32
|
+
const timeoutOptions: Record<string, keyof FusionTimeoutOverrides> = {
|
|
33
|
+
"--panelist-timeout-ms": "panelistTimeoutMs",
|
|
34
|
+
"--panel-timeout-ms": "panelTimeoutMs",
|
|
35
|
+
"--panel-grace-ms": "panelGraceMs",
|
|
36
|
+
"--judge-timeout-ms": "judgeTimeoutMs",
|
|
37
|
+
};
|
|
31
38
|
const promptTokens: string[] = [];
|
|
32
39
|
|
|
33
40
|
for (let index = 0; index < tokens.length; index++) {
|
|
@@ -81,6 +88,25 @@ export function parseFusionArgs(
|
|
|
81
88
|
continue;
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
const timeoutKey = timeoutOptions[token];
|
|
92
|
+
const timeoutEquals = Object.entries(timeoutOptions).find(([option]) =>
|
|
93
|
+
token.startsWith(`${option}=`),
|
|
94
|
+
);
|
|
95
|
+
if (promptTokens.length === 0 && (timeoutKey || timeoutEquals)) {
|
|
96
|
+
const key = timeoutKey ?? timeoutEquals?.[1];
|
|
97
|
+
const raw = timeoutKey ? tokens[index + 1] : token.slice((timeoutEquals?.[0].length ?? 0) + 1);
|
|
98
|
+
const value = raw ? Number(raw) : NaN;
|
|
99
|
+
if (!key || !Number.isInteger(value) || value <= 0) {
|
|
100
|
+
throw new FusionArgsError(`Timeout options require a positive integer milliseconds value. ${FUSION_USAGE}`);
|
|
101
|
+
}
|
|
102
|
+
if (timeoutOverrides[key] !== undefined) {
|
|
103
|
+
throw new FusionArgsError(`${token.split("=")[0]} can only be provided once.`);
|
|
104
|
+
}
|
|
105
|
+
timeoutOverrides[key] = value;
|
|
106
|
+
if (timeoutKey) index++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
if (promptTokens.length === 0 && token.startsWith("-")) {
|
|
85
111
|
throw new FusionArgsError(`Unknown option ${token}. ${FUSION_USAGE}`);
|
|
86
112
|
}
|
|
@@ -95,6 +121,7 @@ export function parseFusionArgs(
|
|
|
95
121
|
prompt,
|
|
96
122
|
...(profile ? { profile } : {}),
|
|
97
123
|
...(panel ? { panel } : {}),
|
|
124
|
+
...(Object.keys(timeoutOverrides).length ? { timeoutOverrides } : {}),
|
|
98
125
|
};
|
|
99
126
|
}
|
|
100
127
|
|
package/src/fusion-rpc.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
CallerOutputContract,
|
|
13
13
|
FusionPhase,
|
|
14
14
|
FusionRun,
|
|
15
|
+
FusionTimeoutOverrides,
|
|
15
16
|
ParsedFusionArgs,
|
|
16
17
|
} from "./types.js";
|
|
17
18
|
import { isNonEmptyString, isRecord } from "./utils.js";
|
|
@@ -151,6 +152,7 @@ interface StartParams {
|
|
|
151
152
|
profile?: string;
|
|
152
153
|
operationId: string;
|
|
153
154
|
outputContract?: CallerOutputContract;
|
|
155
|
+
timeoutOverrides?: FusionTimeoutOverrides;
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
interface RunParams {
|
|
@@ -425,6 +427,8 @@ function parseStartParams(input: unknown): StartParams {
|
|
|
425
427
|
);
|
|
426
428
|
}
|
|
427
429
|
|
|
430
|
+
const timeoutOverrides = parseTimeoutOverrides(input);
|
|
431
|
+
|
|
428
432
|
const outputContract = input.outputContract;
|
|
429
433
|
if (outputContract !== undefined && !isCallerOutputContract(outputContract)) {
|
|
430
434
|
throw invalidParams(
|
|
@@ -437,9 +441,31 @@ function parseStartParams(input: unknown): StartParams {
|
|
|
437
441
|
operationId,
|
|
438
442
|
...(profile === undefined ? {} : { profile }),
|
|
439
443
|
...(outputContract === undefined ? {} : { outputContract }),
|
|
444
|
+
...(timeoutOverrides ? { timeoutOverrides } : {}),
|
|
440
445
|
};
|
|
441
446
|
}
|
|
442
447
|
|
|
448
|
+
function parseTimeoutOverrides(
|
|
449
|
+
input: Record<string, unknown>,
|
|
450
|
+
): FusionTimeoutOverrides | undefined {
|
|
451
|
+
const fields = [
|
|
452
|
+
["panelistTimeoutMs", "panelistTimeoutMs"],
|
|
453
|
+
["panelTimeoutMs", "panelTimeoutMs"],
|
|
454
|
+
["panelGraceMs", "panelGraceMs"],
|
|
455
|
+
["judgeTimeoutMs", "judgeTimeoutMs"],
|
|
456
|
+
] as const;
|
|
457
|
+
const overrides: FusionTimeoutOverrides = {};
|
|
458
|
+
for (const [wireName, key] of fields) {
|
|
459
|
+
const value = input[wireName];
|
|
460
|
+
if (value === undefined) continue;
|
|
461
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
462
|
+
throw invalidParams(`${wireName} must be a positive integer when provided.`);
|
|
463
|
+
}
|
|
464
|
+
overrides[key] = value;
|
|
465
|
+
}
|
|
466
|
+
return Object.keys(overrides).length ? overrides : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
443
469
|
function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
|
|
444
470
|
return {
|
|
445
471
|
prompt: input.prompt,
|
|
@@ -448,6 +474,9 @@ function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
|
|
|
448
474
|
...(input.outputContract === undefined
|
|
449
475
|
? {}
|
|
450
476
|
: { outputContract: input.outputContract }),
|
|
477
|
+
...(input.timeoutOverrides === undefined
|
|
478
|
+
? {}
|
|
479
|
+
: { timeoutOverrides: input.timeoutOverrides }),
|
|
451
480
|
};
|
|
452
481
|
}
|
|
453
482
|
|
package/src/index.ts
CHANGED
|
@@ -25,23 +25,45 @@ function registerFusionTool(
|
|
|
25
25
|
"Pass panel only when the user names the models to compare. Otherwise omit it and let the profile decide.",
|
|
26
26
|
],
|
|
27
27
|
parameters: Type.Object({
|
|
28
|
-
prompt: Type.String({
|
|
28
|
+
prompt: Type.String({
|
|
29
|
+
minLength: 1,
|
|
30
|
+
pattern: ".*\\S.*",
|
|
31
|
+
description: "What to review or discuss (must contain non-whitespace text)",
|
|
32
|
+
}),
|
|
29
33
|
profile: Type.Optional(
|
|
30
|
-
Type.String({ description: "Fusion profile name (optional)" }),
|
|
34
|
+
Type.String({ minLength: 1, description: "Fusion profile name (optional)" }),
|
|
31
35
|
),
|
|
32
36
|
panel: Type.Optional(
|
|
33
|
-
Type.Array(Type.String(), {
|
|
37
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
38
|
+
minItems: 1,
|
|
34
39
|
description:
|
|
35
40
|
"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
41
|
}),
|
|
37
42
|
),
|
|
43
|
+
panelistTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Per-panelist deadline in milliseconds" })),
|
|
44
|
+
panelTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Panel workflow deadline in milliseconds" })),
|
|
45
|
+
panelGraceMs: Type.Optional(Type.Integer({ minimum: 1, description: "Reserved grace between child and panel deadlines in milliseconds" })),
|
|
46
|
+
judgeTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Judge/composer deadline in milliseconds" })),
|
|
38
47
|
}),
|
|
39
48
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
40
49
|
const result = await orchestrator.startRun(
|
|
41
50
|
{
|
|
42
51
|
prompt: params.prompt,
|
|
43
|
-
...(params.profile ? { profile: params.profile } : {}),
|
|
44
|
-
...(params.panel
|
|
52
|
+
...(params.profile !== undefined ? { profile: params.profile } : {}),
|
|
53
|
+
...(params.panel !== undefined ? { panel: params.panel } : {}),
|
|
54
|
+
...(params.panelistTimeoutMs !== undefined ||
|
|
55
|
+
params.panelTimeoutMs !== undefined ||
|
|
56
|
+
params.panelGraceMs !== undefined ||
|
|
57
|
+
params.judgeTimeoutMs !== undefined
|
|
58
|
+
? {
|
|
59
|
+
timeoutOverrides: {
|
|
60
|
+
...(params.panelistTimeoutMs !== undefined ? { panelistTimeoutMs: params.panelistTimeoutMs } : {}),
|
|
61
|
+
...(params.panelTimeoutMs !== undefined ? { panelTimeoutMs: params.panelTimeoutMs } : {}),
|
|
62
|
+
...(params.panelGraceMs !== undefined ? { panelGraceMs: params.panelGraceMs } : {}),
|
|
63
|
+
...(params.judgeTimeoutMs !== undefined ? { judgeTimeoutMs: params.judgeTimeoutMs } : {}),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: {}),
|
|
45
67
|
},
|
|
46
68
|
ctx,
|
|
47
69
|
);
|
|
@@ -57,6 +79,12 @@ function registerFusionTool(
|
|
|
57
79
|
prompt: params.prompt,
|
|
58
80
|
profile: params.profile,
|
|
59
81
|
panel: params.panel,
|
|
82
|
+
timeoutOverrides: {
|
|
83
|
+
panelistTimeoutMs: params.panelistTimeoutMs,
|
|
84
|
+
panelTimeoutMs: params.panelTimeoutMs,
|
|
85
|
+
panelGraceMs: params.panelGraceMs,
|
|
86
|
+
judgeTimeoutMs: params.judgeTimeoutMs,
|
|
87
|
+
},
|
|
60
88
|
status: result.status,
|
|
61
89
|
},
|
|
62
90
|
};
|
|
@@ -20,8 +20,11 @@ export function reconcileIndexedLifecycleResult(
|
|
|
20
20
|
|
|
21
21
|
const statusSteps = findLifecycleArray(statusPayload, "steps");
|
|
22
22
|
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
23
|
-
const statusResults =
|
|
24
|
-
|
|
23
|
+
const statusResults = authoritativeStatusLifecycleArray(
|
|
24
|
+
statusPayload,
|
|
25
|
+
statusSteps,
|
|
26
|
+
rawStatusResults,
|
|
27
|
+
);
|
|
25
28
|
const statusResult = statusResults?.[index];
|
|
26
29
|
if (statusResults && !isRecord(statusResult)) {
|
|
27
30
|
return `Subagents event includes ${label} result ${index + 1}, but status does not.`;
|
|
@@ -38,6 +41,8 @@ export interface ReconcilePanelResultsOptions {
|
|
|
38
41
|
allowedTrailingResults?: number;
|
|
39
42
|
/** Indices intentionally absent from status after early agreement. */
|
|
40
43
|
stoppedPanelIndices?: readonly number[];
|
|
44
|
+
/** A workflow deadline terminalized running child slots as timeout failures. */
|
|
45
|
+
terminalizeRunning?: boolean;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -64,8 +69,14 @@ export function reconcilePanelResults(
|
|
|
64
69
|
|
|
65
70
|
const rawStatusSteps = findLifecycleArray(statusPayload, "steps");
|
|
66
71
|
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
// Preserve an explicit terminal `results: []`: it is authoritative even
|
|
73
|
+
// when it contains no child results. A running empty poll, however, is not
|
|
74
|
+
// a terminal lifecycle assertion and can race a completion event.
|
|
75
|
+
const rawStatus = authoritativeStatusLifecycleArray(
|
|
76
|
+
statusPayload,
|
|
77
|
+
rawStatusSteps,
|
|
78
|
+
rawStatusResults,
|
|
79
|
+
);
|
|
69
80
|
if (!rawStatus) {
|
|
70
81
|
if (eventResults.outputs.length + eventResults.failures.length !== expectedCount) {
|
|
71
82
|
return error(
|
|
@@ -85,7 +96,9 @@ export function reconcilePanelResults(
|
|
|
85
96
|
|
|
86
97
|
const statusResults = extractPanelResults(statusPayload, {
|
|
87
98
|
panel: profile.panel,
|
|
88
|
-
|
|
99
|
+
...(options.terminalizeRunning
|
|
100
|
+
? { terminalizeRunning: true }
|
|
101
|
+
: { completedOnly: true }),
|
|
89
102
|
limit: expectedCount,
|
|
90
103
|
...(options.stoppedPanelIndices
|
|
91
104
|
? { stoppedPanelIndices: options.stoppedPanelIndices }
|
|
@@ -100,6 +113,25 @@ export function reconcilePanelResults(
|
|
|
100
113
|
|
|
101
114
|
const statusCount =
|
|
102
115
|
statusResults.outputs.length + statusResults.failures.length;
|
|
116
|
+
if (
|
|
117
|
+
!options.terminalizeRunning &&
|
|
118
|
+
eventResults.outputs.length + eventResults.failures.length === expectedCount
|
|
119
|
+
) {
|
|
120
|
+
const eventSucceeded = new Set(eventResults.outputs.map((item) => item.index));
|
|
121
|
+
const statusSucceeded = new Set(statusResults.outputs.map((item) => item.index));
|
|
122
|
+
for (let index = 0; index < expectedCount; index++) {
|
|
123
|
+
const eventKnown =
|
|
124
|
+
eventSucceeded.has(index) || eventResults.failures.some((item) => item.index === index);
|
|
125
|
+
const statusKnown =
|
|
126
|
+
statusSucceeded.has(index) || statusResults.failures.some((item) => item.index === index);
|
|
127
|
+
if (eventKnown && statusKnown && eventSucceeded.has(index) !== statusSucceeded.has(index)) {
|
|
128
|
+
return error(
|
|
129
|
+
`Subagents event and status disagree about panel result ${index + 1}.`,
|
|
130
|
+
`$.steps[${index}]`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
103
135
|
const eventCount =
|
|
104
136
|
eventResults.outputs.length + eventResults.failures.length;
|
|
105
137
|
const statusIndices = new Set([
|
|
@@ -113,7 +145,14 @@ export function reconcilePanelResults(
|
|
|
113
145
|
).filter((index) => !statusIndices.has(index));
|
|
114
146
|
|
|
115
147
|
if (statusCount === expectedCount) {
|
|
116
|
-
return
|
|
148
|
+
return options.terminalizeRunning
|
|
149
|
+
? mergeTerminalDeadlineResults(
|
|
150
|
+
eventResults,
|
|
151
|
+
statusResults,
|
|
152
|
+
statusPayload,
|
|
153
|
+
resultPayload,
|
|
154
|
+
)
|
|
155
|
+
: preserveAgreementReasons(statusResults, eventResults);
|
|
117
156
|
}
|
|
118
157
|
|
|
119
158
|
const missingWereStopped =
|
|
@@ -137,6 +176,124 @@ export function reconcilePanelResults(
|
|
|
137
176
|
return mergeObservations(eventResults, statusResults);
|
|
138
177
|
}
|
|
139
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Status is normally authoritative. At a workflow deadline it can still show
|
|
181
|
+
* a child as running even though the completion artifact contains that child's
|
|
182
|
+
* verified final output. Keep that verified output, normalize only genuinely
|
|
183
|
+
* unfinished slots, and retain status observations/failure details.
|
|
184
|
+
*/
|
|
185
|
+
function mergeTerminalDeadlineResults(
|
|
186
|
+
event: ExtractPanelResultsSuccess,
|
|
187
|
+
status: ExtractPanelResultsSuccess,
|
|
188
|
+
statusPayload: unknown,
|
|
189
|
+
eventPayload: unknown,
|
|
190
|
+
): ExtractPanelResultsSuccess {
|
|
191
|
+
const eventOutputs = new Map(event.outputs.map((item) => [item.index, item]));
|
|
192
|
+
const eventFailures = new Map(event.failures.map((item) => [item.index, item]));
|
|
193
|
+
const statusOutputs = new Map(status.outputs.map((item) => [item.index, item]));
|
|
194
|
+
const statusFailures = new Map(status.failures.map((item) => [item.index, item]));
|
|
195
|
+
const replaceableSlots = deadlineEventReplacementSlots(
|
|
196
|
+
statusPayload,
|
|
197
|
+
eventPayload,
|
|
198
|
+
);
|
|
199
|
+
const outputs: PanelOutput[] = [];
|
|
200
|
+
const failures: FailedPanelSummary[] = [];
|
|
201
|
+
const maxIndex = Math.max(
|
|
202
|
+
...[...eventOutputs.keys(), ...eventFailures.keys(), ...statusOutputs.keys(), ...statusFailures.keys()],
|
|
203
|
+
-1,
|
|
204
|
+
);
|
|
205
|
+
for (let index = 0; index <= maxIndex; index++) {
|
|
206
|
+
const eventOutput = eventOutputs.get(index);
|
|
207
|
+
const eventFailure = eventFailures.get(index);
|
|
208
|
+
const statusOutput = statusOutputs.get(index);
|
|
209
|
+
const statusFailure = statusFailures.get(index);
|
|
210
|
+
|
|
211
|
+
// A completed status slot is authoritative even when a stale compact
|
|
212
|
+
// event reports a failure. Event data can replace only a status slot that
|
|
213
|
+
// deadline handling normalized from a nonterminal state, and only when
|
|
214
|
+
// both records identify the same public panel-N slot.
|
|
215
|
+
if (!replaceableSlots.has(index)) {
|
|
216
|
+
if (statusOutput) outputs.push(statusOutput);
|
|
217
|
+
else if (statusFailure) failures.push(statusFailure);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (eventOutput) {
|
|
222
|
+
outputs.push(withObservation(eventOutput, statusFailure?.observation));
|
|
223
|
+
} else if (eventFailure) {
|
|
224
|
+
failures.push(withObservation(eventFailure, statusFailure?.observation));
|
|
225
|
+
} else if (statusFailure) {
|
|
226
|
+
failures.push(statusFailure);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, outputs, failures, ...(event.runId ? { runId: event.runId } : {}) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Deadline reconciliation never trusts compact-event array order: failed
|
|
234
|
+
* children can be omitted or arrive late. A compact event may replace only a
|
|
235
|
+
* status record terminalized from a nonterminal state when both explicitly
|
|
236
|
+
* name the same public workflow slot.
|
|
237
|
+
*/
|
|
238
|
+
function deadlineEventReplacementSlots(
|
|
239
|
+
statusPayload: unknown,
|
|
240
|
+
eventPayload: unknown,
|
|
241
|
+
): ReadonlySet<number> {
|
|
242
|
+
const statusResults =
|
|
243
|
+
findLifecycleArray(statusPayload, "steps") ??
|
|
244
|
+
findLifecycleArray(statusPayload, "results");
|
|
245
|
+
const eventResults = findLifecycleArray(eventPayload, "results");
|
|
246
|
+
if (!statusResults || !eventResults) return new Set<number>();
|
|
247
|
+
|
|
248
|
+
const nonterminalStatusSlots = new Set<number>();
|
|
249
|
+
for (const result of statusResults) {
|
|
250
|
+
const slot = stablePanelSlot(result);
|
|
251
|
+
if (slot !== undefined && isNonterminalLifecycleResult(result)) {
|
|
252
|
+
nonterminalStatusSlots.add(slot);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const matchingEventSlots = new Set<number>();
|
|
257
|
+
for (const result of eventResults) {
|
|
258
|
+
const slot = stablePanelSlot(result);
|
|
259
|
+
if (slot !== undefined && nonterminalStatusSlots.has(slot)) {
|
|
260
|
+
matchingEventSlots.add(slot);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return matchingEventSlots;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stablePanelSlot(result: unknown): number | undefined {
|
|
267
|
+
if (!isRecord(result)) return undefined;
|
|
268
|
+
// Result extraction already accepts these lifecycle fields as zero-based
|
|
269
|
+
// public slots. Keep deadline matching exactly aligned; a numeric 1 must
|
|
270
|
+
// mean panel slot 1, never a guessed one-based panel-1.
|
|
271
|
+
for (const candidate of [result.index, result.taskIndex, result.stepIndex]) {
|
|
272
|
+
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
|
|
273
|
+
return candidate;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const key = firstString(
|
|
277
|
+
result.key,
|
|
278
|
+
result.taskKey,
|
|
279
|
+
result.stepKey,
|
|
280
|
+
result.agent,
|
|
281
|
+
);
|
|
282
|
+
const match = key?.match(/^panel-([1-9]\d*)$/);
|
|
283
|
+
return match ? Number(match[1]) - 1 : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNonterminalLifecycleResult(result: unknown): boolean {
|
|
287
|
+
if (!isRecord(result)) return false;
|
|
288
|
+
const status = firstString(result.status, result.state);
|
|
289
|
+
return (
|
|
290
|
+
status === "running" ||
|
|
291
|
+
status === "active" ||
|
|
292
|
+
status === "pending" ||
|
|
293
|
+
status === "queued"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
140
297
|
function preserveAgreementReasons(
|
|
141
298
|
status: ExtractPanelResultsSuccess,
|
|
142
299
|
event: ExtractPanelResultsSuccess,
|
|
@@ -214,6 +371,46 @@ function findLifecycleArray(
|
|
|
214
371
|
return undefined;
|
|
215
372
|
}
|
|
216
373
|
|
|
374
|
+
function authoritativeStatusLifecycleArray(
|
|
375
|
+
payload: unknown,
|
|
376
|
+
steps: readonly unknown[] | undefined,
|
|
377
|
+
results: readonly unknown[] | undefined,
|
|
378
|
+
): readonly unknown[] | undefined {
|
|
379
|
+
const lifecycle = steps ?? results;
|
|
380
|
+
if (!lifecycle || lifecycle.length > 0 || isTerminalLifecyclePayload(payload)) {
|
|
381
|
+
return lifecycle;
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isTerminalLifecyclePayload(payload: unknown): boolean {
|
|
387
|
+
if (!isRecord(payload)) return false;
|
|
388
|
+
const state = firstString(payload.state, payload.status);
|
|
389
|
+
const textState = firstString(payload.text)?.match(
|
|
390
|
+
/(?:^|\n)(?:State|Status):\s*([^\n\r]+)/i,
|
|
391
|
+
)?.[1]?.trim();
|
|
392
|
+
if (
|
|
393
|
+
state === "complete" ||
|
|
394
|
+
textState === "complete" ||
|
|
395
|
+
textState === "completed" ||
|
|
396
|
+
textState === "done" ||
|
|
397
|
+
textState === "failed" ||
|
|
398
|
+
textState === "paused" ||
|
|
399
|
+
textState === "detached" ||
|
|
400
|
+
state === "completed" ||
|
|
401
|
+
state === "done" ||
|
|
402
|
+
state === "failed" ||
|
|
403
|
+
state === "paused" ||
|
|
404
|
+
state === "detached"
|
|
405
|
+
) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (isRecord(payload.details) && isTerminalLifecyclePayload(payload.details)) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
return isRecord(payload.data) && isTerminalLifecyclePayload(payload.data);
|
|
412
|
+
}
|
|
413
|
+
|
|
217
414
|
function isFailedLifecycleResult(result: Record<string, unknown>): boolean {
|
|
218
415
|
if (result.success === false) return true;
|
|
219
416
|
if (result.timedOut === true || result.interrupted === true) return true;
|