pi-background-tasks 0.7.7 → 0.9.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.
@@ -22,12 +22,21 @@ import {
22
22
  buildFusionCanonicalInput,
23
23
  normalizeFusionCommandRequest,
24
24
  } from './core/fusion/context.js';
25
+ import {
26
+ FUSION_BRAINSTORM_WORKFLOW,
27
+ FUSION_VALIDATE_TOOL_NAME,
28
+ FUSION_VALIDATE_WORKFLOW,
29
+ type FusionWorkflowProfile,
30
+ } from './core/fusion/workflows.js';
25
31
  import type { JsonObject } from './core/common.js';
26
32
  import { FusionOrchestrator } from './core/fusion/orchestrator.js';
27
33
  import {
34
+ FUSION_CAPABILITY_VALUES,
35
+ FUSION_DEFAULT_CAPABILITY,
28
36
  FUSION_RESULT_SCHEMA_VERSION,
29
37
  FusionError,
30
38
  cloneFusionUsage,
39
+ type FusionCapability,
31
40
  type FusionModelConfigV1,
32
41
  type FusionModelSelection,
33
42
  type FusionProgressEvent,
@@ -73,6 +82,8 @@ interface FusionRunRequest {
73
82
  source: 'command' | 'tool';
74
83
  ctx: ExtensionContext;
75
84
  request: string;
85
+ profile?: FusionWorkflowProfile | undefined;
86
+ capability?: FusionCapability | undefined;
76
87
  signal?: AbortSignal | undefined;
77
88
  toolCallId?: string | undefined;
78
89
  onProgress?: ((event: FusionProgressEvent) => void) | undefined;
@@ -84,9 +95,15 @@ interface FusionRequestDetails {
84
95
  source: 'command';
85
96
  }
86
97
 
87
- const FusionBrainstormParams = Type.Object(
98
+ export const FusionBrainstormParams = Type.Object(
88
99
  {
89
100
  prompt: Type.String({ description: 'Prompt to run through the five-model fusion workflow.' }),
101
+ capability: Type.Optional(
102
+ Type.Union([Type.Literal('reason'), Type.Literal('inspect'), Type.Literal('research')], {
103
+ description:
104
+ "Optional candidate-child capability: 'reason' uses no tools; 'inspect' enables read-only file inspection; 'research' enables read-only file inspection plus fusion_web_fetch.",
105
+ }),
106
+ ),
90
107
  },
91
108
  { additionalProperties: false },
92
109
  );
@@ -139,29 +156,35 @@ function toolFailureMessage(error: unknown): string {
139
156
  return `Fusion failed${location}: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
140
157
  }
141
158
 
142
- function progressText(event: FusionProgressEvent): string {
143
- if (event.type === 'state') return `fusion: ${event.state.replace(/_/g, ' ')}`;
144
- if (event.type === 'candidate_started') return `fusion: candidate ${String(event.slot)} starting`;
159
+ function progressText(event: FusionProgressEvent, label = FUSION_BRAINSTORM_WORKFLOW.label): string {
160
+ if (event.type === 'state') return `${label}: ${event.state.replace(/_/g, ' ')}`;
161
+ if (event.type === 'candidate_started')
162
+ return `${label}: candidate ${String(event.slot)} starting`;
145
163
  if (event.type === 'candidate_completed')
146
- return `fusion: candidates ${String(event.completed)}/${String(event.total)} complete`;
164
+ return `${label}: candidates ${String(event.completed)}/${String(event.total)} complete`;
147
165
  if (event.type === 'evaluation_started')
148
- return event.repair ? 'fusion: repairing evaluator JSON' : 'fusion: evaluating candidates';
166
+ return event.repair
167
+ ? `${label}: repairing evaluator JSON`
168
+ : `${label}: evaluating candidates`;
149
169
  if (event.type === 'evaluation_retry')
150
- return `fusion: evaluator schema retry (${String(event.errors.length)} issue${event.errors.length === 1 ? '' : 's'})`;
170
+ return `${label}: evaluator schema retry (${String(event.errors.length)} issue${event.errors.length === 1 ? '' : 's'})`;
151
171
  if (event.type === 'budget_warning')
152
- return `fusion: budget warning (${String(event.warnings.length)} stage${event.warnings.length === 1 ? '' : 's'} at or above 80%)`;
172
+ return `${label}: budget warning (${String(event.warnings.length)} stage${event.warnings.length === 1 ? '' : 's'} at or above 80%)`;
153
173
  if (event.type === 'calibration_warning')
154
- return `fusion: calibration warning (${String(event.warning.under_forecast_tokens)} tokens under forecast)`;
155
- if (event.type === 'merge_started') return 'fusion: merging final answer';
156
- if (event.type === 'completed') return 'fusion: completed';
157
- if (event.type === 'cancelled') return `fusion: cancelled (${event.reason})`;
158
- return `fusion: failed (${event.error})`;
174
+ return `${label}: calibration warning (${String(event.warning.under_forecast_tokens)} tokens under forecast)`;
175
+ if (event.type === 'merge_started') return `${label}: merging final answer`;
176
+ if (event.type === 'completed') return `${label}: completed`;
177
+ if (event.type === 'cancelled') return `${label}: cancelled (${event.reason})`;
178
+ return `${label}: failed (${event.error})`;
159
179
  }
160
180
 
161
- function makeProgressDetails(event: FusionProgressEvent): FusionProgressDetails {
181
+ function makeProgressDetails(
182
+ event: FusionProgressEvent,
183
+ label = FUSION_BRAINSTORM_WORKFLOW.label,
184
+ ): FusionProgressDetails {
162
185
  return {
163
186
  schema_version: FUSION_PROGRESS_SCHEMA_VERSION,
164
- status: progressText(event),
187
+ status: progressText(event, label),
165
188
  event,
166
189
  };
167
190
  }
@@ -189,12 +212,13 @@ function renderFusionResultText(
189
212
  details: FusionResultDetails,
190
213
  options: ToolRenderResultOptions,
191
214
  theme: Theme,
215
+ label = FUSION_BRAINSTORM_WORKFLOW.label,
192
216
  ) {
193
217
  if (options.expanded) {
194
218
  const container = new Container();
195
219
  container.addChild(
196
220
  new Text(
197
- `${theme.fg('success', '✓ fusion complete')} ${theme.fg('dim', details.run_id)}\n${theme.fg('dim', `Artifacts: ${details.artifact_dir} · ${usageSummary(details)}`)}`,
221
+ `${theme.fg('success', `✓ ${label} complete`)} ${theme.fg('dim', details.run_id)}\n${theme.fg('dim', `Artifacts: ${details.artifact_dir} · ${usageSummary(details)}`)}`,
198
222
  0,
199
223
  0,
200
224
  ),
@@ -204,7 +228,7 @@ function renderFusionResultText(
204
228
  }
205
229
  const preview = mergedText.replace(/\s+/g, ' ').trim();
206
230
  return new Text(
207
- `${theme.fg('success', '✓ fusion')} ${theme.fg('dim', details.run_id)} ${theme.fg('muted', usageSummary(details))}\n${preview}`,
231
+ `${theme.fg('success', `✓ ${label}`)} ${theme.fg('dim', details.run_id)} ${theme.fg('muted', usageSummary(details))}\n${preview}`,
208
232
  0,
209
233
  0,
210
234
  );
@@ -219,6 +243,7 @@ function isFusionResultDetails(value: unknown): value is FusionResultDetails {
219
243
  return (
220
244
  value['schema_version'] === FUSION_RESULT_SCHEMA_VERSION &&
221
245
  typeof value['run_id'] === 'string' &&
246
+ (value['workflow'] === 'brainstorm' || value['workflow'] === 'validate') &&
222
247
  (value['source'] === 'command' || value['source'] === 'tool') &&
223
248
  value['status'] === 'completed' &&
224
249
  typeof value['artifact_dir'] === 'string' &&
@@ -278,11 +303,62 @@ function normalizeToolPrompt(value: unknown): string {
278
303
  return prompt;
279
304
  }
280
305
 
281
- function prepareFusionArguments(args: unknown): FusionBrainstormParamsValue {
306
+ function normalizeFusionCapability(value: unknown): FusionCapability {
307
+ if (value === undefined) return FUSION_DEFAULT_CAPABILITY;
308
+ if (typeof value !== 'string') {
309
+ throw new Error(
310
+ `fusion_brainstorm capability must be one of: ${FUSION_CAPABILITY_VALUES.join(', ')}`,
311
+ );
312
+ }
313
+ if (FUSION_CAPABILITY_VALUES.includes(value as FusionCapability)) return value as FusionCapability;
314
+ throw new Error(
315
+ `fusion_brainstorm capability ${JSON.stringify(value)} is not supported; allowed values: ${FUSION_CAPABILITY_VALUES.join(', ')}`,
316
+ );
317
+ }
318
+
319
+ export function prepareFusionArguments(args: unknown): FusionBrainstormParamsValue {
282
320
  if (!isRecord(args)) throw new Error('fusion_brainstorm arguments must be an object');
283
321
  const keys = Object.keys(args);
284
- if (keys.length !== 1 || keys[0] !== 'prompt') {
285
- throw new Error('fusion_brainstorm arguments must contain only prompt');
322
+ const unknown = keys.filter((key) => key !== 'prompt' && key !== 'capability');
323
+ if (unknown.length > 0 || !keys.includes('prompt')) {
324
+ throw new Error('fusion_brainstorm arguments must contain prompt and optional capability only');
325
+ }
326
+ return {
327
+ prompt: normalizeToolPrompt(args['prompt']),
328
+ capability: normalizeFusionCapability(args['capability']),
329
+ };
330
+ }
331
+
332
+ export const FusionValidateParams = Type.Object(
333
+ {
334
+ prompt: Type.String({
335
+ description:
336
+ 'What was done and what must hold true about it. Reviewers read the repository themselves.',
337
+ }),
338
+ },
339
+ { additionalProperties: false },
340
+ );
341
+
342
+ type FusionValidateParamsValue = Static<typeof FusionValidateParams>;
343
+
344
+ /**
345
+ * `fusion_validate` takes no capability.
346
+ *
347
+ * A caller-supplied capability is rejected rather than ignored: silently dropping
348
+ * `capability:'reason'` would run a review whose children never read the code,
349
+ * which is the one outcome this tool exists to prevent.
350
+ */
351
+ export function prepareFusionValidateArguments(args: unknown): FusionValidateParamsValue {
352
+ if (!isRecord(args)) throw new Error('fusion_validate arguments must be an object');
353
+ const keys = Object.keys(args);
354
+ if (keys.includes('capability')) {
355
+ throw new Error(
356
+ 'fusion_validate does not accept capability; validation always runs candidates with read-only inspect access',
357
+ );
358
+ }
359
+ const unknown = keys.filter((key) => key !== 'prompt');
360
+ if (unknown.length > 0 || !keys.includes('prompt')) {
361
+ throw new Error('fusion_validate arguments must contain prompt only');
286
362
  }
287
363
  return { prompt: normalizeToolPrompt(args['prompt']) };
288
364
  }
@@ -328,6 +404,7 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
328
404
  if (shuttingDown || lifecycleGeneration !== generation)
329
405
  throw new Error('fusion extension is shutting down');
330
406
  };
407
+ const profile = request.profile ?? FUSION_BRAINSTORM_WORKFLOW;
331
408
  try {
332
409
  assertActive();
333
410
  const contextOptions =
@@ -335,13 +412,13 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
335
412
  ? {
336
413
  source: request.source,
337
414
  request: request.request,
338
- toolName: FUSION_BRAINSTORM_TOOL_NAME,
415
+ toolName: profile.toolName,
339
416
  }
340
417
  : {
341
418
  source: request.source,
342
419
  request: request.request,
343
420
  toolCallId: request.toolCallId,
344
- toolName: FUSION_BRAINSTORM_TOOL_NAME,
421
+ toolName: profile.toolName,
345
422
  };
346
423
  const built = buildFusionCanonicalInput(request.ctx, contextOptions);
347
424
  const cwd = request.ctx.cwd;
@@ -367,6 +444,8 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
367
444
  contextLedger: built.ledger,
368
445
  config: loaded.config,
369
446
  models,
447
+ profile,
448
+ candidateCapability: request.capability,
370
449
  signal: controller.signal,
371
450
  onProgress: request.onProgress,
372
451
  });
@@ -554,12 +633,13 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
554
633
  pi.registerTool<typeof FusionBrainstormParams, FusionToolDetails>({
555
634
  name: FUSION_BRAINSTORM_TOOL_NAME,
556
635
  label: 'Fusion Brainstorm',
557
- description: 'Run a five-model fusion workflow for a prompt and return the merged answer.',
636
+ description:
637
+ "Run a five-model fusion workflow for a prompt and return the merged answer. Optional capability:'inspect' lets candidate children use read-only file tools; capability:'research' also enables fusion_web_fetch.",
558
638
  promptSnippet:
559
639
  'Use fusion_brainstorm to get a merged answer from the five-model fusion workflow',
560
640
  promptGuidelines: [
561
- 'fusion_brainstorm is always available; call fusion_brainstorm({prompt}) whenever a merged multi-model answer would help.',
562
- 'fusion_brainstorm has no eligibility, quota, routine, or justification gate; provide only the prompt string.',
641
+ "fusion_brainstorm is always available; call fusion_brainstorm({prompt}) for no-tool reasoning, fusion_brainstorm({prompt, capability:'inspect'}) when candidate children need read-only file inspection, or fusion_brainstorm({prompt, capability:'research'}) when they also need to fetch a specific public URL.",
642
+ "Use capability:'inspect' only when the answer benefits from reading/searching/listing repository files; use capability:'research' only when public web fetching is required. Evaluator and merger remain no-tools by policy.",
563
643
  ],
564
644
  parameters: FusionBrainstormParams,
565
645
  prepareArguments: prepareFusionArguments,
@@ -571,6 +651,7 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
571
651
  source: 'tool',
572
652
  ctx,
573
653
  request: prompt,
654
+ capability: params.capability ?? FUSION_DEFAULT_CAPABILITY,
574
655
  signal,
575
656
  toolCallId,
576
657
  onProgress: (event) => {
@@ -610,12 +691,86 @@ export function registerFusionExtension(pi: ExtensionAPI): void {
610
691
  },
611
692
  });
612
693
 
694
+ pi.registerTool<typeof FusionValidateParams, FusionToolDetails>({
695
+ name: FUSION_VALIDATE_TOOL_NAME,
696
+ label: 'Fusion Validate',
697
+ description:
698
+ 'Run a five-model fusion validation review of completed work and return the merged review. Reviewers always have read-only repository access; there is no capability argument.',
699
+ promptSnippet:
700
+ 'Use fusion_validate to get a merged multi-model review of work that was just completed',
701
+ promptGuidelines: [
702
+ 'Call fusion_validate({prompt}) after work is complete to get an independent multi-model review. It always runs with read-only repository inspection and takes no capability argument.',
703
+ 'State in the prompt what was done, where it lives, and what must hold true. Reviewers read the repository themselves, but facts that exist only inside omitted tool output are not available to them; restate those in the prompt.',
704
+ 'fusion_validate returns a prose review whose findings are classified critical, high, or minor. It never modifies files and is not a substitute for running tests or builds.',
705
+ ],
706
+ parameters: FusionValidateParams,
707
+ prepareArguments: prepareFusionValidateArguments,
708
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
709
+ const prompt = normalizeToolPrompt(params.prompt);
710
+ const label = FUSION_VALIDATE_WORKFLOW.label;
711
+ let result: FusionRunResult;
712
+ try {
713
+ result = await runFusion({
714
+ source: 'tool',
715
+ ctx,
716
+ request: prompt,
717
+ profile: FUSION_VALIDATE_WORKFLOW,
718
+ // Workflow policy, not caller input: the fixed capability is resolved by the
719
+ // workflow itself, so no caller-selected value can reach this launch.
720
+ signal,
721
+ toolCallId,
722
+ onProgress: (event) => {
723
+ onUpdate?.({
724
+ content: textContent(progressText(event, label)),
725
+ details: makeProgressDetails(event, label),
726
+ });
727
+ },
728
+ });
729
+ } catch (error) {
730
+ throw new Error(toolFailureMessage(error), { cause: error });
731
+ }
732
+ const toolResult: FusionToolResultWithUsage = {
733
+ content: textContent(result.mergedText),
734
+ details: result.details,
735
+ usage: cloneFusionUsage(result.details.usage),
736
+ };
737
+ return toolResult;
738
+ },
739
+ renderCall(args, theme) {
740
+ const preview = args.prompt.replace(/\s+/g, ' ').trim();
741
+ return new Text(
742
+ `${theme.fg('toolTitle', theme.bold('fusion_validate '))}${theme.fg('muted', preview)}`,
743
+ 0,
744
+ 0,
745
+ );
746
+ },
747
+ renderResult(result, options, theme) {
748
+ if (isFusionProgressDetails(result.details))
749
+ return renderProgressResult(result.details, theme);
750
+ if (!isFusionResultDetails(result.details))
751
+ return new Text(theme.fg('error', 'Invalid fusion tool details'), 0, 0);
752
+ const mergedText = result.content
753
+ .map((part) => (part.type === 'text' ? part.text : ''))
754
+ .join('\n');
755
+ return renderFusionResultText(
756
+ mergedText,
757
+ result.details,
758
+ options,
759
+ theme,
760
+ FUSION_VALIDATE_WORKFLOW.label,
761
+ );
762
+ },
763
+ });
764
+
613
765
  pi.on('session_start', () => {
614
766
  shuttingDown = false;
615
767
  lifecycleGeneration += 1;
616
768
  const active = pi.getActiveTools();
617
- if (!active.includes(FUSION_BRAINSTORM_TOOL_NAME)) {
618
- pi.setActiveTools([...active, FUSION_BRAINSTORM_TOOL_NAME]);
769
+ const missing = [FUSION_BRAINSTORM_TOOL_NAME, FUSION_VALIDATE_TOOL_NAME].filter(
770
+ (name) => !active.includes(name),
771
+ );
772
+ if (missing.length > 0) {
773
+ pi.setActiveTools([...active, ...missing]);
619
774
  }
620
775
  });
621
776