pi-workflow-engine 0.9.0 → 0.10.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.
@@ -3,14 +3,21 @@ import { Type } from "typebox";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { SelectList, Text, truncateToWidth, type Component, type SelectItem, type SelectListTheme, type TUI } from "@earendil-works/pi-tui";
5
5
  import type { WorkflowProgressSnapshot } from "./src/progress.ts";
6
- import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
6
+ import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunMetadata, WorkflowRunOptions } from "./src/types.ts";
7
7
  import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
8
8
  import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
9
- import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./src/usage.ts";
9
+ import type { WorkflowUsageSnapshot } from "./src/usage.ts";
10
10
  import { registerDynamax } from "./src/dynamax.ts";
11
11
  import { handleReviewViewerAction } from "./src/review/review-actions.ts";
12
12
  import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
13
- import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
13
+ import {
14
+ formatWorkflowDetailLines,
15
+ isWorkflowResult,
16
+ renderWorkflowResult,
17
+ type WorkflowPerfDetails,
18
+ type WorkflowResultEnvelope,
19
+ } from "./src/ui/workflow-result-renderer.ts";
20
+ import { parseWorkflowBudgetString, WORKFLOW_BUDGET_MAX, WORKFLOW_BUDGET_MIN } from "./src/options.ts";
14
21
 
15
22
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
16
23
  const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
@@ -22,15 +29,25 @@ function summarize(result: unknown): string {
22
29
  return typeof result === "string" ? result : "Workflow finished.";
23
30
  }
24
31
 
25
- function formatMessageContent(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): string {
26
- const usageLine = formatWorkflowUsageLine(usage);
27
- const perfLine = formatPerfLine(perf);
28
- const details = [usageLine, perfLine].filter((line): line is string => line !== undefined);
32
+ function formatMessageContent(
33
+ name: string,
34
+ result: unknown,
35
+ usage?: WorkflowUsageSnapshot,
36
+ perf?: WorkflowPerfDetails,
37
+ metadata?: WorkflowRunMetadata,
38
+ ): string {
39
+ const details = formatWorkflowDetailLines({ usage, perf, metadata });
29
40
  return `## Workflow: ${name}\n\n${summarize(result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
30
41
  }
31
42
 
32
- function workflowEnvelope(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
33
- return { name, result, completedAt: Date.now(), usage, perf };
43
+ function workflowEnvelope(
44
+ name: string,
45
+ result: unknown,
46
+ usage?: WorkflowUsageSnapshot,
47
+ perf?: WorkflowPerfDetails,
48
+ metadata?: WorkflowRunMetadata,
49
+ ): WorkflowResultEnvelope {
50
+ return { name, result, completedAt: Date.now(), usage, perf, runId: metadata?.runId, resumedFromRunId: metadata?.resumedFromRunId };
34
51
  }
35
52
 
36
53
  function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
@@ -38,12 +55,6 @@ function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDe
38
55
  return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
39
56
  }
40
57
 
41
- function formatPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
42
- if (!perf) return undefined;
43
- const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
44
- return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
45
- }
46
-
47
58
  type DiscoveryModule = typeof import("./src/discovery.ts");
48
59
  type EngineModule = typeof import("./src/engine.ts");
49
60
  type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
@@ -216,6 +227,7 @@ export interface WorkflowInvocation {
216
227
  args: string;
217
228
  options: WorkflowRunOptions;
218
229
  refreshDiscovery?: boolean;
230
+ optionErrors?: string[];
219
231
  authorBrief?: string;
220
232
  }
221
233
 
@@ -224,14 +236,21 @@ export function parseWorkflowInvocation(input: string): WorkflowInvocation {
224
236
  const space = trimmed.indexOf(" ");
225
237
  const name = space === -1 ? trimmed : trimmed.slice(0, space);
226
238
  const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
227
- const { args, options, refreshDiscovery } = parseWorkflowOptions(rest);
228
- return { name, args, options, refreshDiscovery };
239
+ const { args, options, refreshDiscovery, optionErrors } = parseWorkflowOptions(rest);
240
+ const invocation: WorkflowInvocation = { name, args, options };
241
+ if (refreshDiscovery) invocation.refreshDiscovery = refreshDiscovery;
242
+ if (optionErrors) invocation.optionErrors = optionErrors;
243
+ return invocation;
229
244
  }
230
245
 
231
- function parseWorkflowOptions(input: string): { args: string; options: WorkflowRunOptions; refreshDiscovery?: boolean } {
246
+ const INVALID_BUDGET_OPTION = "--budget requires a positive integer output-token count";
247
+ const INVALID_RESUME_OPTION = "--resume requires a workflow run id";
248
+
249
+ function parseWorkflowOptions(input: string): { args: string; options: WorkflowRunOptions; refreshDiscovery?: boolean; optionErrors?: string[] } {
232
250
  const tokens = input.split(/\s+/).filter(Boolean);
233
251
  const kept: string[] = [];
234
252
  const options: WorkflowRunOptions = {};
253
+ const optionErrors: string[] = [];
235
254
  let refreshDiscovery = false;
236
255
  for (let i = 0; i < tokens.length; i++) {
237
256
  const token = tokens[i];
@@ -275,9 +294,46 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
275
294
  if (next !== undefined) i++;
276
295
  continue;
277
296
  }
297
+ if (token.startsWith("--budget=")) {
298
+ const parsed = parseBudgetOption(token.slice("--budget=".length));
299
+ if (parsed === undefined) optionErrors.push(INVALID_BUDGET_OPTION);
300
+ else options.budget = parsed;
301
+ continue;
302
+ }
303
+ if (token === "--budget") {
304
+ const next = tokens[i + 1];
305
+ const parsed = next === undefined ? undefined : parseBudgetOption(next);
306
+ if (parsed === undefined) {
307
+ optionErrors.push(INVALID_BUDGET_OPTION);
308
+ } else {
309
+ options.budget = parsed;
310
+ i++;
311
+ }
312
+ continue;
313
+ }
314
+ if (token.startsWith("--resume=")) {
315
+ const value = token.slice("--resume=".length).trim();
316
+ if (value === "") optionErrors.push(INVALID_RESUME_OPTION);
317
+ else options.resumeFromRunId = value;
318
+ continue;
319
+ }
320
+ if (token === "--resume") {
321
+ const next = tokens[i + 1];
322
+ if (next === undefined || next.startsWith("--")) {
323
+ optionErrors.push(INVALID_RESUME_OPTION);
324
+ } else {
325
+ options.resumeFromRunId = next;
326
+ i++;
327
+ }
328
+ continue;
329
+ }
278
330
  kept.push(token);
279
331
  }
280
- return { args: kept.join(" ").trim(), options, refreshDiscovery: refreshDiscovery || undefined };
332
+ return { args: kept.join(" ").trim(), options, refreshDiscovery: refreshDiscovery || undefined, optionErrors: optionErrors.length > 0 ? optionErrors : undefined };
333
+ }
334
+
335
+ function parseBudgetOption(value: string): number | undefined {
336
+ return parseWorkflowBudgetString(value);
281
337
  }
282
338
 
283
339
  function parseNumericOption(value: string | undefined): number | undefined {
@@ -302,6 +358,7 @@ Use the workflow tool with a script argument, not a saved workflow name.
302
358
  The script must start with export const meta = { ... } and default-export an async workflow function.
303
359
  Use the injected Type object for schemas. Do not import anything or use dynamic import().
304
360
  Set thinkingLevel explicitly on each agent() call.
361
+ When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent.
305
362
  Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
306
363
  Do not edit files unless the user explicitly requested edits.`;
307
364
  }
@@ -309,6 +366,7 @@ Do not edit files unless the user explicitly requested edits.`;
309
366
  export interface WorkflowToolRequestParams {
310
367
  readonly name?: string;
311
368
  readonly script?: string;
369
+ readonly resumeFromRunId?: string;
312
370
  }
313
371
 
314
372
  export type WorkflowToolRequest =
@@ -375,6 +433,7 @@ export async function sendWorkflowResult(
375
433
  const { runWorkflow } = await loadEngine();
376
434
  let perfSnapshot: PerfSnapshot | undefined;
377
435
  let usageSnapshot: WorkflowUsageSnapshot | undefined;
436
+ let runMetadata: WorkflowRunMetadata | undefined;
378
437
  let liveInspection: ActiveWorkflowInspection | undefined;
379
438
  const result = await runWorkflow(ctx, mod, args, {
380
439
  ...options,
@@ -399,6 +458,10 @@ export async function sendWorkflowResult(
399
458
  usageSnapshot = snapshot;
400
459
  options.onUsageSnapshot?.(snapshot);
401
460
  },
461
+ onRunMetadata(metadata) {
462
+ runMetadata = metadata;
463
+ options.onRunMetadata?.(metadata);
464
+ },
402
465
  onProgressSnapshot(snapshot) {
403
466
  lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
404
467
  options.onProgressSnapshot?.(snapshot);
@@ -418,7 +481,12 @@ export async function sendWorkflowResult(
418
481
  await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
419
482
  }
420
483
  pi.sendMessage(
421
- { customType: "workflow-result", content: formatMessageContent(name, result, usageSnapshot, perf), display: true, details: workflowEnvelope(name, result, usageSnapshot, perf) },
484
+ {
485
+ customType: "workflow-result",
486
+ content: formatMessageContent(name, result, usageSnapshot, perf, runMetadata),
487
+ display: true,
488
+ details: workflowEnvelope(name, result, usageSnapshot, perf, runMetadata),
489
+ },
422
490
  { triggerTurn: false },
423
491
  );
424
492
  }
@@ -428,7 +496,12 @@ export default function workflowEngine(pi: ExtensionAPI): void {
428
496
 
429
497
  pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
430
498
  const details = message.details;
431
- if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
499
+ if (isWorkflowResult(details)) {
500
+ return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage, {
501
+ runId: details.runId,
502
+ resumedFromRunId: details.resumedFromRunId,
503
+ }, details.perf);
504
+ }
432
505
  return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
433
506
  });
434
507
 
@@ -449,6 +522,10 @@ export default function workflowEngine(pi: ExtensionAPI): void {
449
522
  description: "Run a multi-agent workflow: /workflow <name> [args]",
450
523
  handler: async (args: string, ctx: ExtensionCommandContext) => {
451
524
  const direct = parseWorkflowInvocation(args);
525
+ if (direct.optionErrors?.length) {
526
+ ctx.ui.notify(`Invalid workflow option: ${direct.optionErrors.join("; ")}`, "warning");
527
+ return;
528
+ }
452
529
  const perfRecorder = await createInvocationPerf(direct.options);
453
530
  const { discoverWorkflows } = await loadDiscovery();
454
531
  const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: direct.refreshDiscovery, perf: perfRecorder });
@@ -489,6 +566,8 @@ export default function workflowEngine(pi: ExtensionAPI): void {
489
566
  "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
490
567
  "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
491
568
  "Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
569
+ "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
570
+ "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
492
571
  "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
493
572
  ],
494
573
  parameters: Type.Object({
@@ -497,7 +576,15 @@ export default function workflowEngine(pi: ExtensionAPI): void {
497
576
  args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
498
577
  concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
499
578
  parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
579
+ budget: Type.Optional(
580
+ Type.Integer({
581
+ minimum: WORKFLOW_BUDGET_MIN,
582
+ maximum: WORKFLOW_BUDGET_MAX,
583
+ description: "Optional output-token ceiling for the run; agent() throws once it is exceeded",
584
+ }),
585
+ ),
500
586
  perf: Type.Optional(Type.Boolean({ description: "Include workflow performance timing aggregates in the result details" })),
587
+ resumeFromRunId: Type.Optional(Type.String({ minLength: 1, description: "Workflow run id to resume from by replaying matching completed agent results" })),
501
588
  }),
502
589
  renderCall(args, theme) {
503
590
  const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
@@ -511,7 +598,12 @@ export default function workflowEngine(pi: ExtensionAPI): void {
511
598
  renderResult(result, { expanded, isPartial }, theme) {
512
599
  if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
513
600
  const details = result.details;
514
- if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
601
+ if (isWorkflowResult(details)) {
602
+ return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage, {
603
+ runId: details.runId,
604
+ resumedFromRunId: details.resumedFromRunId,
605
+ }, details.perf);
606
+ }
515
607
  const first = result.content[0];
516
608
  const text = first?.type === "text" ? first.text : "Workflow finished.";
517
609
  return new Text(theme.fg("muted", text), 0, 0);
@@ -519,12 +611,21 @@ export default function workflowEngine(pi: ExtensionAPI): void {
519
611
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
520
612
  const request = normalizeWorkflowToolRequest(params);
521
613
  if (request.kind === "error") return invalidWorkflowInvocationResult();
614
+ const resumeFromRunId = params.resumeFromRunId?.trim();
615
+ if (params.resumeFromRunId !== undefined && resumeFromRunId === "") {
616
+ return {
617
+ content: [{ type: "text", text: "resumeFromRunId must be non-empty." }],
618
+ details: { error: "invalid_resume_from_run_id" },
619
+ };
620
+ }
522
621
 
523
622
  const runOptions: WorkflowRunOptions = {
524
623
  inspect: ctx.hasUI,
525
624
  concurrency: params.concurrency,
526
625
  parallelSubmissionLimit: params.parallelSubmissionLimit,
626
+ budget: params.budget,
527
627
  perf: params.perf,
628
+ resumeFromRunId,
528
629
  signal,
529
630
  };
530
631
  const perfRecorder = await createInvocationPerf(runOptions);
@@ -558,6 +659,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
558
659
  const { runWorkflow } = await loadEngine();
559
660
  let perfSnapshot: PerfSnapshot | undefined;
560
661
  let usageSnapshot: WorkflowUsageSnapshot | undefined;
662
+ let runMetadata: WorkflowRunMetadata | undefined;
561
663
  let liveInspection: ActiveWorkflowInspection | undefined;
562
664
  const resultArgs = params.args ?? "";
563
665
  const result = await runWorkflow(ctx, mod, resultArgs, {
@@ -580,6 +682,9 @@ export default function workflowEngine(pi: ExtensionAPI): void {
580
682
  onUsageSnapshot: (snapshot) => {
581
683
  usageSnapshot = snapshot;
582
684
  },
685
+ onRunMetadata: (metadata) => {
686
+ runMetadata = metadata;
687
+ },
583
688
  onProgressSnapshot: (snapshot) => {
584
689
  // Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
585
690
  // previously uninspectable, unlike the /workflow command path.
@@ -587,10 +692,11 @@ export default function workflowEngine(pi: ExtensionAPI): void {
587
692
  },
588
693
  });
589
694
  const perf = compactPerfSnapshot(perfSnapshot);
590
- const usageLine = formatWorkflowUsageLine(usageSnapshot);
591
- const perfLine = formatPerfLine(perf);
592
- const detailLines = [usageLine, perfLine].filter((line): line is string => line !== undefined);
593
- return { content: [{ type: "text", text: `${summarize(result)}${detailLines.length > 0 ? `\n\n${detailLines.join("\n")}` : ""}` }], details: workflowEnvelope(resultName, result, usageSnapshot, perf) };
695
+ const detailLines = formatWorkflowDetailLines({ usage: usageSnapshot, perf, metadata: runMetadata });
696
+ return {
697
+ content: [{ type: "text", text: `${summarize(result)}${detailLines.length > 0 ? `\n\n${detailLines.join("\n")}` : ""}` }],
698
+ details: workflowEnvelope(resultName, result, usageSnapshot, perf, runMetadata),
699
+ };
594
700
  },
595
701
  });
596
702
  }