@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 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 an optional `profile`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
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` and `result`
166
- return `{ run }`. `cancel` returns `{ cancelled, run? }`. `adopt` returns
167
- `{ adopted: true, run }`. Run state contains `runId`, optional `operationId`,
168
- `phase`, `terminal`, and optional `report` or `error`.
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, evaluates panelists in pairs so it can avoid starting later work, records skipped panelists in the report, and still runs the judge.
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
@@ -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:
@@ -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 as a single `pi-subagents` chain remain supported when restored.
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
- "timeoutMs": 300000,
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 evaluates the first two panelists before launching another batch so it can avoid work after strong agreement.
153
- - `timeoutMs`: async subagent timeout in milliseconds
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. 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.
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
- - `judgeToolBudget`: optional `{ "soft": n, "hard": n }`. It caps the tool calls the judge can spend to verify contested claims. `soft` is a nudge. After `hard`, Fusion blocks further tool use, so the judge still produces a report. Both numbers must be positive integers, and `soft` must not be larger than `hard`.
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`, one surviving panelist still goes to the composer. Fusion does
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
- "timeoutMs": 300000,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- timeoutMs: 300_000,
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
 
@@ -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 { FusionPhase, FusionRun, ParsedFusionArgs } from "./types.js";
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
- "id" | "operationId" | "phase" | "report" | "error"
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
- return { run: state };
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
- return profile === undefined
405
- ? { prompt, operationId }
406
- : { prompt, operationId, profile };
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 input.profile === undefined
411
- ? { prompt: input.prompt, operationId: input.operationId }
412
- : {
413
- prompt: input.prompt,
414
- profile: input.profile,
415
- operationId: input.operationId,
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({ description: "What to review or discuss" }),
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?.length ? { panel: 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
  };