pi-extensible-workflows 1.0.0 → 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.
@@ -4,87 +4,80 @@ description: Use when the task is complex enough to require multiple subagents o
4
4
  ---
5
5
 
6
6
  # pi-extensible-workflows
7
-
8
- Use `workflow` exclusively for genuinely multi-agent orchestration. For one agent, use ordinary tools or `Agent` directly. Do not wrap a single agent in a workflow; define distinct responsibilities and keep the result flow explicit.
7
+ Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
9
8
 
10
9
  ## Pattern
11
-
12
10
  ```js
13
- const reportSchema = {
14
- type: "object",
15
- properties: {
16
- summary: { type: "string" },
17
- findings: { type: "array", items: { type: "string" } },
18
- },
19
- required: ["summary", "findings"],
20
- additionalProperties: false,
21
- };
11
+ const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
22
12
 
23
13
  const reports = await parallel("research", {
24
14
  first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
25
15
  second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
26
16
  });
27
17
 
28
- // Add a downstream agent only when synthesis or independent review is a real phase.
29
18
  return agent(
30
19
  prompt("Review these reports:\n\n{reports}", { reports }),
31
20
  { role: "reviewer", outputSchema: reportSchema },
32
21
  );
33
22
  ```
34
23
 
35
- To pass structured input from the main agent, include `args`:
24
+ Pass structured input from the main agent with `args`:
36
25
  ```json
37
26
  { "workflow": "workflowName", "args": { "issue": 42 } }
38
27
  ```
39
- Inside the workflow, read `args.issue`; omitted `args` is `null`.
40
- Use `workflow_stop` with the exact run ID to stop an active background run from the current Pi session.
41
- If `workflow_catalog` is available, call it once before creating the first workflow for a task. Use the returned global functions, variables, registered workflows, and configured model aliases as needed for the rest of that task. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
28
+ Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes; unnamed or missing names create new worktrees.
29
+ If `workflow_catalog` is available, call it once before creating the first workflow for a task. The name-less result is a compact index with launch-ready input schemas, descriptions, variables, and configured model aliases. Use those inputs to launch registered functions directly with `{ "workflow": "name", "args": { ... } }`; their input and output schemas are enforced. Request full detail with `{ "name": "name" }` only when composing a function programmatically or inspecting its output contract and extension metadata; do not load full definitions unconditionally. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
42
30
 
43
- Pass downstream only needed results. Workflow JavaScript has no imports, filesystem, network, process, or timers; delegate such work to agents with the required tools.
31
+ Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
44
32
 
45
- ## `agent()` options
33
+ Use a bounded verification loop when command output controls the gate:
34
+ ```js
35
+ return withWorktree("fix-tests", async ({ path, branch }) => {
36
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
37
+ const tests = await shell("yarn test", { env: { CI: "1" } });
38
+ if (tests.exitCode === 0) return { path, branch, tests };
39
+ await agent(prompt("Fix these failures:\n\n{output}", { output: tests.stderr || tests.stdout }));
40
+ }
41
+ return { path, branch, tests: await shell("yarn test", { env: { CI: "1" } }) };
42
+ });
43
+ ```
44
+ Shell results are journaled only after process exit and RPC validation. A host crash after side effects but before journaling can rerun the command on resume; use `shell()` mainly for verification and bounded gates, not exactly-once mutations.
46
45
 
46
+ ## `agent()` options
47
47
  ```typescript
48
48
  interface AgentOptions {
49
- label?: string; // optional non-empty display name
50
- model?: string; // configured alias or provider/model[:thinking]
51
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
52
- role?: string; // one of the available workflow roles
53
- tools?: string[]; // [] = no tools; omitted uses role or launch tools
54
- outputSchema?: JsonSchema;
55
- retries?: number; // non-negative; use for safe, repeatable work
56
- timeoutMs?: number | null; // positive milliseconds; null means unlimited
49
+ label?: string; model?: string; role?: string; tools?: string[];
50
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null;
57
51
  }
58
52
  ```
59
53
 
60
- Extensions may define additional JSON-compatible agent option keys such as `advisor: true`. Core-owned keys still use the validation and role constraints above; extension options are passed to setup hooks and native setup but are not inherited by child agents.
61
-
62
- Agent calls are unnamed. Direct `agent(...)` calls receive hidden source call-site identity; JavaScript aliases for workflow calls are unsupported. Calls from one source call site must not race outside `parallel` or `pipeline`, whose structural keys keep replay deterministic.
54
+ Extensions may add JSON-compatible agent options such as `advisor: true`; core keys retain validation and role constraints. Extension options go to setup hooks/native setup and are not inherited by child agents.
63
55
 
64
- ## Shared worktree scope
56
+ Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
65
57
 
66
- Use `withWorktree(callback)` or `withWorktree(name, callback)` when top-level agents should collaborate in one worktree:
58
+ ## Persistent conversations
59
+ Use `conversation(name, options)` when several agent turns should share a persisted transcript and build on one another. Unlike independent `agent(prompt, options)` calls, the returned handle continues from the last successful turn:
67
60
 
68
61
  ```js
69
- const results = await withWorktree("implementation", async () => parallel("implementation", {
70
- api: () => agent("Implement the API"),
71
- tests: () => agent("Add integration tests"),
72
- }));
62
+ const handle = conversation("developer", { role: "developer" });
63
+ const findings = await handle.run("Inspect the implementation.");
64
+ const fix = await handle.run("Now propose the smallest fix.");
65
+ return { findings, fix };
73
66
  ```
74
67
 
75
- The callback result is returned unchanged and the worktree is created only when the first enclosed agent launches. Concurrent agents share mutable files, so give them non-conflicting work or coordinate explicitly.
76
-
77
- `parallel()` tasks may call any workflow function, not only `agent()`:
68
+ Await each `handle.run(prompt, turnOptions)` call before starting the next one; conversation turns must be sequential and cannot overlap. Conversation creation accepts the same execution-policy options as `agent()`. `timeoutMs` and `retries` passed to `run()` are turn-local, so a failed turn does not advance the persisted conversation head.
78
69
 
70
+ ## Worktrees
71
+ Use `withWorktree(callback)` or `withWorktree(name, callback)` for top-level agents that collaborate in one worktree:
79
72
  ```js
80
- const results = await parallel("checks", {
81
- security: () => reviewRepository({ focus: "security" }),
82
- release: () => reviewRepository({ focus: "release readiness" }),
73
+ const result = await withWorktree("issue", async ({ path, branch }) => {
74
+ const report = await agent("Implement the issue");
75
+ return { path, branch, report };
83
76
  });
84
77
  ```
78
+ Entering the scope materializes its worktree before the callback. The callback receives a frozen reference containing only the real string `path` and `branch`; callbacks may ignore the argument, and their bare return value is preserved. Concurrent agents share mutable files, so assign non-conflicting work or coordinate explicitly.
85
79
 
86
- Use separate named scopes when each parallel branch needs its own worktree:
87
-
80
+ Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
88
81
  ```js
89
82
  const results = await parallel("implementation", {
90
83
  api: () => withWorktree("api", () => agent("Implement the API")),
@@ -92,22 +85,17 @@ const results = await parallel("implementation", {
92
85
  });
93
86
  ```
94
87
 
95
- Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
88
+ Registered extension functions receive `withWorktree` in context and can compose other registered functions with `context.invoke("reviewRepository", { focus: "security" })`. Their public inputs and outputs remain JSON; callbacks cannot cross the extension boundary.
96
89
 
97
90
  ## Rules
98
-
99
- - Do not create a workflow for one agent. Phases must have distinct work.
100
- - Use `log(messageString)` in the script to surface brief status messages to the operator.
101
- - A role owns its execution policy. When `role` is present, do not also set `model`, `thinking`, or `tools`; only task-specific options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
102
- - Use `parallel()` for independent tasks with different flows. Use `pipeline()` when each keyed item passes through the same ordered stages; do not duplicate identical stage chains inside `parallel()` branches.
103
- - Call shapes are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; object keys are stable task, item, and stage names.
104
- - Preserve item metadata in workflow code between pipeline stages instead of requiring agents to echo it through `outputSchema`.
105
- - Repeated work uses a JavaScript loop; each direct `agent(...)` call receives deterministic call-site and occurrence identity.
91
+ - Use `log(messageString)` for brief operator status.
92
+ - A role owns execution policy: with `role`, do not set `model`, `thinking`, or `tools`; only task options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
93
+ - Use `parallel()` for independent tasks with different flows and `pipeline()` when every keyed item follows the same ordered stages; do not duplicate identical chains in `parallel()`. Signatures are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; keys are stable task, item, and stage names.
94
+ - Preserve item metadata in workflow code between pipeline stages instead of making agents echo it through `outputSchema`.
95
+ - Use a JavaScript loop for repeated work; each direct `agent(...)` call gets deterministic call-site and occurrence identity.
106
96
  - Runs default to background; set tool-call `foreground: true` when asked to wait.
107
- - Add `budget` only when the run needs aggregate token, cost, duration, or launch limits; each dimension accepts optional `soft` and `hard` values. Soft crossings request wrap-up, while hard exhaustion blocks further budgeted work.
108
- - A `budget_exhausted` run is resumable through `workflow_resume`. Omitted patch values stay unchanged, explicit `null` removes a limit, and any relaxation requires an exact human-approved proposal through `workflow_respond`.
109
- - `parallel()` and `pipeline()` return keyed bare values. Await results before use.
110
- - Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings stay literal.
111
- - Use `outputSchema` only when another phase must compare, aggregate, or validate the result. Never add it to a final agent whose prose is returned directly. Keep only fields the consumer needs, and avoid repeating the same evidence in multiple schemas.
112
- - With `outputSchema`, agents must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an additional retry is justified and the work is idempotent.
113
- - Do not add "persona" specs to the prompt for agents. Just define the task.
97
+ - Add `budget` only for aggregate limits. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
98
+ - `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it.
99
+ - `parallel()` and `pipeline()` return keyed bare values; await them before use. Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings remain literal.
100
+ - Use `outputSchema` only when another phase compares, aggregates, or validates a result, never for final prose. Keep only consumer-needed fields and avoid repeated evidence. Agents with it must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an extra retry is justified and work is idempotent.
101
+ - Do not add persona specifications to agent prompts; define the task directly.
@@ -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;
@@ -192,6 +212,42 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
192
212
  }
193
213
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
194
214
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
215
+ function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
216
+ if (value === null || typeof value === "boolean" || typeof value === "string") return true;
217
+ if (typeof value === "number") return Number.isFinite(value);
218
+ if (typeof value !== "object" || seen.has(value)) return false;
219
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
220
+ const keys = Reflect.ownKeys(value);
221
+ if (keys.some((key) => typeof key !== "string")) return false;
222
+ seen.add(value);
223
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
224
+ seen.delete(value);
225
+ return valid;
226
+ }
227
+ function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
228
+ interface ChildAgentToolParams {
229
+ prompt: string;
230
+ label: string;
231
+ tools?: string[];
232
+ model?: string;
233
+ thinking?: ThinkingLevel;
234
+ role?: string;
235
+ outputSchema?: JsonSchema;
236
+ retries?: number;
237
+ timeoutMs?: number | null;
238
+ [key: string]: unknown;
239
+ }
240
+ function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams & Record<string, JsonValue> {
241
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string") return false;
242
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string"))) return false;
243
+ if (value.model !== undefined && typeof value.model !== "string") return false;
244
+ if (value.thinking !== undefined && !validThinking(value.thinking)) return false;
245
+ if (value.role !== undefined && typeof value.role !== "string") return false;
246
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
247
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
248
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1))) return false;
249
+ return true;
250
+ }
195
251
  function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
196
252
  const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
197
253
  const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
@@ -226,6 +282,21 @@ function conversationExecutionPolicy(options: AgentExecutionOptions, setup: Agen
226
282
  resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
227
283
  }) as unknown as JsonValue;
228
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
+ }
229
300
  function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
230
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 }> {
231
302
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -271,7 +342,7 @@ export class WorkflowAgentExecutor {
271
342
  const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
272
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})` : ""}`); }
273
344
  const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
274
- 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);
275
346
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
276
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})` : ""}`);
277
348
  return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
@@ -281,7 +352,8 @@ export class WorkflowAgentExecutor {
281
352
  const executionSignal = signal ?? this.root.runContext?.signal;
282
353
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
283
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");
284
- const resolved = this.resolve(options);
355
+ let resolved = this.resolve(options);
356
+ let recoveryModel: ModelSpec | undefined;
285
357
  let cwd: string;
286
358
  if (options.parent) {
287
359
  if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
@@ -306,15 +378,19 @@ export class WorkflowAgentExecutor {
306
378
  const store = this.root.runStore;
307
379
  if (!store) throw conversationFailure("Conversation persistence is unavailable");
308
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
+ }
309
385
  if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
310
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`);
311
387
  }
312
388
  const attempts: AgentAttempt[] = [];
313
389
  let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
314
- const maxAttempts = (options.retries ?? 0) + 1;
390
+ let maxAttempts = (options.retries ?? 0) + 1;
315
391
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
392
+ if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
316
393
  options.budget?.beforeAttempt();
317
- let accepted = false;
318
394
  let schemaResult: JsonValue | undefined;
319
395
  let session: NativeSession | undefined;
320
396
  let setup: AgentSetup | undefined;
@@ -330,8 +406,8 @@ export class WorkflowAgentExecutor {
330
406
  const resultTool = options.schema ? {
331
407
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
332
408
  async execute(_id: string, value: unknown) {
333
- if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
334
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 };
335
411
  schemaResult = structuredClone(value) as JsonValue;
336
412
  void session?.abort?.();
337
413
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
@@ -360,6 +436,7 @@ export class WorkflowAgentExecutor {
360
436
  setupSummary = prepared.summary;
361
437
  setupFailed = false;
362
438
  if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
439
+ if (recoveryModel && conversationRecord) setup.sessionInput.allowModelChange = true;
363
440
  const started = Date.now();
364
441
  session = await setup.createSession(setup.sessionInput);
365
442
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
@@ -369,12 +446,12 @@ export class WorkflowAgentExecutor {
369
446
  const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
370
447
  if (conversationRecord) {
371
448
  if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
372
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
373
- 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");
374
451
  if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
375
452
  if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
376
453
  } else if (conversationBaseline) {
377
- 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");
378
455
  if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
379
456
  } else {
380
457
  conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
@@ -413,7 +490,7 @@ export class WorkflowAgentExecutor {
413
490
  activity = undefined;
414
491
  if (event.message.role === "assistant") {
415
492
  const needsMoreWork = hasToolCall(event.message);
416
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
493
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
417
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?.(); } }
418
495
  turnStarted = false;
419
496
  report(true);
@@ -432,18 +509,19 @@ export class WorkflowAgentExecutor {
432
509
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
433
510
  options.budget?.beforeTurn();
434
511
  turnStarted = true;
435
- 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; }
436
513
  if (conversationMismatch) throw conversationMismatch;
437
- throwIfTerminalAssistantError(session);
438
- { 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; }
439
516
  if (budgetError) throw budgetError;
440
517
  if (options.schema) {
441
- accepted = true;
442
- 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; }
443
- 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);
444
522
  if (!hasSchemaResult()) {
445
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; }
446
- throwIfTerminalAssistantError(session);
524
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
447
525
  }
448
526
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
449
527
  }
@@ -481,6 +559,22 @@ export class WorkflowAgentExecutor {
481
559
  session.dispose();
482
560
  }
483
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
+ }
484
578
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
485
579
  beforeRetry?.();
486
580
  }
@@ -640,17 +734,35 @@ export class FairAgentScheduler {
640
734
  if (nodes.every(({ restored }) => restored)) run.logical = 0;
641
735
  }
642
736
 
643
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
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
+
644
753
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
645
754
  const parent = this.#node(parentId);
646
755
  if (!parent.options.tools.includes("agent")) return [];
647
756
  const agentTool = {
648
757
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
649
758
  parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
650
- execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null; [key: string]: unknown }) => {
759
+ execute: async (_id: string, rawParams: unknown) => {
760
+ if (!isChildAgentToolParams(rawParams)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
761
+ const params = rawParams;
651
762
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
652
763
  const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
653
- const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt")) as Record<string, JsonValue>;
764
+ const agentOptions = { ...params };
765
+ Reflect.deleteProperty(agentOptions, "prompt");
654
766
  const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
655
767
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
656
768
  return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
@@ -694,14 +806,14 @@ export class FairAgentScheduler {
694
806
 
695
807
  #inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
696
808
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
697
- if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
809
+ const inheritedTools: readonly string[] = options.tools;
810
+ if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
698
811
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
699
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
812
+ const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
700
813
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
701
814
  const identity = options.agentIdentity ?? parent.options.agentIdentity;
702
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
815
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
703
816
  }
704
- /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
705
817
 
706
818
  #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
707
819
 
@@ -734,6 +846,7 @@ export class FairAgentScheduler {
734
846
  if (["completed", "failed", "cancelled"].includes(node.state)) return;
735
847
  const heldPermit = node.state === "running" || node.state === "retrying";
736
848
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
849
+ Reflect.deleteProperty(node, "steer");
737
850
  this.#persist(node.runId);
738
851
  if (heldPermit) this.#release(node.runId);
739
852
  for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }
@@ -1,12 +1,13 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
9
- import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
9
+ import { isObject } from "./index.js";
10
+ import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
10
11
 
11
12
  export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
12
13
  export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
@@ -327,26 +328,13 @@ function ambientAgentDir(environment: NodeJS.ProcessEnv): string {
327
328
  return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
328
329
  }
329
330
 
330
- function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
331
-
332
- function findSessionFile(directory: string, sessionId: string): string | undefined {
333
- if (!existsSync(directory)) return undefined;
334
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
335
- const path = join(directory, entry.name);
336
- if (entry.isDirectory()) { const found = findSessionFile(path, sessionId); if (found) return found; }
337
- else if (entry.name.endsWith(".jsonl")) {
338
- try { const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}") as unknown; if (isObject(header) && header.id === sessionId) return path; } catch { /* Ignore incomplete sessions. */ }
339
- }
340
- }
341
- return undefined;
342
- }
343
-
344
- function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResult {
331
+ type AmbientCaseResultDraft = Omit<AmbientCaseResult, "manifest"> & { manifest: Omit<AmbientManifest, "gitStatusAfter"> };
332
+ function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResultDraft {
345
333
  const accounting = emptyAccounting();
346
334
  return {
347
335
  id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
348
336
  manifest: {
349
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
337
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
350
338
  cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
351
339
  },
352
340
  };
@@ -356,12 +344,13 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
356
344
  const worktree = createAmbientCaseWorktree(repository, candidate.id);
357
345
  const sessionId = randomUUID();
358
346
  const sessionDir = join(repository.root, "sessions", candidate.id);
359
- let result: AmbientCaseResult | undefined;
360
- let gitStatusAfter: readonly string[] = worktree.gitStatusBefore;
347
+ let result: AmbientCaseResultDraft | undefined;
348
+ let failure: string | undefined;
349
+ let gitStatusAfter: readonly string[];
361
350
  try {
362
351
  const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
363
352
  const sessionFile = findSessionFile(sessionDir, sessionId);
364
- if (!sessionFile) result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
353
+ if (!sessionFile) failure = "Ambient parent session was not written.";
365
354
  else {
366
355
  const oracle = extractParentOracleFile(sessionFile);
367
356
  const workflows = extractCapturedWorkflows(oracle);
@@ -371,24 +360,25 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
371
360
  result = {
372
361
  id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
373
362
  manifest: {
374
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
363
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
375
364
  cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
376
365
  },
377
366
  };
378
367
  }
379
368
  } catch (error) {
380
- result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
369
+ failure = error instanceof Error ? error.message : String(error);
381
370
  } finally {
382
371
  try { gitStatusAfter = gitStatus(worktree.path); } catch { gitStatusAfter = ["<unavailable>"]; }
383
372
  const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
384
- if (!result) result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
373
+ if (!result) result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
385
374
  const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
386
- result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
387
- writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
375
+ result = { ...result, manifest: { ...result.manifest, cleanup } };
388
376
  }
389
- return result;
390
- }
391
377
 
378
+ const finalized: AmbientCaseResult = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
379
+ writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
380
+ return finalized;
381
+ }
392
382
  export function assertAmbientOptIn(environment: NodeJS.ProcessEnv = process.env): void {
393
383
  if (environment[AMBIENT_OPT_IN] !== "1") throw new Error(`Ambient Tier D evals are opt-in. Set ${AMBIENT_OPT_IN}=1 to run them.`);
394
384
  }