pi-workflow-engine 0.11.0 → 0.12.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.
Files changed (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. package/package.json +1 -1
@@ -2,7 +2,8 @@ import type { Skill } from "@earendil-works/pi-coding-agent";
2
2
  import { Value } from "typebox/value";
3
3
  import { captureAgentSkillIdentities, extractSkillSelectorsFromText } from "./agent-skills.ts";
4
4
  import type { AgentExecutionOptions, AgentRunnerSession, AgentRunTags, RunContext } from "./agent-runner-types.ts";
5
- import { captureEffectiveSession, FINAL_TOOL } from "./agent-session.ts";
5
+ import { captureEffectiveAgentSessionIdentity } from "./agent-session-identity.ts";
6
+ import { FINAL_TOOL } from "./agent-session.ts";
6
7
  import type { AgentWorkspace, IsolatedAgentWorkspace } from "./agent-workspace.ts";
7
8
  import { captureAgentJournalKey } from "./journal.ts";
8
9
  import { unknownErrorMessage } from "./unknown-error.ts";
@@ -116,7 +117,9 @@ export async function lookupReplayResult(input: {
116
117
  readonly opts: AgentExecutionOptions;
117
118
  readonly workspace: AgentWorkspace;
118
119
  }): Promise<{ readonly hit: true; readonly result: unknown } | { readonly hit: false; readonly reason?: string }> {
119
- const cached = input.rc.journal.lookup(input.key, input.identity);
120
+ const cached = input.rc.journal.lookup(input.key, input.identity, {
121
+ allowWorkflowSourceMismatch: input.rc.resumeEditedWorkflow,
122
+ });
120
123
  if (!cached.hit) return cached;
121
124
  const validation = await validateCachedResult(cached.value, input.opts, input.rc, input.workspace);
122
125
  return validation.ok
@@ -147,7 +150,9 @@ export async function validateReplayIdentity(input: {
147
150
  sessionCwd: input.sessionCwd,
148
151
  });
149
152
  if (current.kind === "unverifiable") return { ok: false, reason: current.reason };
150
- const mismatch = resumeContextMismatchReason(input.identity, current.identity);
153
+ const mismatch = resumeContextMismatchReason(input.identity, current.identity, {
154
+ allowWorkflowSourceMismatch: input.rc.resumeEditedWorkflow,
155
+ });
151
156
  return mismatch ? { ok: false, reason: mismatch } : { ok: true };
152
157
  }
153
158
 
@@ -160,6 +165,7 @@ export async function settleAgentAttempt(input: {
160
165
  }): Promise<AgentAttemptSettlement> {
161
166
  const { rc, label, replay, outcome } = input;
162
167
  if (outcome.kind === "live-unrecordable") {
168
+ recordAgentResultSource(rc, "live");
163
169
  return { kind: "done", result: outcome.result };
164
170
  }
165
171
  if (!isReplayEnabled(replay)) throw new Error("Replayable agent outcome produced without an enabled replay plan.");
@@ -183,10 +189,23 @@ export async function settleAgentAttempt(input: {
183
189
  if (outcome.kind === "cache-hit") {
184
190
  rc.progress.log(`${label}: using cached result from workflow journal`);
185
191
  rc.perf.counter("agent.cache_hit", 1, input.tags);
192
+ recordAgentResultSource(rc, "cached");
193
+ } else {
194
+ recordAgentResultSource(rc, "live");
186
195
  }
187
196
  return { kind: "done", result: outcome.result };
188
197
  }
189
198
 
199
+ function recordAgentResultSource(rc: RunContext, source: "cached" | "live"): void {
200
+ if (!rc.resumeEditedWorkflow) return;
201
+ rc.progress.event({
202
+ type: "counter_delta",
203
+ key: `resume.${source}`,
204
+ label: source === "cached" ? "Cached agents" : "Live agents",
205
+ delta: 1,
206
+ });
207
+ }
208
+
190
209
  async function captureVerifiedReplayIdentity(input: {
191
210
  readonly rc: RunContext;
192
211
  readonly repository: VerifiedRepositoryResumeContext;
@@ -201,7 +220,11 @@ async function captureVerifiedReplayIdentity(input: {
201
220
  workspaceRoot: input.rc.cwd,
202
221
  signal: input.rc.signal,
203
222
  }),
204
- captureEffectiveSession(input.session, input.sessionCwd, input.rc.cwd, input.rc.signal),
223
+ captureEffectiveAgentSessionIdentity(input.session, {
224
+ sessionCwd: input.sessionCwd,
225
+ workspaceRoot: input.rc.cwd,
226
+ signal: input.rc.signal,
227
+ }),
205
228
  ]);
206
229
  if (skills.kind === "unverifiable") return skills;
207
230
  if (effectiveSession.kind === "unverifiable") return effectiveSession;
@@ -0,0 +1,116 @@
1
+ import { isRetryableAssistantError, type AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { abortReason, throwIfAborted } from "./cancellation.ts";
3
+ import {
4
+ providerUsageLimitFromMessages,
5
+ type WorkflowProviderUsageLimitError,
6
+ } from "./provider-usage-limit.ts";
7
+
8
+ export const AGENT_RETRY_BASE_DELAY_MS = 1_000;
9
+ export const AGENT_RETRY_MAX_DELAY_MS = 30_000;
10
+ export const WORKFLOW_PROVIDER_ERROR_CODE = "WORKFLOW_PROVIDER_ERROR";
11
+
12
+ export interface AgentRetryScheduler {
13
+ sleep(delayMs: number, signal: AbortSignal | undefined): Promise<void>;
14
+ }
15
+
16
+ export interface ProviderErrorDetails {
17
+ readonly stopReason: "error";
18
+ readonly retryable: boolean;
19
+ readonly provider?: string;
20
+ readonly model?: string;
21
+ readonly api?: string;
22
+ }
23
+
24
+ /** Provider failure reconstructed from pi's terminal assistant-message metadata. */
25
+ export class WorkflowProviderError extends Error {
26
+ override readonly name = "WorkflowProviderError";
27
+ readonly code = WORKFLOW_PROVIDER_ERROR_CODE;
28
+
29
+ constructor(
30
+ message: string,
31
+ readonly details: ProviderErrorDetails,
32
+ ) {
33
+ super(message);
34
+ }
35
+
36
+ get retryable(): boolean {
37
+ return this.details.retryable;
38
+ }
39
+
40
+ toJSON(): {
41
+ readonly name: string;
42
+ readonly message: string;
43
+ readonly code: string;
44
+ readonly details: ProviderErrorDetails;
45
+ } {
46
+ return { name: this.name, message: this.message, code: this.code, details: this.details };
47
+ }
48
+ }
49
+
50
+ export const defaultAgentRetryScheduler: AgentRetryScheduler = {
51
+ async sleep(delayMs, signal) {
52
+ throwIfAborted(signal);
53
+ await new Promise<void>((resolve, reject) => {
54
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
55
+ const timer = setTimeout(() => {
56
+ cleanup();
57
+ resolve();
58
+ }, delayMs);
59
+ const onAbort = () => {
60
+ clearTimeout(timer);
61
+ cleanup();
62
+ reject(abortReason(signal));
63
+ };
64
+ signal?.addEventListener("abort", onAbort, { once: true });
65
+ if (signal?.aborted) onAbort();
66
+ });
67
+ },
68
+ };
69
+
70
+ export function agentRetryDelayMs(retryAttempt: number): number {
71
+ return Math.min(AGENT_RETRY_MAX_DELAY_MS, AGENT_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryAttempt - 1));
72
+ }
73
+
74
+ export function providerErrorFromMessages(
75
+ messages: readonly unknown[],
76
+ options: { readonly pauseOnUsageLimit?: boolean } = {},
77
+ ): WorkflowProviderError | WorkflowProviderUsageLimitError | undefined {
78
+ const usageLimit = providerUsageLimitFromMessages(messages);
79
+ if (usageLimit && options.pauseOnUsageLimit) return usageLimit;
80
+ const message = messages.findLast(isAssistantMessage);
81
+ if (!message || message.stopReason !== "error") return undefined;
82
+ const errorMessage = typeof message.errorMessage === "string" && message.errorMessage.length > 0
83
+ ? message.errorMessage
84
+ : "Provider session ended with an unspecified error.";
85
+ const retryable = isRetryableAssistantError(message as AssistantMessage);
86
+ const provider = stringDetail(message.provider);
87
+ const model = stringDetail(message.model);
88
+ const api = stringDetail(message.api);
89
+ return new WorkflowProviderError(errorMessage, {
90
+ stopReason: "error",
91
+ retryable,
92
+ ...(provider ? { provider } : {}),
93
+ ...(model ? { model } : {}),
94
+ ...(api ? { api } : {}),
95
+ });
96
+ }
97
+
98
+ function isAssistantMessage(value: unknown): value is {
99
+ readonly role: "assistant";
100
+ readonly stopReason?: unknown;
101
+ readonly errorMessage?: unknown;
102
+ readonly provider?: unknown;
103
+ readonly model?: unknown;
104
+ readonly api?: unknown;
105
+ } {
106
+ return (
107
+ typeof value === "object" &&
108
+ value !== null &&
109
+ "role" in value &&
110
+ value.role === "assistant"
111
+ );
112
+ }
113
+
114
+ function stringDetail(value: unknown): string | undefined {
115
+ return typeof value === "string" && value.length > 0 ? value : undefined;
116
+ }
@@ -1,52 +1,37 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
- import type { CreateAgentSessionOptions, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentSession,
4
+ CreateAgentSessionOptions,
5
+ ModelRegistry,
6
+ } from "@earendil-works/pi-coding-agent";
3
7
  import type { WorkflowBudget } from "./budget.ts";
4
8
  import type { Semaphore } from "./concurrency.ts";
9
+ import type { WorkflowAgentLimiter } from "./agent-limits.ts";
10
+ import type { AgentRetryScheduler } from "./agent-retry.ts";
11
+ import type { ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
5
12
  import type { WorkflowJournal } from "./journal.ts";
6
13
  import type { PerfSink } from "./perf.ts";
7
- import type { AgentOptions } from "./types.ts";
14
+ import type { AgentOptions, WorkflowProgressEvent } from "./types.ts";
8
15
  import type { WorkflowUsageSink } from "./usage.ts";
9
16
  import type { WorktreeBaseline, WorktreeRegistry } from "./worktree.ts";
10
- import type {
11
- EffectiveAgentModelLike,
12
- EffectiveToolDefinitionLike,
13
- } from "./agent-session-identity.ts";
14
-
15
- export interface AgentRunnerEvent {
16
- readonly type: string;
17
- readonly toolName?: string;
18
- }
19
-
20
- export interface AgentRunnerToolInfo {
21
- readonly name: string;
22
- readonly description?: string;
23
- readonly parameters?: unknown;
24
- readonly promptGuidelines?: readonly string[];
25
- readonly sourceInfo?: {
26
- readonly path: string;
27
- readonly source: string;
28
- readonly scope: string;
29
- readonly origin: string;
30
- readonly baseDir?: string;
31
- };
32
- }
33
17
 
34
- export interface AgentRunnerSession {
35
- readonly state: {
36
- readonly messages: readonly unknown[];
37
- readonly systemPrompt?: string;
38
- readonly model?: EffectiveAgentModelLike;
39
- readonly thinkingLevel?: string;
40
- };
41
- prompt(text: string): Promise<void>;
42
- subscribe(listener: (event: AgentRunnerEvent) => void): () => void;
43
- dispose(): void;
44
- abort(): Promise<void>;
45
- getAllTools?(): readonly AgentRunnerToolInfo[];
46
- getActiveToolNames?(): readonly string[];
47
- getToolDefinition?(name: string): EffectiveToolDefinitionLike | undefined;
48
- setActiveToolsByName?(toolNames: readonly string[]): void;
49
- }
18
+ export type AgentRunnerSession = Pick<
19
+ AgentSession,
20
+ | "messages"
21
+ | "systemPrompt"
22
+ | "model"
23
+ | "thinkingLevel"
24
+ | "prompt"
25
+ | "subscribe"
26
+ | "dispose"
27
+ | "abort"
28
+ | "getAllTools"
29
+ | "getActiveToolNames"
30
+ | "getToolDefinition"
31
+ | "setActiveToolsByName"
32
+ | "setAutoRetryEnabled"
33
+ | "getLastAssistantText"
34
+ >;
50
35
 
51
36
  export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
52
37
 
@@ -56,15 +41,21 @@ export interface AgentProgress {
56
41
  agentTool(label: string, tool: string, id?: number): void;
57
42
  agentDone(label: string, id?: number): void;
58
43
  agentFailed(label: string, error: unknown, id?: number): void;
44
+ event(event: WorkflowProgressEvent): void;
59
45
  log(message: string): void;
60
46
  }
61
47
 
62
- /** Shared per-run context threaded into every agent() call. */
63
- export interface RunContext {
48
+ interface RunContextBase {
64
49
  cwd: string;
65
50
  hostModel: Model<Api> | undefined;
66
- modelRegistry: Pick<ModelRegistry, "find">;
67
51
  semaphore: Semaphore;
52
+ agentLimiter: WorkflowAgentLimiter;
53
+ agentTimeoutMs: number;
54
+ agentRetries: number;
55
+ pauseOnProviderUsageLimit?: boolean;
56
+ resumeEditedWorkflow?: boolean;
57
+ retryScheduler: AgentRetryScheduler;
58
+ modelProfiles: ResolvedWorkflowModelProfiles;
68
59
  progress: AgentProgress;
69
60
  signal: AbortSignal | undefined;
70
61
  perf: PerfSink;
@@ -72,9 +63,22 @@ export interface RunContext {
72
63
  budget: WorkflowBudget;
73
64
  journal: WorkflowJournal;
74
65
  worktrees: WorktreeRegistry;
75
- createSession?: CreateAgentSession;
76
66
  }
77
67
 
68
+ /** Shared per-run context threaded into every agent() call. */
69
+ export type RunContext = RunContextBase & (
70
+ | {
71
+ /** Production sessions inherit the host's live provider and auth state. */
72
+ modelRegistry: ModelRegistry;
73
+ createSession?: undefined;
74
+ }
75
+ | {
76
+ /** Injected test sessions bypass Pi's resource loader and therefore do not resolve skills. */
77
+ modelRegistry: Pick<ModelRegistry, "find">;
78
+ createSession: CreateAgentSession;
79
+ }
80
+ );
81
+
78
82
  /** Runtime-only options. The authored WorkflowApi exposes only AgentOptions. */
79
83
  export type AgentExecutionOptions = AgentOptions & {
80
84
  readonly worktreeBaseline?: WorktreeBaseline;
@@ -1,10 +1,12 @@
1
1
  import { assertWorkflowBudgetAvailable } from "./budget.ts";
2
- import { throwIfAborted } from "./cancellation.ts";
2
+ import { WorkflowAgentTimeoutError } from "./agent-limits.ts";
3
+ import { abortReason, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
3
4
  import { executeAgentAttempt } from "./agent-attempt.ts";
4
5
  import {
5
6
  createAgentReplayPlan,
6
7
  isReplayEnabled,
7
8
  settleAgentAttempt,
9
+ type AgentAttemptResult,
8
10
  type AgentReplayPlan,
9
11
  } from "./agent-replay.ts";
10
12
  import {
@@ -18,13 +20,16 @@ import type {
18
20
  } from "./agent-runner-types.ts";
19
21
  import type { AgentResumeBaseContext } from "./resume-context.ts";
20
22
  import { unknownErrorMessage } from "./unknown-error.ts";
23
+ import {
24
+ agentRetryDelayMs,
25
+ WorkflowProviderError,
26
+ } from "./agent-retry.ts";
27
+ import { resolveAgentModelProfile } from "./model-profiles.ts";
21
28
 
22
29
  export type {
23
30
  AgentExecutionOptions,
24
31
  AgentProgress,
25
- AgentRunnerEvent,
26
32
  AgentRunnerSession,
27
- AgentRunnerToolInfo,
28
33
  CreateAgentSession,
29
34
  RunContext,
30
35
  } from "./agent-runner-types.ts";
@@ -35,7 +40,7 @@ export type { ResolvedAgentModel, ResolvedAgentModelRequest } from "./agent-sess
35
40
  * Run one subagent to completion in an isolated in-memory session.
36
41
  *
37
42
  * Session construction, replay validation, and workspace cleanup live in focused
38
- * modules; this boundary owns only admission, progress, and the one cache-to-live retry.
43
+ * modules; this boundary owns admission, progress, and bounded cache/provider retries.
39
44
  */
40
45
  export async function runAgent(
41
46
  rc: RunContext,
@@ -53,35 +58,63 @@ export async function runAgent(
53
58
 
54
59
  return await rc.perf.time("agent.total_ms", async () => {
55
60
  throwIfAborted(rc.signal);
56
- const model = resolveModel(rc, opts, label);
57
- const replay = createAgentReplayPlan(prompt, opts);
61
+ const routing = resolveAgentRouting(rc, opts, label);
62
+ const effectiveOpts = routing.thinkingLevel === opts.thinkingLevel
63
+ ? opts
64
+ : { ...opts, thinkingLevel: routing.thinkingLevel };
65
+ const replay = createAgentReplayPlan(prompt, effectiveOpts);
58
66
  if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(rc.budget);
59
67
 
60
68
  const rowId = rc.progress.agentQueued(opts.phase, label);
69
+ const liveScope = createAgentLiveScope(rc, label);
61
70
  try {
62
71
  return await rc.semaphore.run(
63
72
  async () => {
64
- throwIfAborted(rc.signal);
65
- if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(rc.budget);
73
+ const agentRc: RunContext = { ...rc, signal: liveScope.signal };
74
+ throwIfAborted(agentRc.signal);
75
+ if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(agentRc.budget);
66
76
  rc.progress.agentStart(opts.phase, label, rowId);
67
77
  if (replay.kind === "disabled") {
68
78
  rc.progress.log(`${label}: resume disabled for this call (${replay.reason})`);
69
79
  }
70
80
 
71
81
  let attemptPlan: AgentReplayPlan = replay;
82
+ let providerRetries = 0;
72
83
  while (true) {
73
- const outcome = await executeAgentAttempt({
74
- rc,
75
- prompt,
76
- opts,
77
- resumeBaseContext,
78
- model,
79
- replay: attemptPlan,
80
- label,
81
- rowId,
82
- tags,
83
- });
84
- const settlement = await settleAgentAttempt({ rc, label, tags, replay: attemptPlan, outcome });
84
+ let outcome: AgentAttemptResult;
85
+ try {
86
+ outcome = await executeAgentAttempt({
87
+ rc: agentRc,
88
+ prompt,
89
+ opts: effectiveOpts,
90
+ resumeBaseContext,
91
+ model: routing.model,
92
+ replay: attemptPlan,
93
+ label,
94
+ rowId,
95
+ tags,
96
+ admitLiveAgent: liveScope.admit,
97
+ });
98
+ } catch (error) {
99
+ if (!(error instanceof WorkflowProviderError) || !error.retryable) throw error;
100
+ if (providerRetries >= agentRc.agentRetries) {
101
+ if (agentRc.agentRetries > 0) {
102
+ rc.progress.log(`${label}: transient provider failure after ${providerRetries} retries; giving up`);
103
+ }
104
+ throw error;
105
+ }
106
+
107
+ assertWorkflowBudgetAvailable(agentRc.budget);
108
+ providerRetries++;
109
+ const delayMs = agentRetryDelayMs(providerRetries);
110
+ rc.progress.log(`${label}: transient provider failure; retry ${providerRetries}/${agentRc.agentRetries} in ${delayMs}ms`);
111
+ rc.perf.counter("agent.provider_retry", 1, tags);
112
+ rc.perf.observe("agent.provider_retry_delay_ms", delayMs, tags);
113
+ attemptPlan = { kind: "off" };
114
+ await agentRc.retryScheduler.sleep(delayMs, agentRc.signal);
115
+ continue;
116
+ }
117
+ const settlement = await settleAgentAttempt({ rc: agentRc, label, tags, replay: attemptPlan, outcome });
85
118
  if (settlement.kind === "retry-live") {
86
119
  attemptPlan = { kind: "off" };
87
120
  continue;
@@ -96,20 +129,56 @@ export async function runAgent(
96
129
  },
97
130
  );
98
131
  } catch (error) {
99
- rc.progress.agentFailed(label, error, rowId);
100
- rc.progress.log(`${label} failed: ${unknownErrorMessage(error)}`);
101
- throw error;
132
+ let failure = error;
133
+ if (liveScope.signal.aborted) failure = abortReason(liveScope.signal);
134
+ if (rc.signal?.aborted) failure = abortReason(rc.signal);
135
+ rc.progress.agentFailed(label, failure, rowId);
136
+ rc.progress.log(`${label} failed: ${unknownErrorMessage(failure)}`);
137
+ throw failure;
138
+ } finally {
139
+ liveScope.dispose();
102
140
  }
103
141
  }, tags);
104
142
  }
105
143
 
106
- function resolveModel(
144
+ function createAgentLiveScope(rc: RunContext, label: string) {
145
+ const controller = new AbortController();
146
+ const unlink = linkAbortSignal(rc.signal, controller);
147
+ let timer: ReturnType<typeof setTimeout> | undefined;
148
+ let timerStarted = false;
149
+
150
+ return {
151
+ signal: controller.signal,
152
+ admit() {
153
+ rc.agentLimiter.admit(controller.signal);
154
+ if (timerStarted) return;
155
+ timerStarted = true;
156
+ timer = setTimeout(
157
+ () => controller.abort(new WorkflowAgentTimeoutError(label, rc.agentTimeoutMs)),
158
+ rc.agentTimeoutMs,
159
+ );
160
+ },
161
+ dispose() {
162
+ if (timer) clearTimeout(timer);
163
+ unlink();
164
+ },
165
+ };
166
+ }
167
+
168
+ function resolveAgentRouting(
107
169
  rc: RunContext,
108
170
  opts: AgentExecutionOptions,
109
171
  label: string,
110
- ): ResolvedAgentModel["model"] {
172
+ ): { readonly model: ResolvedAgentModel["model"]; readonly thinkingLevel: AgentExecutionOptions["thinkingLevel"] } {
111
173
  try {
112
- return resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel).model;
174
+ return resolveAgentModelProfile(
175
+ {
176
+ request: opts,
177
+ profiles: rc.modelProfiles,
178
+ resolveExplicitModel: (modelRef) => resolveAgentModel(modelRef, rc.modelRegistry, rc.hostModel).model,
179
+ hostModel: rc.hostModel,
180
+ },
181
+ );
113
182
  } catch (error) {
114
183
  rc.progress.agentFailed(label, error);
115
184
  throw error;
@@ -0,0 +1,50 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+
4
+ type HostProviderRegistry = Pick<
5
+ ModelRegistry,
6
+ | "getApiKeyForProvider"
7
+ | "getProviderAuthStatus"
8
+ | "getRegisteredProviderConfig"
9
+ | "getRegisteredProviderIds"
10
+ | "isUsingOAuth"
11
+ >;
12
+
13
+ /** Mirror the host's live provider and selected-model auth state into a child runtime. */
14
+ export async function synchronizeWorkflowModelRuntime(input: {
15
+ readonly host: HostProviderRegistry;
16
+ readonly child: ModelRuntime;
17
+ readonly selectedModel: Model<Api> | undefined;
18
+ readonly removeChildOnlyProviders: boolean;
19
+ }): Promise<void> {
20
+ const { host, child, selectedModel, removeChildOnlyProviders } = input;
21
+ const hostProviderIds = new Set(host.getRegisteredProviderIds());
22
+
23
+ if (removeChildOnlyProviders) {
24
+ for (const providerId of child.getRegisteredProviderIds()) {
25
+ if (!hostProviderIds.has(providerId)) child.unregisterProvider(providerId);
26
+ }
27
+ }
28
+
29
+ for (const providerId of hostProviderIds) {
30
+ const config = host.getRegisteredProviderConfig(providerId);
31
+ if (!config) continue;
32
+ child.unregisterProvider(providerId);
33
+ child.registerProvider(providerId, config);
34
+ }
35
+
36
+ const selectedProvider = selectedModel?.provider;
37
+ if (!selectedModel || !selectedProvider) return;
38
+
39
+ const hostAuth = host.getProviderAuthStatus(selectedProvider);
40
+ const childAuth = child.getProviderAuthStatus(selectedProvider);
41
+ if (hostAuth.configured && !childAuth.configured && host.isUsingOAuth(selectedModel)) {
42
+ throw new Error(
43
+ `Workflow subagents cannot inherit OAuth credentials for "${selectedProvider}" from a host-only credential store; configure OAuth in Pi's shared agent directory.`,
44
+ );
45
+ }
46
+ if (hostAuth.source === "runtime" || (hostAuth.configured && !childAuth.configured)) {
47
+ const apiKey = await host.getApiKeyForProvider(selectedProvider);
48
+ if (apiKey !== undefined) await child.setRuntimeApiKey(selectedProvider, apiKey);
49
+ }
50
+ }