pi-extensible-workflows 1.0.1 → 2.0.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
@@ -20,7 +20,7 @@ For source installs and local development, see the [installation guide](https://
20
20
 
21
21
  ## Capabilities
22
22
 
23
- The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts, or register complete workflows that can be invoked by name.
23
+ The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts; every registered function is also directly runnable as a top-level workflow.
24
24
 
25
25
  A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
26
26
 
@@ -61,9 +61,14 @@ Global workflow settings live at `~/.pi/agent/pi-extensible-workflows/settings.j
61
61
  ```sh
62
62
  npx pi-extensible-workflows doctor
63
63
  npx pi-extensible-workflows inspect [session-id]
64
+ npx pi-extensible-workflows transcript <session-file>
65
+ npx pi-extensible-workflows run <workflow-name> [workflow arguments]
66
+ npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]
64
67
  ```
65
68
 
66
- `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs.
69
+ `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs. `transcript` renders a session transcript to stdout. `run` derives flat CLI arguments and help from a registered function's input schema. Use `--input '<json>'` for nested or otherwise complex inputs. It executes in the current working directory, writes the final JSON result to stdout, and writes progress and errors to stderr. `export` creates an executable POSIX launcher in `~/.local/bin` by default.
70
+ `run` and `export` accept the trust overrides `--approve` and `--no-approve`; the generated launcher forwards its arguments to `run`. `--` ends launcher option parsing, and later tokens are passed to workflow input instead of being interpreted as launcher options.
71
+ Launch snapshots use identity version 4. A cold resume intentionally rejects persisted v3 runs with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
67
72
 
68
73
  ## Development
69
74
 
@@ -31,6 +31,15 @@ export interface AgentDefinition {
31
31
  tools?: readonly string[];
32
32
  disabledAgentResources?: AgentResourceExclusions;
33
33
  }
34
+ export interface AgentProviderFailure {
35
+ label: string;
36
+ provider: string;
37
+ model: string;
38
+ error: string;
39
+ }
40
+ export type AgentProviderRecovery = "retry" | "abort" | {
41
+ model: string;
42
+ };
34
43
  export interface AgentExecutionOptions {
35
44
  label: string;
36
45
  workflowName: string;
@@ -40,6 +49,8 @@ export interface AgentExecutionOptions {
40
49
  thinking?: ThinkingLevel;
41
50
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
42
51
  onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
52
+ providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
53
+ modelOverride?: ModelSpec;
43
54
  tools?: readonly string[];
44
55
  effectiveTools?: readonly string[];
45
56
  role?: string;
@@ -179,6 +190,7 @@ export interface SessionInput {
179
190
  sessionFile: string;
180
191
  leafId: string;
181
192
  };
193
+ allowModelChange?: boolean;
182
194
  }
183
195
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
184
196
  export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
@@ -278,6 +290,7 @@ export declare class FairAgentScheduler {
278
290
  retry(id: string): void;
279
291
  attemptStarted(id: string): void;
280
292
  cancelRun(runId: string): Promise<void>;
293
+ removeRun(runId: string): void;
281
294
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[];
282
295
  snapshot(): readonly OwnershipRecord[];
283
296
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void;
@@ -25,10 +25,23 @@ function latestAssistantHasToolCall(messages) {
25
25
  const message = [...messages].reverse().find((item) => item.role === "assistant");
26
26
  return hasToolCall(message);
27
27
  }
28
- function throwIfTerminalAssistantError(session) {
28
+ function throwIfTerminalAssistantError(session, fallbackModel) {
29
29
  const message = [...session.messages].reverse().find((item) => item.role === "assistant");
30
- if (message?.stopReason === "error")
31
- throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
30
+ if (message?.stopReason !== "error")
31
+ return;
32
+ const provider = session.model?.provider ?? fallbackModel.provider;
33
+ const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
34
+ const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
35
+ const failure = new WorkflowError("AGENT_FAILED", error);
36
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
37
+ throw failure;
38
+ }
39
+ function terminalProviderError(error) {
40
+ const value = error.terminalProviderError;
41
+ if (!value || typeof value !== "object")
42
+ return undefined;
43
+ const candidate = value;
44
+ return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
32
45
  }
33
46
  function accounting(stats) {
34
47
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
@@ -50,8 +63,11 @@ export async function createNativeAgentSession(input) {
50
63
  throw new Error("Persisted transcript identity does not match the conversation head");
51
64
  manager.branch(input.continuation.leafId);
52
65
  const context = manager.buildSessionContext();
53
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
54
- throw new Error("Persisted transcript model does not match the conversation execution policy");
66
+ if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
67
+ if (!input.allowModelChange)
68
+ throw new Error("Persisted transcript model does not match the conversation execution policy");
69
+ manager.appendModelChange(input.model.provider, input.model.model);
70
+ }
55
71
  if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
56
72
  throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
57
73
  }
@@ -193,6 +209,26 @@ function conversationExecutionPolicy(options, setup) {
193
209
  resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
194
210
  });
195
211
  }
212
+ function conversationPolicyMatches(expected, current, allowModelChange) {
213
+ if (fingerprint(expected) === fingerprint(current))
214
+ return true;
215
+ if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current))
216
+ return false;
217
+ const expectedModel = conversationPolicyModel(expected);
218
+ const currentModel = conversationPolicyModel(current);
219
+ if (!expectedModel || !currentModel)
220
+ return false;
221
+ return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
222
+ }
223
+ function conversationPolicyModel(policy) {
224
+ if (!policy || typeof policy !== "object" || Array.isArray(policy))
225
+ return undefined;
226
+ const model = policy.model;
227
+ if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string")
228
+ return undefined;
229
+ const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking : undefined;
230
+ return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
231
+ }
196
232
  function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
197
233
  async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
198
234
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -261,7 +297,7 @@ export class WorkflowAgentExecutor {
261
297
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
262
298
  }
263
299
  const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
264
- const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
300
+ const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
265
301
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
266
302
  if (!availableModels.has(modelCapability(model)))
267
303
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
@@ -273,7 +309,8 @@ export class WorkflowAgentExecutor {
273
309
  throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
274
310
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
275
311
  throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
276
- const resolved = this.resolve(options);
312
+ let resolved = this.resolve(options);
313
+ let recoveryModel;
277
314
  let cwd;
278
315
  if (options.parent) {
279
316
  if (!options.cwd)
@@ -313,6 +350,11 @@ export class WorkflowAgentExecutor {
313
350
  catch (error) {
314
351
  throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
315
352
  }
353
+ if (conversationRecord) {
354
+ const model = conversationPolicyModel(conversationRecord.policy);
355
+ if (model)
356
+ resolved = this.resolve({ ...options, modelOverride: model });
357
+ }
316
358
  if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
317
359
  throw conversationFailure("Conversation turn must be a positive integer");
318
360
  if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
@@ -320,10 +362,11 @@ export class WorkflowAgentExecutor {
320
362
  }
321
363
  const attempts = [];
322
364
  let conversationBaseline;
323
- const maxAttempts = (options.retries ?? 0) + 1;
365
+ let maxAttempts = (options.retries ?? 0) + 1;
324
366
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
367
+ if (recoveryModel)
368
+ resolved = this.resolve({ ...options, modelOverride: recoveryModel });
325
369
  options.budget?.beforeAttempt();
326
- let accepted = false;
327
370
  let schemaResult;
328
371
  let session;
329
372
  let setup;
@@ -339,10 +382,10 @@ export class WorkflowAgentExecutor {
339
382
  const resultTool = options.schema ? {
340
383
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
341
384
  async execute(_id, value) {
342
- if (!accepted)
343
- return { content: [{ type: "text", text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
344
385
  if (!Value.Check(options.schema, value))
345
386
  return { content: [{ type: "text", text: "Result does not match the required schema." }], details: {}, isError: true };
387
+ if (schemaResult !== undefined)
388
+ return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
346
389
  schemaResult = structuredClone(value);
347
390
  void session?.abort?.();
348
391
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
@@ -374,6 +417,8 @@ export class WorkflowAgentExecutor {
374
417
  setupFailed = false;
375
418
  if (executionSignal?.aborted)
376
419
  throw new WorkflowError("CANCELLED", "Agent cancelled");
420
+ if (recoveryModel && conversationRecord)
421
+ setup.sessionInput.allowModelChange = true;
377
422
  const started = Date.now();
378
423
  session = await setup.createSession(setup.sessionInput);
379
424
  if (setup.sessionInput.resourcePolicy)
@@ -385,9 +430,9 @@ export class WorkflowAgentExecutor {
385
430
  if (conversationRecord) {
386
431
  if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
387
432
  throw conversationFailure("Conversation transcript identity changed");
388
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
433
+ if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId))
389
434
  throw conversationFailure("Conversation transcript leaf identity changed");
390
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy))
435
+ if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel)))
391
436
  throw conversationFailure("Conversation execution policy changed");
392
437
  if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
393
438
  throw conversationFailure("Conversation system prompt changed");
@@ -395,7 +440,7 @@ export class WorkflowAgentExecutor {
395
440
  throw conversationFailure("Conversation tool definitions changed");
396
441
  }
397
442
  else if (conversationBaseline) {
398
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy))
443
+ if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel)))
399
444
  throw conversationFailure("Conversation execution policy changed");
400
445
  if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
401
446
  throw conversationFailure("Conversation tool definitions changed");
@@ -454,7 +499,7 @@ export class WorkflowAgentExecutor {
454
499
  activity = undefined;
455
500
  if (event.message.role === "assistant") {
456
501
  const needsMoreWork = hasToolCall(event.message);
457
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
502
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
458
503
  if (!budgetError) {
459
504
  try {
460
505
  options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
@@ -496,34 +541,41 @@ export class WorkflowAgentExecutor {
496
541
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
497
542
  options.budget?.beforeTurn();
498
543
  turnStarted = true;
499
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
544
+ try {
545
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
546
+ }
547
+ catch (error) {
548
+ if (!hasSchemaResult())
549
+ throw error;
550
+ }
500
551
  if (conversationMismatch)
501
552
  throw conversationMismatch;
502
- throwIfTerminalAssistantError(session);
553
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
503
554
  {
504
555
  const completedAccounting = accounting(session.getSessionStats());
505
- options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages));
556
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
506
557
  turnStarted = false;
507
558
  }
508
559
  if (budgetError)
509
560
  throw budgetError;
510
561
  if (options.schema) {
511
- accepted = true;
512
- try {
513
- options.budget?.beforeTurn();
514
- turnStarted = true;
515
- await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
516
- {
517
- const completedAccounting = accounting(session.getSessionStats());
518
- options.budget?.afterTurn(completedAccounting, true);
519
- turnStarted = false;
562
+ if (!hasSchemaResult()) {
563
+ try {
564
+ options.budget?.beforeTurn();
565
+ turnStarted = true;
566
+ await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
567
+ {
568
+ const completedAccounting = accounting(session.getSessionStats());
569
+ options.budget?.afterTurn(completedAccounting, true);
570
+ turnStarted = false;
571
+ }
572
+ }
573
+ catch (error) {
574
+ if (!hasSchemaResult())
575
+ throw error;
520
576
  }
521
577
  }
522
- catch (error) {
523
- if (!hasSchemaResult())
524
- throw error;
525
- }
526
- throwIfTerminalAssistantError(session);
578
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
527
579
  if (!hasSchemaResult()) {
528
580
  try {
529
581
  options.budget?.beforeTurn();
@@ -539,7 +591,7 @@ export class WorkflowAgentExecutor {
539
591
  if (!hasSchemaResult())
540
592
  throw error;
541
593
  }
542
- throwIfTerminalAssistantError(session);
594
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
543
595
  }
544
596
  if (schemaResult === undefined)
545
597
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
@@ -594,6 +646,30 @@ export class WorkflowAgentExecutor {
594
646
  }
595
647
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
596
648
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
649
+ const terminal = terminalProviderError(typed);
650
+ if (terminal && options.providerErrorRecovery) {
651
+ let recovery;
652
+ try {
653
+ recovery = await options.providerErrorRecovery({ label: options.label, ...terminal });
654
+ }
655
+ catch {
656
+ throw Object.assign(typed, { attempts });
657
+ }
658
+ if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
659
+ if (typeof recovery === "object") {
660
+ try {
661
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
662
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
663
+ }
664
+ catch {
665
+ throw Object.assign(typed, { attempts });
666
+ }
667
+ }
668
+ maxAttempts += 1;
669
+ beforeRetry?.();
670
+ continue;
671
+ }
672
+ }
597
673
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
598
674
  throw Object.assign(typed, { attempts });
599
675
  beforeRetry?.();
@@ -723,6 +799,26 @@ export class FairAgentScheduler {
723
799
  if (nodes.every(({ restored }) => restored))
724
800
  run.logical = 0;
725
801
  }
802
+ removeRun(runId) {
803
+ const run = this.#runs.get(runId);
804
+ if (!run)
805
+ return;
806
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
807
+ if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state)))
808
+ throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
809
+ for (const { id } of nodes)
810
+ this.#nodes.delete(id);
811
+ this.#runs.delete(runId);
812
+ const index = this.#runOrder.indexOf(runId);
813
+ if (index >= 0) {
814
+ this.#runOrder.splice(index, 1);
815
+ if (index < this.#cursor)
816
+ this.#cursor -= 1;
817
+ if (this.#cursor >= this.#runOrder.length)
818
+ this.#cursor = 0;
819
+ }
820
+ this.#dispatch();
821
+ }
726
822
  toolsFor(parentId, resolveTools) {
727
823
  const parent = this.#node(parentId);
728
824
  if (!parent.options.tools.includes("agent"))
@@ -842,6 +938,7 @@ export class FairAgentScheduler {
842
938
  return;
843
939
  const heldPermit = node.state === "running" || node.state === "retrying";
844
940
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
941
+ Reflect.deleteProperty(node, "steer");
845
942
  this.#persist(node.runId);
846
943
  if (heldPermit)
847
944
  this.#release(node.runId);
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { type DoctorOptions } from "./doctor.js";
3
+ import { type JsonSchema, type JsonValue } from "./index.js";
4
+ import type { WorkflowCatalogFunction } from "./index.js";
3
5
  export interface CliOptions extends DoctorOptions {
4
6
  inspect?: (sessionId?: string) => Promise<void>;
7
+ transcript?: (sessionFile: string) => Promise<void>;
8
+ stderr?: (text: string) => void;
9
+ signal?: AbortSignal;
10
+ trustOverride?: boolean;
11
+ isTTY?: boolean;
5
12
  }
13
+ export declare function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command?: string): string;
14
+ export declare function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue>;
6
15
  export declare function runCli(args: readonly string[], options?: CliOptions, write?: (text: string) => void): Promise<number>;