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.
@@ -17,6 +17,8 @@ export interface AgentBudgetHooks {
17
17
  instruction(): string | undefined;
18
18
  }
19
19
  export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
20
+ export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
21
+ export type AgentProviderRecovery = "retry" | "abort" | { model: string };
20
22
  export interface AgentExecutionOptions {
21
23
  label: string;
22
24
  workflowName: string;
@@ -26,6 +28,8 @@ export interface AgentExecutionOptions {
26
28
  thinking?: ThinkingLevel;
27
29
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
28
30
  onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
31
+ providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
32
+ modelOverride?: ModelSpec;
29
33
  tools?: readonly string[];
30
34
  effectiveTools?: readonly string[];
31
35
  role?: string;
@@ -85,7 +89,7 @@ export interface NativeSession {
85
89
  abort?(): Promise<void>;
86
90
  dispose(): void;
87
91
  }
88
- export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string } }
92
+ export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string }; allowModelChange?: boolean }
89
93
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
90
94
 
91
95
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
@@ -110,9 +114,22 @@ function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean
110
114
  return hasToolCall(message);
111
115
  }
112
116
 
113
- function throwIfTerminalAssistantError(session: NativeSession): void {
117
+ type TerminalProviderError = { provider: string; model: string; error: string };
118
+ function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
114
119
  const message = [...session.messages].reverse().find((item) => item.role === "assistant");
115
- if (message?.stopReason === "error") throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
120
+ if (message?.stopReason !== "error") return;
121
+ const provider = session.model?.provider ?? fallbackModel.provider;
122
+ const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
123
+ const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
124
+ const failure = new WorkflowError("AGENT_FAILED", error);
125
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
126
+ throw failure;
127
+ }
128
+ function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
129
+ const value = (error as WorkflowError & { terminalProviderError?: unknown }).terminalProviderError;
130
+ if (!value || typeof value !== "object") return undefined;
131
+ const candidate = value as Partial<TerminalProviderError>;
132
+ return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
116
133
  }
117
134
 
118
135
  function accounting(stats: NativeSessionStats): AgentAccounting {
@@ -130,7 +147,10 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
130
147
  if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
131
148
  manager.branch(input.continuation.leafId);
132
149
  const context = manager.buildSessionContext();
133
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) throw new Error("Persisted transcript model does not match the conversation execution policy");
150
+ if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
151
+ if (!input.allowModelChange) throw new Error("Persisted transcript model does not match the conversation execution policy");
152
+ manager.appendModelChange(input.model.provider, input.model.model);
153
+ }
134
154
  if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
135
155
  } catch (error) {
136
156
  if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
@@ -262,6 +282,21 @@ function conversationExecutionPolicy(options: AgentExecutionOptions, setup: Agen
262
282
  resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
263
283
  }) as unknown as JsonValue;
264
284
  }
285
+ function conversationPolicyMatches(expected: JsonValue, current: JsonValue, allowModelChange: boolean): boolean {
286
+ if (fingerprint(expected) === fingerprint(current)) return true;
287
+ if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current)) return false;
288
+ const expectedModel = conversationPolicyModel(expected);
289
+ const currentModel = conversationPolicyModel(current);
290
+ if (!expectedModel || !currentModel) return false;
291
+ return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
292
+ }
293
+ function conversationPolicyModel(policy: JsonValue): ModelSpec | undefined {
294
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) return undefined;
295
+ const model = policy.model;
296
+ if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string") return undefined;
297
+ const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking as ModelSpec["thinking"] : undefined;
298
+ return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
299
+ }
265
300
  function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
266
301
  async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
267
302
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -307,7 +342,7 @@ export class WorkflowAgentExecutor {
307
342
  const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
308
343
  if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
309
344
  const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
310
- 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);
345
+ 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);
311
346
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
312
347
  if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
313
348
  return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
@@ -317,7 +352,8 @@ export class WorkflowAgentExecutor {
317
352
  const executionSignal = signal ?? this.root.runContext?.signal;
318
353
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
319
354
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
320
- const resolved = this.resolve(options);
355
+ let resolved = this.resolve(options);
356
+ let recoveryModel: ModelSpec | undefined;
321
357
  let cwd: string;
322
358
  if (options.parent) {
323
359
  if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
@@ -342,15 +378,19 @@ export class WorkflowAgentExecutor {
342
378
  const store = this.root.runStore;
343
379
  if (!store) throw conversationFailure("Conversation persistence is unavailable");
344
380
  try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
381
+ if (conversationRecord) {
382
+ const model = conversationPolicyModel(conversationRecord.policy);
383
+ if (model) resolved = this.resolve({ ...options, modelOverride: model });
384
+ }
345
385
  if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
346
386
  if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
347
387
  }
348
388
  const attempts: AgentAttempt[] = [];
349
389
  let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
350
- const maxAttempts = (options.retries ?? 0) + 1;
390
+ let maxAttempts = (options.retries ?? 0) + 1;
351
391
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
392
+ if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
352
393
  options.budget?.beforeAttempt();
353
- let accepted = false;
354
394
  let schemaResult: JsonValue | undefined;
355
395
  let session: NativeSession | undefined;
356
396
  let setup: AgentSetup | undefined;
@@ -366,8 +406,8 @@ export class WorkflowAgentExecutor {
366
406
  const resultTool = options.schema ? {
367
407
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
368
408
  async execute(_id: string, value: unknown) {
369
- if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
370
409
  if (!Value.Check(options.schema as object, value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
410
+ if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
371
411
  schemaResult = structuredClone(value) as JsonValue;
372
412
  void session?.abort?.();
373
413
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
@@ -396,6 +436,7 @@ export class WorkflowAgentExecutor {
396
436
  setupSummary = prepared.summary;
397
437
  setupFailed = false;
398
438
  if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
439
+ if (recoveryModel && conversationRecord) setup.sessionInput.allowModelChange = true;
399
440
  const started = Date.now();
400
441
  session = await setup.createSession(setup.sessionInput);
401
442
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
@@ -405,12 +446,12 @@ export class WorkflowAgentExecutor {
405
446
  const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
406
447
  if (conversationRecord) {
407
448
  if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
408
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
409
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
449
+ if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId)) throw conversationFailure("Conversation transcript leaf identity changed");
450
+ if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
410
451
  if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
411
452
  if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
412
453
  } else if (conversationBaseline) {
413
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
454
+ if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
414
455
  if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
415
456
  } else {
416
457
  conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
@@ -449,7 +490,7 @@ export class WorkflowAgentExecutor {
449
490
  activity = undefined;
450
491
  if (event.message.role === "assistant") {
451
492
  const needsMoreWork = hasToolCall(event.message);
452
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
493
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
453
494
  if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void session?.steer?.(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
454
495
  turnStarted = false;
455
496
  report(true);
@@ -468,18 +509,19 @@ export class WorkflowAgentExecutor {
468
509
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
469
510
  options.budget?.beforeTurn();
470
511
  turnStarted = true;
471
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
512
+ try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
472
513
  if (conversationMismatch) throw conversationMismatch;
473
- throwIfTerminalAssistantError(session);
474
- { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
514
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
515
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
475
516
  if (budgetError) throw budgetError;
476
517
  if (options.schema) {
477
- accepted = true;
478
- try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
479
- throwIfTerminalAssistantError(session);
518
+ if (!hasSchemaResult()) {
519
+ try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
520
+ }
521
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
480
522
  if (!hasSchemaResult()) {
481
523
  try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
482
- throwIfTerminalAssistantError(session);
524
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
483
525
  }
484
526
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
485
527
  }
@@ -517,6 +559,22 @@ export class WorkflowAgentExecutor {
517
559
  session.dispose();
518
560
  }
519
561
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
562
+ const terminal = terminalProviderError(typed);
563
+ if (terminal && options.providerErrorRecovery) {
564
+ let recovery: AgentProviderRecovery;
565
+ try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
566
+ if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
567
+ if (typeof recovery === "object") {
568
+ try {
569
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
570
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
571
+ } catch { throw Object.assign(typed, { attempts }); }
572
+ }
573
+ maxAttempts += 1;
574
+ beforeRetry?.();
575
+ continue;
576
+ }
577
+ }
520
578
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
521
579
  beforeRetry?.();
522
580
  }
@@ -676,6 +734,22 @@ export class FairAgentScheduler {
676
734
  if (nodes.every(({ restored }) => restored)) run.logical = 0;
677
735
  }
678
736
 
737
+ removeRun(runId: string): void {
738
+ const run = this.#runs.get(runId);
739
+ if (!run) return;
740
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
741
+ if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state))) throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
742
+ for (const { id } of nodes) this.#nodes.delete(id);
743
+ this.#runs.delete(runId);
744
+ const index = this.#runOrder.indexOf(runId);
745
+ if (index >= 0) {
746
+ this.#runOrder.splice(index, 1);
747
+ if (index < this.#cursor) this.#cursor -= 1;
748
+ if (this.#cursor >= this.#runOrder.length) this.#cursor = 0;
749
+ }
750
+ this.#dispatch();
751
+ }
752
+
679
753
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
680
754
  const parent = this.#node(parentId);
681
755
  if (!parent.options.tools.includes("agent")) return [];
@@ -772,6 +846,7 @@ export class FairAgentScheduler {
772
846
  if (["completed", "failed", "cancelled"].includes(node.state)) return;
773
847
  const heldPermit = node.state === "running" || node.state === "retrying";
774
848
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
849
+ Reflect.deleteProperty(node, "steer");
775
850
  this.#persist(node.runId);
776
851
  if (heldPermit) this.#release(node.runId);
777
852
  for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }