@alexeiled/pi-fusion 0.6.2 → 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 +10 -7
- 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 +32 -11
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/caller-contract.ts +80 -0
- package/src/config.ts +58 -2
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +88 -13
- package/src/index.ts +33 -5
- package/src/lifecycle-reconcile.ts +445 -0
- package/src/orchestrator.ts +562 -129
- package/src/panel-completion.ts +105 -7
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +105 -3
- package/src/result-extract.ts +84 -7
- package/src/run-builder.ts +141 -15
- package/src/run-store.ts +377 -11
- package/src/status.ts +1 -1
- package/src/types.ts +89 -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`, 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
|
|
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`.
|
|
@@ -205,12 +207,13 @@ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./
|
|
|
205
207
|
- Config is optional. Defaults work. Use `/fusion init` when you want project config.
|
|
206
208
|
- Project config lives at `.pi/fusion.json`. Global config lives at `~/.pi/agent/fusion.json`.
|
|
207
209
|
- Output appears as a Pi custom message. Active progress also uses the `fusion` status key.
|
|
208
|
-
- 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.
|
|
209
212
|
- `pi-fusion` does not own the footer.
|
|
210
213
|
- Fusion sends your prompt and any inspected snippets to every panel model, and to the judge, through `pi-subagents`.
|
|
211
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.
|
|
212
215
|
- `Model` is lifecycle metadata. `Configured model` is the profile request. Both appear when the run differs from the request.
|
|
213
|
-
- `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.
|
|
214
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.
|
|
215
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.
|
|
216
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.
|
|
@@ -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
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -149,13 +152,21 @@ Profile:
|
|
|
149
152
|
|
|
150
153
|
- `panel`: one or more panel members
|
|
151
154
|
- `judge`: judge agent config
|
|
152
|
-
- `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion
|
|
153
|
-
- `timeoutMs`:
|
|
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.
|
|
154
162
|
- `context`: `fresh` or `fork`
|
|
155
|
-
- `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.
|
|
156
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).
|
|
157
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.
|
|
158
|
-
- `
|
|
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.
|
|
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.
|
|
168
|
+
|
|
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.
|
|
159
170
|
|
|
160
171
|
Panel member:
|
|
161
172
|
|
|
@@ -286,9 +297,7 @@ run does not fail, so a mismatch appears as an empty report, not as an error.
|
|
|
286
297
|
}
|
|
287
298
|
```
|
|
288
299
|
|
|
289
|
-
Under `merge`,
|
|
290
|
-
not return that answer directly. One facet is not the answer, and the report must
|
|
291
|
-
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.
|
|
292
301
|
|
|
293
302
|
Judge:
|
|
294
303
|
|
|
@@ -357,7 +366,8 @@ Deliberate review:
|
|
|
357
366
|
"thinking": "high"
|
|
358
367
|
},
|
|
359
368
|
"concurrency": 2,
|
|
360
|
-
"
|
|
369
|
+
"panelTimeoutMs": 900000,
|
|
370
|
+
"judgeTimeoutMs": 900000,
|
|
361
371
|
"context": "fresh"
|
|
362
372
|
}
|
|
363
373
|
}
|
|
@@ -368,6 +378,8 @@ Deliberate review:
|
|
|
368
378
|
|
|
369
379
|
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
380
|
|
|
381
|
+
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`.
|
|
382
|
+
|
|
371
383
|
The judge returns:
|
|
372
384
|
|
|
373
385
|
- summary
|
|
@@ -467,6 +479,15 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
|
|
|
467
479
|
- verify the requested `--profile` name
|
|
468
480
|
- run `/fusion init` to regenerate a known-good template
|
|
469
481
|
|
|
482
|
+
`Workflow script timed out` or judge exit 143
|
|
483
|
+
|
|
484
|
+
- raise `panelTimeoutMs` or `judgeTimeoutMs` for slower models
|
|
485
|
+
- keep `panelToolBudget` and `judgeToolBudget` bounded so agents finalise before the deadline
|
|
486
|
+
- inspect `/fusion status`; panel failures and the workflow timeout must both be present
|
|
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
|
|
490
|
+
|
|
470
491
|
Run is stuck or no longer useful:
|
|
471
492
|
|
|
472
493
|
```text
|
|
@@ -483,4 +504,4 @@ Notes:
|
|
|
483
504
|
|
|
484
505
|
- `Panel run` is the normal panel phase for new Fusion runs.
|
|
485
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.
|
|
486
|
-
- 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.
|
|
@@ -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,44 @@ 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.panelistTimeoutMs !== undefined &&
|
|
330
|
+
!isPositiveInteger(value.panelistTimeoutMs)
|
|
331
|
+
) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
value.panelTimeoutMs !== undefined &&
|
|
336
|
+
!isPositiveInteger(value.panelTimeoutMs)
|
|
337
|
+
) {
|
|
338
|
+
return false;
|
|
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;
|
|
352
|
+
if (
|
|
353
|
+
value.judgeTimeoutMs !== undefined &&
|
|
354
|
+
!isPositiveInteger(value.judgeTimeoutMs)
|
|
355
|
+
) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
if (
|
|
359
|
+
value.minimumSuccessfulPanelists !== undefined &&
|
|
360
|
+
value.minimumSuccessfulPanelists !== "majority" &&
|
|
361
|
+
value.minimumSuccessfulPanelists !== "all" &&
|
|
362
|
+
!isPositiveInteger(value.minimumSuccessfulPanelists)
|
|
363
|
+
) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
325
366
|
if (value.context !== undefined && !isFusionContextMode(value.context))
|
|
326
367
|
return false;
|
|
327
368
|
if (
|
|
@@ -336,6 +377,12 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
336
377
|
) {
|
|
337
378
|
return false;
|
|
338
379
|
}
|
|
380
|
+
if (
|
|
381
|
+
value.panelToolBudget !== undefined &&
|
|
382
|
+
!isToolBudget(value.panelToolBudget)
|
|
383
|
+
) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
339
386
|
if (
|
|
340
387
|
value.judgeToolBudget !== undefined &&
|
|
341
388
|
!isToolBudget(value.judgeToolBudget)
|
|
@@ -354,9 +401,9 @@ function isFusionProfile(value: unknown): value is FusionProfile {
|
|
|
354
401
|
|
|
355
402
|
function isToolBudget(value: unknown): value is ToolBudget {
|
|
356
403
|
if (!isRecord(value)) return false;
|
|
404
|
+
if (value.soft === undefined && value.hard === undefined) return false;
|
|
357
405
|
if (value.soft !== undefined && !isPositiveInteger(value.soft)) return false;
|
|
358
406
|
if (value.hard !== undefined && !isPositiveInteger(value.hard)) return false;
|
|
359
|
-
if (value.soft === undefined && value.hard === undefined) return false;
|
|
360
407
|
if (
|
|
361
408
|
isPositiveInteger(value.soft) &&
|
|
362
409
|
isPositiveInteger(value.hard) &&
|
|
@@ -364,6 +411,15 @@ function isToolBudget(value: unknown): value is ToolBudget {
|
|
|
364
411
|
) {
|
|
365
412
|
return false;
|
|
366
413
|
}
|
|
414
|
+
if (
|
|
415
|
+
value.block !== undefined &&
|
|
416
|
+
value.block !== "*" &&
|
|
417
|
+
(!Array.isArray(value.block) ||
|
|
418
|
+
value.block.length === 0 ||
|
|
419
|
+
!value.block.every(isNonEmptyString))
|
|
420
|
+
) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
367
423
|
return true;
|
|
368
424
|
}
|
|
369
425
|
|
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
|
@@ -1,9 +1,20 @@
|
|
|
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
|
+
FusionTimeoutOverrides,
|
|
16
|
+
ParsedFusionArgs,
|
|
17
|
+
} from "./types.js";
|
|
7
18
|
import { isNonEmptyString, isRecord } from "./utils.js";
|
|
8
19
|
|
|
9
20
|
export const FUSION_RPC_VERSION = 1;
|
|
@@ -70,8 +81,14 @@ export interface FusionRpcStatusData {
|
|
|
70
81
|
run: FusionRunState;
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
export interface FusionRpcCallerOutput {
|
|
85
|
+
contract: CallerOutputContract;
|
|
86
|
+
output: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
export interface FusionRpcResultData {
|
|
74
90
|
run: FusionRunState;
|
|
91
|
+
callerOutput?: FusionRpcCallerOutput;
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
export interface FusionRpcCancelData {
|
|
@@ -134,6 +151,8 @@ interface StartParams {
|
|
|
134
151
|
prompt: string;
|
|
135
152
|
profile?: string;
|
|
136
153
|
operationId: string;
|
|
154
|
+
outputContract?: CallerOutputContract;
|
|
155
|
+
timeoutOverrides?: FusionTimeoutOverrides;
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
interface RunParams {
|
|
@@ -143,7 +162,13 @@ interface RunParams {
|
|
|
143
162
|
|
|
144
163
|
type ObservableRun = Pick<
|
|
145
164
|
FusionRun,
|
|
146
|
-
|
|
165
|
+
| "id"
|
|
166
|
+
| "operationId"
|
|
167
|
+
| "phase"
|
|
168
|
+
| "prompt"
|
|
169
|
+
| "outputContract"
|
|
170
|
+
| "report"
|
|
171
|
+
| "error"
|
|
147
172
|
>;
|
|
148
173
|
|
|
149
174
|
const TERMINAL_PHASES = new Set<FusionPhase>(["done", "failed", "cancelled"]);
|
|
@@ -252,7 +277,8 @@ export function registerFusionRpc({
|
|
|
252
277
|
details: { run: state },
|
|
253
278
|
});
|
|
254
279
|
}
|
|
255
|
-
|
|
280
|
+
const callerOutput = validatedCallerOutput(run);
|
|
281
|
+
return { run: state, ...(callerOutput ? { callerOutput } : {}) };
|
|
256
282
|
}
|
|
257
283
|
|
|
258
284
|
async function cancel(params: unknown): Promise<FusionRpcCancelData> {
|
|
@@ -401,19 +427,57 @@ function parseStartParams(input: unknown): StartParams {
|
|
|
401
427
|
);
|
|
402
428
|
}
|
|
403
429
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
430
|
+
const timeoutOverrides = parseTimeoutOverrides(input);
|
|
431
|
+
|
|
432
|
+
const outputContract = input.outputContract;
|
|
433
|
+
if (outputContract !== undefined && !isCallerOutputContract(outputContract)) {
|
|
434
|
+
throw invalidParams(
|
|
435
|
+
"start outputContract must be a supported caller output contract.",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
prompt,
|
|
441
|
+
operationId,
|
|
442
|
+
...(profile === undefined ? {} : { profile }),
|
|
443
|
+
...(outputContract === undefined ? {} : { outputContract }),
|
|
444
|
+
...(timeoutOverrides ? { timeoutOverrides } : {}),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
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;
|
|
407
467
|
}
|
|
408
468
|
|
|
409
469
|
function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
|
|
410
|
-
return
|
|
411
|
-
|
|
412
|
-
:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
470
|
+
return {
|
|
471
|
+
prompt: input.prompt,
|
|
472
|
+
operationId: input.operationId,
|
|
473
|
+
...(input.profile === undefined ? {} : { profile: input.profile }),
|
|
474
|
+
...(input.outputContract === undefined
|
|
475
|
+
? {}
|
|
476
|
+
: { outputContract: input.outputContract }),
|
|
477
|
+
...(input.timeoutOverrides === undefined
|
|
478
|
+
? {}
|
|
479
|
+
: { timeoutOverrides: input.timeoutOverrides }),
|
|
480
|
+
};
|
|
417
481
|
}
|
|
418
482
|
|
|
419
483
|
function findRun(
|
|
@@ -548,6 +612,17 @@ function startFailure(message: string, run?: ObservableRun): RpcFailure {
|
|
|
548
612
|
});
|
|
549
613
|
}
|
|
550
614
|
|
|
615
|
+
function validatedCallerOutput(
|
|
616
|
+
run: ObservableRun,
|
|
617
|
+
): FusionRpcCallerOutput | undefined {
|
|
618
|
+
if (!run.report) return undefined;
|
|
619
|
+
const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
|
|
620
|
+
if (!contract || !validateCallerOutput(contract, run.report).ok) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
return { contract, output: run.report.trim() };
|
|
624
|
+
}
|
|
625
|
+
|
|
551
626
|
function stateFor(run: ObservableRun): FusionRunState {
|
|
552
627
|
const state: FusionRunState = {
|
|
553
628
|
runId: run.id,
|
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
|
};
|