@alexeiled/pi-fusion 0.6.2 → 0.7.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 +7 -5
- package/agents/fusion-composer.md +3 -0
- package/agents/fusion-judge.md +3 -0
- package/agents/fusion-panelist.md +3 -0
- package/docs/user-guide.md +22 -4
- package/package.json +1 -1
- package/src/caller-contract.ts +80 -0
- package/src/config.ts +32 -2
- package/src/fusion-rpc.ts +59 -13
- package/src/lifecycle-reconcile.ts +248 -0
- package/src/orchestrator.ts +78 -61
- package/src/panel-completion.ts +57 -7
- package/src/report.ts +20 -0
- package/src/result-extract.ts +18 -3
- package/src/run-builder.ts +63 -13
- package/src/run-store.ts +17 -0
- package/src/types.ts +14 -0
package/README.md
CHANGED
|
@@ -156,16 +156,18 @@ 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
|
|
159
|
+
- `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile` and versioned `outputContract` (`plan-review-v1`). 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
|
|
163
163
|
- `adopt` — verify and return a run from restored session history by `runId`
|
|
164
164
|
|
|
165
|
-
`start` returns `{ operationId, replayed, run }`. `status`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
`
|
|
165
|
+
`start` returns `{ operationId, replayed, run }`. `status` returns `{ run }`.
|
|
166
|
+
`result` returns `{ run, callerOutput? }`; `callerOutput` is present only when
|
|
167
|
+
Fusion validated a strict caller contract, and contains `{ contract, output }`.
|
|
168
|
+
`cancel` returns `{ cancelled, run? }`. `adopt` returns `{ adopted: true, run }`.
|
|
169
|
+
Run state contains `runId`, optional `operationId`, `phase`, `terminal`, and
|
|
170
|
+
optional `report` or `error`.
|
|
169
171
|
|
|
170
172
|
Failure codes are `invalid_request`, `unsupported_method`, `busy`, `not_found`,
|
|
171
173
|
`not_ready`, `unavailable`, `start_failed`, `cancel_failed`, and `internal`.
|
|
@@ -28,6 +28,9 @@ a winner.
|
|
|
28
28
|
Read-only synthesis. Do not edit files. Do not ask other agents. Do not run
|
|
29
29
|
subagents.
|
|
30
30
|
|
|
31
|
+
If the task defines an exact caller output contract, follow it instead of the
|
|
32
|
+
sections below. Return only the caller's required syntax.
|
|
33
|
+
|
|
31
34
|
Return final Markdown with these sections:
|
|
32
35
|
|
|
33
36
|
# Fusion Report
|
package/agents/fusion-judge.md
CHANGED
|
@@ -20,6 +20,9 @@ yourself rather than choosing the more confident wording. You have read tools;
|
|
|
20
20
|
a factual conflict is settled by looking, not by weighing prose. Cite `file:line`
|
|
21
21
|
for what you find, and say plainly when a claim could not be verified.
|
|
22
22
|
|
|
23
|
+
If the task defines an exact caller output contract, follow it instead of the
|
|
24
|
+
sections below. Return only the caller's required syntax.
|
|
25
|
+
|
|
23
26
|
Return final Markdown with these sections:
|
|
24
27
|
|
|
25
28
|
# Fusion Report
|
|
@@ -17,6 +17,9 @@ You have no web access: answer from the repository and your own knowledge, and s
|
|
|
17
17
|
so plainly when a question turns on external facts you cannot retrieve.
|
|
18
18
|
Do not edit files. Do not ask other agents. Do not run subagents.
|
|
19
19
|
|
|
20
|
+
If the task defines an exact caller output contract, follow it instead of the
|
|
21
|
+
sections and decision record below. Return only the caller's required syntax.
|
|
22
|
+
|
|
20
23
|
Return concise Markdown with these sections.
|
|
21
24
|
|
|
22
25
|
When the task includes a decision-record contract:
|
package/docs/user-guide.md
CHANGED
|
@@ -130,7 +130,10 @@ Run this inside a trusted project:
|
|
|
130
130
|
"agent": "pi-fusion.fusion-judge"
|
|
131
131
|
},
|
|
132
132
|
"concurrency": 3,
|
|
133
|
-
"
|
|
133
|
+
"panelTimeoutMs": 900000,
|
|
134
|
+
"judgeTimeoutMs": 900000,
|
|
135
|
+
"panelToolBudget": { "soft": 8, "hard": 12, "block": "*" },
|
|
136
|
+
"judgeToolBudget": { "soft": 8, "hard": 12, "block": "*" },
|
|
134
137
|
"context": "fresh",
|
|
135
138
|
"stopWhenPanelAgrees": false
|
|
136
139
|
}
|
|
@@ -150,12 +153,17 @@ Profile:
|
|
|
150
153
|
- `panel`: one or more panel members
|
|
151
154
|
- `judge`: judge agent config
|
|
152
155
|
- `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion evaluates the first two panelists before launching another batch so it can avoid work after strong agreement.
|
|
153
|
-
- `timeoutMs`:
|
|
156
|
+
- `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It remains supported as the fallback for both stages. New profiles default to 15 minutes through the stage-specific fields; existing explicit `timeoutMs` values are preserved. Use a stage-specific field when panel and synthesis need different deadlines.
|
|
157
|
+
- `panelTimeoutMs`: panel workflow wall-clock timeout. It overrides `timeoutMs` for the panel. The default profile uses 15 minutes.
|
|
158
|
+
- `judgeTimeoutMs`: synthesis workflow wall-clock timeout. It overrides `timeoutMs` for the judge or composer. The default profile uses 15 minutes.
|
|
154
159
|
- `context`: `fresh` or `fork`
|
|
155
160
|
- `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet. All four conditions must hold: two or more finished panelists give the same normalized recommendation, every one of them reports `high` confidence, none of them asks for more evidence, and work remains. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
|
|
156
161
|
- `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).
|
|
157
162
|
- `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.
|
|
158
|
-
- `
|
|
163
|
+
- `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
|
+
- `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
|
+
|
|
166
|
+
Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion keeps completed panel slots and failed panel slots separate, never relabels a compact completion payload, and fails closed when lifecycle sources disagree. A timed-out judge never becomes a panel-only success. Synthesis also fails closed when any configured panelist is missing or failed, unless the missing slots were explicitly stopped after strong agreement.
|
|
159
167
|
|
|
160
168
|
Panel member:
|
|
161
169
|
|
|
@@ -357,7 +365,8 @@ Deliberate review:
|
|
|
357
365
|
"thinking": "high"
|
|
358
366
|
},
|
|
359
367
|
"concurrency": 2,
|
|
360
|
-
"
|
|
368
|
+
"panelTimeoutMs": 900000,
|
|
369
|
+
"judgeTimeoutMs": 900000,
|
|
361
370
|
"context": "fresh"
|
|
362
371
|
}
|
|
363
372
|
}
|
|
@@ -368,6 +377,8 @@ Deliberate review:
|
|
|
368
377
|
|
|
369
378
|
When agreement stopping is on, each panelist appends one tagged JSON decision record. The record holds a short recommendation, a confidence level, and whether the panelist needs more evidence. Fusion uses it only to decide whether an unfinished panel can stop early. A record that is malformed, missing, or not final turns early stopping off. You see the Markdown answer above the record, not the record itself.
|
|
370
379
|
|
|
380
|
+
An original task can define the strict plan-review contract: exactly `NO_FINDINGS`, or complete `FINDING`/`Evidence`/`Fix` blocks with no other prose. That caller contract overrides Fusion's normal report headings and agreement record. Fusion validates the synthesis, returns it unchanged, and fails rather than fabricating a clean result when the judge violates the contract. RPC `result` also exposes a validated value as `callerOutput`.
|
|
381
|
+
|
|
371
382
|
The judge returns:
|
|
372
383
|
|
|
373
384
|
- summary
|
|
@@ -467,6 +478,13 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
|
|
|
467
478
|
- verify the requested `--profile` name
|
|
468
479
|
- run `/fusion init` to regenerate a known-good template
|
|
469
480
|
|
|
481
|
+
`Workflow script timed out` or judge exit 143
|
|
482
|
+
|
|
483
|
+
- raise `panelTimeoutMs` or `judgeTimeoutMs` for slower models
|
|
484
|
+
- keep `panelToolBudget` and `judgeToolBudget` bounded so agents finalise before the deadline
|
|
485
|
+
- inspect `/fusion status`; panel failures and the workflow timeout must both be present
|
|
486
|
+
- retry only after the run is terminal
|
|
487
|
+
|
|
470
488
|
Run is stuck or no longer useful:
|
|
471
489
|
|
|
472
490
|
```text
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CallerOutputContract } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type CallerOutputValidation =
|
|
4
|
+
| { ok: true }
|
|
5
|
+
| { ok: false; error: string };
|
|
6
|
+
|
|
7
|
+
const PLAN_REVIEW_HEADER = /^FINDING:\s*(CRITICAL|MAJOR|MINOR)\s*\|\s*\S.*$/;
|
|
8
|
+
|
|
9
|
+
export function isCallerOutputContract(
|
|
10
|
+
value: unknown,
|
|
11
|
+
): value is CallerOutputContract {
|
|
12
|
+
return value === "plan-review-v1";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function detectCallerOutputContract(
|
|
16
|
+
prompt: string,
|
|
17
|
+
): CallerOutputContract | undefined {
|
|
18
|
+
const hasExactCleanToken = /exact line\s+`?NO_FINDINGS`?/i.test(prompt);
|
|
19
|
+
const hasFindingContract =
|
|
20
|
+
/exact format[\s\S]*FINDING:\s*(?:CRITICAL\|MAJOR\|MINOR|CRITICAL|MAJOR|MINOR)/i.test(
|
|
21
|
+
prompt,
|
|
22
|
+
);
|
|
23
|
+
const forbidsProse = /do not write any other prose/i.test(prompt);
|
|
24
|
+
return hasExactCleanToken && hasFindingContract && forbidsProse
|
|
25
|
+
? "plan-review-v1"
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateCallerOutput(
|
|
30
|
+
contract: CallerOutputContract,
|
|
31
|
+
output: string,
|
|
32
|
+
): CallerOutputValidation {
|
|
33
|
+
switch (contract) {
|
|
34
|
+
case "plan-review-v1":
|
|
35
|
+
return validatePlanReviewOutput(output);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function callerOutputContractInstructions(
|
|
40
|
+
contract: CallerOutputContract,
|
|
41
|
+
): readonly string[] {
|
|
42
|
+
switch (contract) {
|
|
43
|
+
case "plan-review-v1":
|
|
44
|
+
return [
|
|
45
|
+
"The exact output contract in the original task takes priority over Fusion's normal Markdown sections.",
|
|
46
|
+
"Return only NO_FINDINGS or complete FINDING/Evidence/Fix blocks as requested by the original task.",
|
|
47
|
+
"Do not add Fusion headings, commentary, or a panel decision record.",
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validatePlanReviewOutput(output: string): CallerOutputValidation {
|
|
53
|
+
const trimmed = output.trim();
|
|
54
|
+
if (trimmed === "NO_FINDINGS") return { ok: true };
|
|
55
|
+
|
|
56
|
+
const blocks = trimmed.split(/(?=^[ \t]*FINDING:\s)/m);
|
|
57
|
+
if (
|
|
58
|
+
blocks.length > 0 &&
|
|
59
|
+
blocks.every((block) => {
|
|
60
|
+
const lines = block
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
return (
|
|
65
|
+
lines.length === 3 &&
|
|
66
|
+
PLAN_REVIEW_HEADER.test(lines[0] ?? "") &&
|
|
67
|
+
/^Evidence:\s*\S.*$/.test(lines[1] ?? "") &&
|
|
68
|
+
/^Fix:\s*\S.*$/.test(lines[2] ?? "")
|
|
69
|
+
);
|
|
70
|
+
})
|
|
71
|
+
) {
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error:
|
|
78
|
+
"Fusion synthesis violated the exact caller output contract. Expected NO_FINDINGS or complete FINDING/Evidence/Fix blocks with no other prose.",
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -115,7 +115,10 @@ export function createDefaultFusionConfig(): FusionConfig {
|
|
|
115
115
|
thinking: "high",
|
|
116
116
|
},
|
|
117
117
|
concurrency: 3,
|
|
118
|
-
|
|
118
|
+
panelTimeoutMs: 900_000,
|
|
119
|
+
judgeTimeoutMs: 900_000,
|
|
120
|
+
panelToolBudget: { soft: 8, hard: 12, block: "*" },
|
|
121
|
+
judgeToolBudget: { soft: 8, hard: 12, block: "*" },
|
|
119
122
|
context: "fresh",
|
|
120
123
|
stopWhenPanelAgrees: false,
|
|
121
124
|
},
|
|
@@ -322,6 +325,18 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
322
325
|
return false;
|
|
323
326
|
if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
|
|
324
327
|
return false;
|
|
328
|
+
if (
|
|
329
|
+
value.panelTimeoutMs !== undefined &&
|
|
330
|
+
!isPositiveInteger(value.panelTimeoutMs)
|
|
331
|
+
) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
value.judgeTimeoutMs !== undefined &&
|
|
336
|
+
!isPositiveInteger(value.judgeTimeoutMs)
|
|
337
|
+
) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
325
340
|
if (value.context !== undefined && !isFusionContextMode(value.context))
|
|
326
341
|
return false;
|
|
327
342
|
if (
|
|
@@ -336,6 +351,12 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
336
351
|
) {
|
|
337
352
|
return false;
|
|
338
353
|
}
|
|
354
|
+
if (
|
|
355
|
+
value.panelToolBudget !== undefined &&
|
|
356
|
+
!isToolBudget(value.panelToolBudget)
|
|
357
|
+
) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
339
360
|
if (
|
|
340
361
|
value.judgeToolBudget !== undefined &&
|
|
341
362
|
!isToolBudget(value.judgeToolBudget)
|
|
@@ -354,9 +375,9 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
354
375
|
|
|
355
376
|
function isToolBudget(value: unknown): value is ToolBudget {
|
|
356
377
|
if (!isRecord(value)) return false;
|
|
378
|
+
if (value.soft === undefined && value.hard === undefined) return false;
|
|
357
379
|
if (value.soft !== undefined && !isPositiveInteger(value.soft)) return false;
|
|
358
380
|
if (value.hard !== undefined && !isPositiveInteger(value.hard)) return false;
|
|
359
|
-
if (value.soft === undefined && value.hard === undefined) return false;
|
|
360
381
|
if (
|
|
361
382
|
isPositiveInteger(value.soft) &&
|
|
362
383
|
isPositiveInteger(value.hard) &&
|
|
@@ -364,6 +385,15 @@ function isToolBudget(value: unknown): value is ToolBudget {
|
|
|
364
385
|
) {
|
|
365
386
|
return false;
|
|
366
387
|
}
|
|
388
|
+
if (
|
|
389
|
+
value.block !== undefined &&
|
|
390
|
+
value.block !== "*" &&
|
|
391
|
+
(!Array.isArray(value.block) ||
|
|
392
|
+
value.block.length === 0 ||
|
|
393
|
+
!value.block.every(isNonEmptyString))
|
|
394
|
+
) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
367
397
|
return true;
|
|
368
398
|
}
|
|
369
399
|
|
package/src/fusion-rpc.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectCallerOutputContract,
|
|
3
|
+
isCallerOutputContract,
|
|
4
|
+
validateCallerOutput,
|
|
5
|
+
} from "./caller-contract.js";
|
|
1
6
|
import type {
|
|
2
7
|
FusionCommandContext,
|
|
3
8
|
FusionCommandResult,
|
|
4
9
|
} from "./orchestrator.js";
|
|
5
10
|
import type { FusionRunStore } from "./run-store.js";
|
|
6
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CallerOutputContract,
|
|
13
|
+
FusionPhase,
|
|
14
|
+
FusionRun,
|
|
15
|
+
ParsedFusionArgs,
|
|
16
|
+
} from "./types.js";
|
|
7
17
|
import { isNonEmptyString, isRecord } from "./utils.js";
|
|
8
18
|
|
|
9
19
|
export const FUSION_RPC_VERSION = 1;
|
|
@@ -70,8 +80,14 @@ export interface FusionRpcStatusData {
|
|
|
70
80
|
run: FusionRunState;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
export interface FusionRpcCallerOutput {
|
|
84
|
+
contract: CallerOutputContract;
|
|
85
|
+
output: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
export interface FusionRpcResultData {
|
|
74
89
|
run: FusionRunState;
|
|
90
|
+
callerOutput?: FusionRpcCallerOutput;
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
export interface FusionRpcCancelData {
|
|
@@ -134,6 +150,7 @@ interface StartParams {
|
|
|
134
150
|
prompt: string;
|
|
135
151
|
profile?: string;
|
|
136
152
|
operationId: string;
|
|
153
|
+
outputContract?: CallerOutputContract;
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
interface RunParams {
|
|
@@ -143,7 +160,13 @@ interface RunParams {
|
|
|
143
160
|
|
|
144
161
|
type ObservableRun = Pick<
|
|
145
162
|
FusionRun,
|
|
146
|
-
|
|
163
|
+
| "id"
|
|
164
|
+
| "operationId"
|
|
165
|
+
| "phase"
|
|
166
|
+
| "prompt"
|
|
167
|
+
| "outputContract"
|
|
168
|
+
| "report"
|
|
169
|
+
| "error"
|
|
147
170
|
>;
|
|
148
171
|
|
|
149
172
|
const TERMINAL_PHASES = new Set<FusionPhase>(["done", "failed", "cancelled"]);
|
|
@@ -252,7 +275,8 @@ export function registerFusionRpc({
|
|
|
252
275
|
details: { run: state },
|
|
253
276
|
});
|
|
254
277
|
}
|
|
255
|
-
|
|
278
|
+
const callerOutput = validatedCallerOutput(run);
|
|
279
|
+
return { run: state, ...(callerOutput ? { callerOutput } : {}) };
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
async function cancel(params: unknown): Promise<FusionRpcCancelData> {
|
|
@@ -401,19 +425,30 @@ function parseStartParams(input: unknown): StartParams {
|
|
|
401
425
|
);
|
|
402
426
|
}
|
|
403
427
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
428
|
+
const outputContract = input.outputContract;
|
|
429
|
+
if (outputContract !== undefined && !isCallerOutputContract(outputContract)) {
|
|
430
|
+
throw invalidParams(
|
|
431
|
+
"start outputContract must be a supported caller output contract.",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
prompt,
|
|
437
|
+
operationId,
|
|
438
|
+
...(profile === undefined ? {} : { profile }),
|
|
439
|
+
...(outputContract === undefined ? {} : { outputContract }),
|
|
440
|
+
};
|
|
407
441
|
}
|
|
408
442
|
|
|
409
443
|
function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
|
|
410
|
-
return
|
|
411
|
-
|
|
412
|
-
:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
444
|
+
return {
|
|
445
|
+
prompt: input.prompt,
|
|
446
|
+
operationId: input.operationId,
|
|
447
|
+
...(input.profile === undefined ? {} : { profile: input.profile }),
|
|
448
|
+
...(input.outputContract === undefined
|
|
449
|
+
? {}
|
|
450
|
+
: { outputContract: input.outputContract }),
|
|
451
|
+
};
|
|
417
452
|
}
|
|
418
453
|
|
|
419
454
|
function findRun(
|
|
@@ -548,6 +583,17 @@ function startFailure(message: string, run?: ObservableRun): RpcFailure {
|
|
|
548
583
|
});
|
|
549
584
|
}
|
|
550
585
|
|
|
586
|
+
function validatedCallerOutput(
|
|
587
|
+
run: ObservableRun,
|
|
588
|
+
): FusionRpcCallerOutput | undefined {
|
|
589
|
+
if (!run.report) return undefined;
|
|
590
|
+
const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
|
|
591
|
+
if (!contract || !validateCallerOutput(contract, run.report).ok) {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
return { contract, output: run.report.trim() };
|
|
595
|
+
}
|
|
596
|
+
|
|
551
597
|
function stateFor(run: ObservableRun): FusionRunState {
|
|
552
598
|
const state: FusionRunState = {
|
|
553
599
|
runId: run.id,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { FailedPanelSummary, PanelOutput } from "./types.js";
|
|
2
|
+
import { mergeRunObservations } from "./run-observations.js";
|
|
3
|
+
import {
|
|
4
|
+
extractPanelResults,
|
|
5
|
+
type ExtractPanelResultsResult,
|
|
6
|
+
type ExtractPanelResultsSuccess,
|
|
7
|
+
} from "./result-extract.js";
|
|
8
|
+
import type { FusionProfile } from "./types.js";
|
|
9
|
+
import { isRecord } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export function reconcileIndexedLifecycleResult(
|
|
12
|
+
eventPayload: unknown,
|
|
13
|
+
statusPayload: unknown,
|
|
14
|
+
index: number,
|
|
15
|
+
label: "judge" | "panel",
|
|
16
|
+
): string | undefined {
|
|
17
|
+
const eventResults = findLifecycleArray(eventPayload, "results");
|
|
18
|
+
const eventResult = eventResults?.[index];
|
|
19
|
+
if (!isRecord(eventResult)) return undefined;
|
|
20
|
+
|
|
21
|
+
const statusSteps = findLifecycleArray(statusPayload, "steps");
|
|
22
|
+
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
23
|
+
const statusResults =
|
|
24
|
+
statusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
|
|
25
|
+
const statusResult = statusResults?.[index];
|
|
26
|
+
if (statusResults && !isRecord(statusResult)) {
|
|
27
|
+
return `Subagents event includes ${label} result ${index + 1}, but status does not.`;
|
|
28
|
+
}
|
|
29
|
+
if (!isRecord(statusResult)) return undefined;
|
|
30
|
+
if (isFailedLifecycleResult(eventResult) === isFailedLifecycleResult(statusResult)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return `Subagents event and status disagree about ${label} result ${index + 1}.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReconcilePanelResultsOptions {
|
|
37
|
+
/** Extra terminal result allowed after the configured panel (the judge). */
|
|
38
|
+
allowedTrailingResults?: number;
|
|
39
|
+
/** Indices intentionally absent from status after early agreement. */
|
|
40
|
+
stoppedPanelIndices?: readonly number[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reconciles compact completion events with the richer lifecycle snapshot.
|
|
45
|
+
* Status is authoritative when it describes every configured panel member.
|
|
46
|
+
* Incomplete or contradictory lifecycle data fails closed.
|
|
47
|
+
*/
|
|
48
|
+
export function reconcilePanelResults(
|
|
49
|
+
eventResults: ExtractPanelResultsSuccess,
|
|
50
|
+
statusPayload: unknown,
|
|
51
|
+
profile: FusionProfile,
|
|
52
|
+
resultPayload: unknown,
|
|
53
|
+
options: ReconcilePanelResultsOptions = {},
|
|
54
|
+
): ExtractPanelResultsResult {
|
|
55
|
+
const expectedCount = profile.panel.length;
|
|
56
|
+
const allowedTrailing = options.allowedTrailingResults ?? 0;
|
|
57
|
+
const rawEvent = findLifecycleArray(resultPayload, "results");
|
|
58
|
+
if (rawEvent && rawEvent.length > expectedCount + allowedTrailing) {
|
|
59
|
+
return error(
|
|
60
|
+
`Terminal subagents data contained ${rawEvent.length} results for ${expectedCount + allowedTrailing} expected workflow steps.`,
|
|
61
|
+
"$.results",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rawStatusSteps = findLifecycleArray(statusPayload, "steps");
|
|
66
|
+
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
67
|
+
const rawStatus =
|
|
68
|
+
rawStatusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
|
|
69
|
+
if (!rawStatus) {
|
|
70
|
+
if (eventResults.outputs.length + eventResults.failures.length !== expectedCount) {
|
|
71
|
+
return error(
|
|
72
|
+
`Terminal subagents data described ${eventResults.outputs.length + eventResults.failures.length} of ${expectedCount} configured panel members.`,
|
|
73
|
+
"$.results",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return eventResults;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (rawStatus.length > expectedCount + allowedTrailing) {
|
|
80
|
+
return error(
|
|
81
|
+
`Terminal subagents status contained ${rawStatus.length} steps for ${expectedCount + allowedTrailing} expected workflow steps.`,
|
|
82
|
+
"$.steps",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const statusResults = extractPanelResults(statusPayload, {
|
|
87
|
+
panel: profile.panel,
|
|
88
|
+
completedOnly: true,
|
|
89
|
+
limit: expectedCount,
|
|
90
|
+
...(options.stoppedPanelIndices
|
|
91
|
+
? { stoppedPanelIndices: options.stoppedPanelIndices }
|
|
92
|
+
: {}),
|
|
93
|
+
});
|
|
94
|
+
if (!statusResults.ok) {
|
|
95
|
+
return error(
|
|
96
|
+
`${statusResults.error.message} (${statusResults.error.path})`,
|
|
97
|
+
statusResults.error.path,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const statusCount =
|
|
102
|
+
statusResults.outputs.length + statusResults.failures.length;
|
|
103
|
+
const eventCount =
|
|
104
|
+
eventResults.outputs.length + eventResults.failures.length;
|
|
105
|
+
const statusIndices = new Set([
|
|
106
|
+
...statusResults.outputs.map(({ index }) => index),
|
|
107
|
+
...statusResults.failures.map(({ index }) => index),
|
|
108
|
+
]);
|
|
109
|
+
const stopped = new Set(options.stoppedPanelIndices ?? []);
|
|
110
|
+
const missingStatusIndices = Array.from(
|
|
111
|
+
{ length: expectedCount },
|
|
112
|
+
(_, index) => index,
|
|
113
|
+
).filter((index) => !statusIndices.has(index));
|
|
114
|
+
|
|
115
|
+
if (statusCount === expectedCount) {
|
|
116
|
+
return preserveAgreementReasons(statusResults, eventResults);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const missingWereStopped =
|
|
120
|
+
eventCount === expectedCount &&
|
|
121
|
+
missingStatusIndices.length > 0 &&
|
|
122
|
+
missingStatusIndices.every((index) => stopped.has(index));
|
|
123
|
+
if (!missingWereStopped) {
|
|
124
|
+
return error(
|
|
125
|
+
`Terminal subagents status described ${statusCount} of ${expectedCount} configured panel members.`,
|
|
126
|
+
"$.steps",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (eventCount !== expectedCount) {
|
|
131
|
+
return error(
|
|
132
|
+
`Terminal subagents data described ${eventCount} of ${expectedCount} configured panel members.`,
|
|
133
|
+
"$.results",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return mergeObservations(eventResults, statusResults);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function preserveAgreementReasons(
|
|
141
|
+
status: ExtractPanelResultsSuccess,
|
|
142
|
+
event: ExtractPanelResultsSuccess,
|
|
143
|
+
): ExtractPanelResultsSuccess {
|
|
144
|
+
const eventFailures = new Map(
|
|
145
|
+
event.failures.map((failure) => [failure.index, failure]),
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
...status,
|
|
149
|
+
failures: status.failures.map((failure) => {
|
|
150
|
+
const eventFailure = eventFailures.get(failure.index);
|
|
151
|
+
return eventFailure?.reason === "stopped-after-agreement"
|
|
152
|
+
? { ...failure, reason: eventFailure.reason }
|
|
153
|
+
: failure;
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeObservations(
|
|
159
|
+
event: ExtractPanelResultsSuccess,
|
|
160
|
+
status: ExtractPanelResultsSuccess,
|
|
161
|
+
): ExtractPanelResultsSuccess {
|
|
162
|
+
const observations = new Map<number, PanelOutput["observation"]>();
|
|
163
|
+
for (const output of status.outputs) {
|
|
164
|
+
observations.set(output.index, output.observation);
|
|
165
|
+
}
|
|
166
|
+
for (const failure of status.failures) {
|
|
167
|
+
observations.set(failure.index, failure.observation);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...event,
|
|
172
|
+
outputs: event.outputs.map((output) =>
|
|
173
|
+
withObservation(output, observations.get(output.index)),
|
|
174
|
+
),
|
|
175
|
+
failures: event.failures.map((failure) =>
|
|
176
|
+
withObservation(failure, observations.get(failure.index)),
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function withObservation<T extends PanelOutput | FailedPanelSummary>(
|
|
182
|
+
item: T,
|
|
183
|
+
statusObservation: PanelOutput["observation"] | undefined,
|
|
184
|
+
): T {
|
|
185
|
+
const observation = mergeRunObservations(statusObservation, item.observation);
|
|
186
|
+
return hasObservationData(observation) ? { ...item, observation } : item;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasObservationData(
|
|
190
|
+
observation: PanelOutput["observation"] | undefined,
|
|
191
|
+
): boolean {
|
|
192
|
+
return Boolean(
|
|
193
|
+
observation &&
|
|
194
|
+
(observation.model ||
|
|
195
|
+
observation.durationMs !== undefined ||
|
|
196
|
+
observation.usage ||
|
|
197
|
+
observation.attempts ||
|
|
198
|
+
observation.providerFailures),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findLifecycleArray(
|
|
203
|
+
payload: unknown,
|
|
204
|
+
key: "results" | "steps",
|
|
205
|
+
): readonly unknown[] | undefined {
|
|
206
|
+
if (!isRecord(payload)) return undefined;
|
|
207
|
+
const direct = unknownArray(payload[key]);
|
|
208
|
+
if (direct) return direct;
|
|
209
|
+
if (isRecord(payload.details)) {
|
|
210
|
+
const nested = unknownArray(payload.details[key]);
|
|
211
|
+
if (nested) return nested;
|
|
212
|
+
}
|
|
213
|
+
if (isRecord(payload.data)) return findLifecycleArray(payload.data, key);
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isFailedLifecycleResult(result: Record<string, unknown>): boolean {
|
|
218
|
+
if (result.success === false) return true;
|
|
219
|
+
if (result.timedOut === true || result.interrupted === true) return true;
|
|
220
|
+
if (typeof result.error === "string" && result.error.trim()) return true;
|
|
221
|
+
const status = firstString(result.status, result.state);
|
|
222
|
+
return status === "failed" || status === "paused" || status === "detached";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function firstString(...values: readonly unknown[]): string | undefined {
|
|
226
|
+
for (const value of values) {
|
|
227
|
+
if (typeof value === "string") return value;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
233
|
+
return Array.isArray(value) ? value : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function error(
|
|
237
|
+
message: string,
|
|
238
|
+
path: string,
|
|
239
|
+
): ExtractPanelResultsResult {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: {
|
|
243
|
+
code: "unknown-result-shape",
|
|
244
|
+
message,
|
|
245
|
+
path,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|