pi-extensible-workflows 3.2.0 → 3.3.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 default path is a named inline workflow: write a `script` that fans out independent work with `parallel(...)`, awaits the keyed results, passes them into one summarizing `agent(...)`, and returns. Inline launches require a non-empty `name`; registered function launches reject `name` and use their registered function name as the run name. Runs are backgrounded by default; set `foreground: true` when the final value must be returned in the same tool call.
23
+ The default path is a named inline workflow: write a `script` that fans out independent work with `parallel(...)`, awaits the keyed results, passes them into one summarizing `agent(...)`, and returns. Inline launches require a non-empty `name`; registered function launches reject `name` and use their registered function name as the run name. Runs are backgrounded by default; set `foreground: true` when the final value must be returned in the same tool call. If a foreground tool call detaches before its result is accepted by the next event-loop turn, the terminal success or failure is promoted to exactly one follow-up message.
24
24
 
25
25
  ```js
26
26
  const reviews = await parallel("review", {
@@ -51,35 +51,6 @@ Learn more about roles, workflow contracts, and extension APIs in the documentat
51
51
  - [Agent patterns and model selection](https://vekexasia.github.io/pi-extensible-workflows/agents.html#patterns)
52
52
  - [Checkpoints](https://vekexasia.github.io/pi-extensible-workflows/agents.html#checkpoints)
53
53
 
54
- ## Configuration
55
-
56
- Global workflow settings live at `~/.pi/agent/pi-extensible-workflows/settings.json` by default. Trusted projects can add partial overrides at `<project>/.pi/pi-extensible-workflows/settings.json`; precedence is defaults < global < trusted project < per-run options. Project collections replace global collections, so `{}` and empty arrays clear inherited aliases or resource exclusions. Untrusted project settings are ignored. See [global and project settings](https://vekexasia.github.io/pi-extensible-workflows/developers.html#settings) for the schema, trust gate, source reporting, and merge rules.
57
-
58
- ## CLI
59
-
60
- ```sh
61
- npx pi-extensible-workflows doctor
62
- npx pi-extensible-workflows doctor cleanup [--older-than-days <days>] [--yes]
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]
67
- ```
68
-
69
- `doctor` validates the installation and active Pi resources and remains read-only. `doctor cleanup` is an explicit, dry-run-first maintenance command: it previews terminal workflow runs older than 90 days across the current project's stored sessions and deletes them only with `--yes`; use `--older-than-days` to change the positive age threshold. It protects active, corrupt, leased, and dependency-linked runs. `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. Headless `run` and generated `export` launchers cannot execute workflows containing checkpoints; use the Pi workflow tool/UI path for checkpointed workflows.
71
- Launch snapshots use identity version 5. Cold resume rejects older snapshots, including v4 snapshots created with the previous worktree or registered-function naming contracts, with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
72
-
73
- ## Development
74
-
75
- ```sh
76
- npm ci
77
- npm run check
78
- npm run acceptance
79
- npm pack --dry-run --json
80
- ```
81
-
82
- Model-backed evaluations are optional. See the [evaluation guide](https://vekexasia.github.io/pi-extensible-workflows/developers.html#evaluation).
83
54
 
84
55
  ## License
85
56
 
@@ -15,6 +15,7 @@ export interface AgentDefinition {
15
15
  model?: string;
16
16
  thinking?: ThinkingLevel;
17
17
  tools?: readonly string[];
18
+ overrideSystemPrompt?: boolean;
18
19
  disabledAgentResources?: AgentResourceExclusions;
19
20
  }
20
21
  export interface AgentProviderFailure {
@@ -56,6 +57,7 @@ export interface AgentExecutionRoot {
56
57
  tools: ReadonlySet<string>;
57
58
  agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
58
59
  agentDir?: string;
60
+ additionalSkillPaths?: readonly string[];
59
61
  availableModels?: ReadonlySet<string>;
60
62
  knownModels?: ReadonlySet<string>;
61
63
  modelAliases?: Readonly<Record<string, string>>;
@@ -88,6 +90,7 @@ export interface AgentProgress {
88
90
  accounting: AgentAccounting;
89
91
  toolCalls: readonly AgentToolCallProgress[];
90
92
  activity?: AgentActivity;
93
+ lastEventAt?: number;
91
94
  persist: boolean;
92
95
  }
93
96
  export interface AgentAttempt {
@@ -117,6 +120,7 @@ export declare class WorkflowAgentExecutor {
117
120
  model: ModelSpec;
118
121
  requestedModel?: string;
119
122
  tools: readonly string[];
123
+ systemPrompt?: string;
120
124
  systemPromptAppend: string;
121
125
  };
122
126
  execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools?: readonly ToolDefinition[], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult>;
@@ -1,4 +1,5 @@
1
- import { realpathSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { Type } from "@earendil-works/pi-ai";
4
5
  import { Value } from "typebox/value";
@@ -42,6 +43,37 @@ function terminalProviderError(error) {
42
43
  const candidate = value;
43
44
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
44
45
  }
46
+ const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
47
+ async function recoverTerminalProviderError(session, fallbackModel, label, recovery, continuePrompt) {
48
+ let continued = false;
49
+ for (;;) {
50
+ try {
51
+ throwIfTerminalAssistantError(session, fallbackModel);
52
+ return continued;
53
+ }
54
+ catch (error) {
55
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
56
+ const terminal = terminalProviderError(typed);
57
+ if (!terminal || !recovery)
58
+ throw error;
59
+ let action;
60
+ try {
61
+ action = await recovery({ label, ...terminal });
62
+ }
63
+ catch {
64
+ Object.assign(typed, { providerRecoveryHandled: true, providerRecoveryFailed: true });
65
+ throw typed;
66
+ }
67
+ if (action === "retry") {
68
+ continued = true;
69
+ await continuePrompt();
70
+ continue;
71
+ }
72
+ Object.assign(typed, { providerRecoveryHandled: true, providerRecovery: action });
73
+ throw typed;
74
+ }
75
+ }
76
+ }
45
77
  function accounting(stats) {
46
78
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
47
79
  }
@@ -51,8 +83,18 @@ function canonicalSourcePath(path) { try {
51
83
  catch {
52
84
  return resolve(path);
53
85
  } }
86
+ const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
87
+ function workflowSystemPromptPath(cwd, agentDir, projectTrusted) {
88
+ const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
89
+ if (projectTrusted && existsSync(projectPath))
90
+ return projectPath;
91
+ const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
92
+ return globalPaths.find((path) => existsSync(path));
93
+ }
54
94
  export async function createNativeAgentSession(input) {
55
95
  const agentDir = input.agentDir ?? getAgentDir();
96
+ const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
97
+ const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
56
98
  const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
57
99
  manager.appendSessionInfo(input.sessionLabel);
58
100
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
@@ -87,18 +129,19 @@ export async function createNativeAgentSession(input) {
87
129
  noExtensions: true,
88
130
  additionalExtensionPaths: extensionPaths,
89
131
  noSkills: true,
90
- additionalSkillPaths: skillPaths,
132
+ additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
91
133
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
92
134
  skillsOverride: (base) => {
93
135
  const disabledSkills = updateSkillMatches(base.skills);
94
136
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
95
137
  },
96
138
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
139
+ ...systemPromptOptions,
97
140
  });
98
141
  await resourceLoader.reload();
99
142
  }
100
- else if (input.systemPromptAppend || input.extensionFactories?.length) {
101
- resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
143
+ else if (input.systemPrompt !== undefined || systemPromptSource !== undefined || input.systemPromptAppend || input.extensionFactories?.length || input.additionalSkillPaths?.length) {
144
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
102
145
  await resourceLoader.reload();
103
146
  }
104
147
  const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
@@ -142,7 +185,7 @@ async function prepareAgentSetup(root, createSession, task, options, resolved, c
142
185
  const baseResourcePolicy = await root.agentResourcePolicy?.();
143
186
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
144
187
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
145
- const sessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
188
+ const sessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
146
189
  const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
147
190
  const base = fallbackSetupContext(root, options, setupSignal);
148
191
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
@@ -206,7 +249,8 @@ export class WorkflowAgentExecutor {
206
249
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
207
250
  if (!availableModels.has(modelCapability(model)))
208
251
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
209
- return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
252
+ const overrideSystemPrompt = definition?.overrideSystemPrompt === true;
253
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], ...(overrideSystemPrompt ? { systemPrompt: definition.prompt ?? "" } : {}), systemPromptAppend: overrideSystemPrompt ? "" : definition?.prompt ?? "" };
210
254
  }
211
255
  async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
212
256
  const executionSignal = signal ?? this.root.runContext?.signal;
@@ -272,6 +316,8 @@ export class WorkflowAgentExecutor {
272
316
  } : undefined;
273
317
  const toolCalls = new Map();
274
318
  let activity;
319
+ let lastEventAt;
320
+ let lastReportedEventAt;
275
321
  let progress = Promise.resolve();
276
322
  let unsubscribe;
277
323
  let systemPromptTurn = 0;
@@ -285,9 +331,17 @@ export class WorkflowAgentExecutor {
285
331
  const report = (persist) => {
286
332
  if (!session || !options.onProgress)
287
333
  return;
288
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
334
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
335
+ if (lastEventAt !== undefined)
336
+ lastReportedEventAt = lastEventAt;
289
337
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
290
338
  };
339
+ const reportTimestamp = () => {
340
+ if (lastEventAt !== undefined && lastReportedEventAt !== undefined && lastEventAt - lastReportedEventAt < 1000)
341
+ return;
342
+ report(false);
343
+ };
344
+ const activityChanged = (previous) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
291
345
  try {
292
346
  setupFailed = true;
293
347
  const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
@@ -303,7 +357,33 @@ export class WorkflowAgentExecutor {
303
357
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
304
358
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
305
359
  const activeSession = session;
360
+ const sessionModel = setup.sessionInput.model;
361
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, sessionModel, options.label, options.providerErrorRecovery, async () => { try {
362
+ await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
363
+ }
364
+ catch (error) {
365
+ if (!hasSchemaResult())
366
+ throw error;
367
+ } });
368
+ const promptAndRecover = async (prompt) => {
369
+ let promptFailed = false;
370
+ let promptError;
371
+ try {
372
+ await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
373
+ }
374
+ catch (error) {
375
+ promptFailed = true;
376
+ promptError = error;
377
+ }
378
+ const recovered = await recoverTerminal();
379
+ if (promptFailed && !hasSchemaResult() && !recovered)
380
+ throw promptError;
381
+ };
306
382
  unsubscribe = activeSession.subscribe?.((event) => {
383
+ lastEventAt = Date.now();
384
+ let persist = false;
385
+ let shouldReport = false;
386
+ let removeToolCallId;
307
387
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
308
388
  if (this.root.runStore) {
309
389
  systemPromptTurn += 1;
@@ -323,10 +403,20 @@ export class WorkflowAgentExecutor {
323
403
  }
324
404
  }
325
405
  activity = { kind: "text", text: "responding" };
326
- report(false);
406
+ shouldReport = true;
407
+ }
408
+ if (event.type === "message_update") {
409
+ const previousActivity = activity;
410
+ if (["thinking_start", "thinking_delta", "thinking_end"].includes(event.assistantMessageEvent.type))
411
+ activity = { kind: "reasoning", text: "reasoning" };
412
+ else if (["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(event.assistantMessageEvent.type))
413
+ activity = { kind: "text", text: "responding" };
414
+ shouldReport = activityChanged(previousActivity);
327
415
  }
328
416
  if (event.type === "message_end") {
417
+ const previousActivity = activity;
329
418
  activity = undefined;
419
+ shouldReport = activityChanged(previousActivity);
330
420
  if (event.message.role === "assistant") {
331
421
  const needsMoreWork = hasToolCall(event.message);
332
422
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
@@ -345,21 +435,32 @@ export class WorkflowAgentExecutor {
345
435
  }
346
436
  }
347
437
  turnStarted = false;
348
- report(true);
438
+ persist = true;
349
439
  }
350
440
  }
351
441
  if (event.type === "tool_execution_start") {
352
442
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" });
353
443
  activity = { kind: "tool", text: event.toolName };
354
- report(false);
444
+ shouldReport = true;
445
+ }
446
+ if (event.type === "tool_execution_update") {
447
+ const previousActivity = activity;
448
+ activity = { kind: "tool", text: event.toolName };
449
+ shouldReport = activityChanged(previousActivity);
355
450
  }
356
451
  if (event.type === "tool_execution_end") {
357
452
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" });
358
453
  if (activity?.kind === "tool" && activity.text === event.toolName)
359
454
  activity = undefined;
360
- report(false);
361
- toolCalls.delete(event.toolCallId);
455
+ shouldReport = true;
456
+ removeToolCallId = event.toolCallId;
362
457
  }
458
+ if (shouldReport || persist)
459
+ report(persist);
460
+ else
461
+ reportTimestamp();
462
+ if (removeToolCallId)
463
+ toolCalls.delete(removeToolCallId);
363
464
  });
364
465
  report(false);
365
466
  if (setSteer) {
@@ -372,14 +473,7 @@ export class WorkflowAgentExecutor {
372
473
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
373
474
  options.budget?.beforeTurn();
374
475
  turnStarted = true;
375
- try {
376
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
377
- }
378
- catch (error) {
379
- if (!hasSchemaResult())
380
- throw error;
381
- }
382
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
476
+ await promptAndRecover(promptText);
383
477
  {
384
478
  const completedAccounting = accounting(session.getSessionStats());
385
479
  options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
@@ -389,38 +483,24 @@ export class WorkflowAgentExecutor {
389
483
  throw budgetError;
390
484
  if (options.schema) {
391
485
  if (!hasSchemaResult()) {
392
- try {
393
- options.budget?.beforeTurn();
394
- turnStarted = true;
395
- 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);
396
- {
397
- const completedAccounting = accounting(session.getSessionStats());
398
- options.budget?.afterTurn(completedAccounting, true);
399
- turnStarted = false;
400
- }
401
- }
402
- catch (error) {
403
- if (!hasSchemaResult())
404
- throw error;
486
+ options.budget?.beforeTurn();
487
+ turnStarted = true;
488
+ await promptAndRecover("Submit the final result now by calling workflow_result exactly once. Do not return prose.");
489
+ {
490
+ const completedAccounting = accounting(session.getSessionStats());
491
+ options.budget?.afterTurn(completedAccounting, true);
492
+ turnStarted = false;
405
493
  }
406
494
  }
407
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
408
495
  if (!hasSchemaResult()) {
409
- try {
410
- options.budget?.beforeTurn();
411
- turnStarted = true;
412
- await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
413
- {
414
- const completedAccounting = accounting(session.getSessionStats());
415
- options.budget?.afterTurn(completedAccounting, true);
416
- turnStarted = false;
417
- }
418
- }
419
- catch (error) {
420
- if (!hasSchemaResult())
421
- throw error;
496
+ options.budget?.beforeTurn();
497
+ turnStarted = true;
498
+ await promptAndRecover("Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.");
499
+ {
500
+ const completedAccounting = accounting(session.getSessionStats());
501
+ options.budget?.afterTurn(completedAccounting, true);
502
+ turnStarted = false;
422
503
  }
423
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
424
504
  }
425
505
  if (schemaResult === undefined)
426
506
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
@@ -464,29 +544,35 @@ export class WorkflowAgentExecutor {
464
544
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
465
545
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
466
546
  const terminal = terminalProviderError(typed);
467
- if (terminal && options.providerErrorRecovery) {
468
- let recovery;
547
+ const recoveryState = typed;
548
+ let recovery = recoveryState.providerRecovery;
549
+ if (terminal && options.providerErrorRecovery && !recoveryState.providerRecoveryHandled) {
469
550
  try {
470
551
  recovery = await options.providerErrorRecovery({ label: options.label, ...terminal });
471
552
  }
472
553
  catch {
473
554
  throw Object.assign(typed, { attempts });
474
555
  }
475
- if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
476
- if (typeof recovery === "object") {
477
- try {
478
- const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
479
- recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
480
- }
481
- catch {
482
- throw Object.assign(typed, { attempts });
483
- }
484
- }
556
+ if (recovery === "retry") {
485
557
  maxAttempts += 1;
486
558
  beforeRetry?.();
487
559
  continue;
488
560
  }
489
561
  }
562
+ if (recoveryState.providerRecoveryFailed || recovery === "abort")
563
+ throw Object.assign(typed, { attempts });
564
+ if (typeof recovery === "object" && typeof recovery.model === "string") {
565
+ try {
566
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
567
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
568
+ }
569
+ catch {
570
+ throw Object.assign(typed, { attempts });
571
+ }
572
+ maxAttempts += 1;
573
+ beforeRetry?.();
574
+ continue;
575
+ }
490
576
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
491
577
  throw Object.assign(typed, { attempts });
492
578
  beforeRetry?.();
@@ -0,0 +1,53 @@
1
+ import type { AgentDefinition, WorkflowCatalogFunction } from "./types.js";
2
+ export interface PortableWorkflowManifest {
3
+ format: "pi-extensible-workflows-bundle";
4
+ version: 1;
5
+ command: string;
6
+ workflow: {
7
+ name: string;
8
+ description: string;
9
+ input: Record<string, unknown>;
10
+ output: Record<string, unknown>;
11
+ };
12
+ runtime: {
13
+ pi: string;
14
+ "pi-extensible-workflows": string;
15
+ };
16
+ requirements: {
17
+ roles: readonly string[];
18
+ aliases: readonly string[];
19
+ tools: readonly string[];
20
+ commands: readonly string[];
21
+ environment: readonly string[];
22
+ };
23
+ aliasTargets?: Readonly<Record<string, string>>;
24
+ payload?: {
25
+ extensions?: readonly string[];
26
+ skills?: readonly string[];
27
+ static?: readonly string[];
28
+ dependencies?: readonly string[];
29
+ };
30
+ }
31
+ export interface PortableWorkflowBundleResources {
32
+ extensions?: readonly string[];
33
+ skills?: readonly string[];
34
+ static?: readonly string[];
35
+ dependencies?: readonly string[];
36
+ }
37
+ export interface PortableWorkflowBundleInput {
38
+ destination: string;
39
+ command: string;
40
+ workflow: WorkflowCatalogFunction;
41
+ functionSource: string;
42
+ piVersion?: string;
43
+ engineVersion?: string;
44
+ force?: boolean;
45
+ requirements?: Partial<PortableWorkflowManifest["requirements"]>;
46
+ aliasTargets?: Readonly<Record<string, string>>;
47
+ roles?: Readonly<Record<string, AgentDefinition>>;
48
+ resources?: PortableWorkflowBundleResources;
49
+ }
50
+ export declare function portableEngineVersion(): string;
51
+ export declare function portablePiVersion(): string;
52
+ export declare function normalizePortableFunctionSource(functionSource: string): string;
53
+ export declare function writePortableWorkflowBundle(input: PortableWorkflowBundleInput): PortableWorkflowManifest;