pi-extensible-workflows 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +72 -0
  2. package/dist/src/agent-execution.d.ts +213 -0
  3. package/dist/src/agent-execution.js +537 -0
  4. package/dist/src/ambient-workflow-evals.d.ts +112 -0
  5. package/dist/src/ambient-workflow-evals.js +375 -0
  6. package/dist/src/cli.d.ts +6 -0
  7. package/dist/src/cli.js +26 -0
  8. package/dist/src/doctor.d.ts +59 -0
  9. package/dist/src/doctor.js +269 -0
  10. package/dist/src/eval-capture-extension.d.ts +5 -0
  11. package/dist/src/eval-capture-extension.js +48 -0
  12. package/dist/src/index.d.ts +362 -0
  13. package/dist/src/index.js +2839 -0
  14. package/dist/src/persistence.d.ts +90 -0
  15. package/dist/src/persistence.js +530 -0
  16. package/dist/src/session-inspector.d.ts +59 -0
  17. package/dist/src/session-inspector.js +396 -0
  18. package/dist/src/workflow-evals-child.d.ts +1 -0
  19. package/dist/src/workflow-evals-child.js +13 -0
  20. package/dist/src/workflow-evals.d.ts +290 -0
  21. package/dist/src/workflow-evals.js +1221 -0
  22. package/evals/cases/custom-model-read.yaml +15 -0
  23. package/evals/cases/direct-answer.yaml +6 -0
  24. package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
  25. package/evals/cases/output-schema.yaml +22 -0
  26. package/evals/cases/parallel.yaml +15 -0
  27. package/evals/cases/pipeline.yaml +10 -0
  28. package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
  29. package/evals/cases/required-role.yaml +14 -0
  30. package/evals/cases/role-model-mixed.yaml +18 -0
  31. package/evals/cases/two-agents.yaml +10 -0
  32. package/package.json +65 -0
  33. package/skills/pi-extensible-workflows/SKILL.md +109 -0
  34. package/src/agent-execution.ts +529 -0
  35. package/src/ambient-workflow-evals.ts +452 -0
  36. package/src/cli.ts +23 -0
  37. package/src/doctor.ts +285 -0
  38. package/src/eval-capture-extension.ts +54 -0
  39. package/src/index.ts +2519 -0
  40. package/src/persistence.ts +470 -0
  41. package/src/session-inspector.ts +370 -0
  42. package/src/workflow-evals-child.ts +15 -0
  43. package/src/workflow-evals.ts +876 -0
  44. package/test/fixtures/ready-for-agent-tasks.md +9 -0
  45. package/test/fixtures/workflow-eval-roles/developer.md +18 -0
  46. package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
  47. package/test/fixtures/workflow-eval-roles/scout.md +17 -0
@@ -0,0 +1,529 @@
1
+ import { join } from "node:path";
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import { Value } from "typebox/value";
4
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, type AgentSessionEvent, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
6
+ type AgentMessage = { role: string; content?: unknown; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
7
+ import type { JsonSchema, JsonValue, ModelSpec } from "./index.js";
8
+ import { parseModelReference, WorkflowError } from "./index.js";
9
+ import type { RunStore } from "./persistence.js";
10
+
11
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[] }
12
+ export interface AgentExecutionOptions {
13
+ label: string;
14
+ workflowName: string;
15
+ phase?: string;
16
+ parent?: string;
17
+ model?: string;
18
+ thinking?: ThinkingLevel;
19
+ onProgress?: (progress: AgentProgress) => void | Promise<void>;
20
+ onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => void | Promise<void>;
21
+ tools?: readonly string[];
22
+ effectiveTools?: readonly string[];
23
+ role?: string;
24
+ schema?: JsonSchema;
25
+ retries?: number;
26
+ timeoutMs?: number | null;
27
+ retryState?: string;
28
+ worktreeOwner?: string;
29
+ cwd?: string;
30
+ }
31
+ export interface AgentExecutionRoot {
32
+ cwd: string;
33
+ model: ModelSpec;
34
+ tools: ReadonlySet<string>;
35
+ agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
36
+ agentDir?: string;
37
+ availableModels?: ReadonlySet<string>;
38
+ runStore?: RunStore;
39
+ providerPause?: () => Promise<void>;
40
+ }
41
+ export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
42
+ export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
43
+ export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
44
+ export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
45
+ export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting }
46
+ export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
47
+
48
+ export interface NativeSession {
49
+ readonly sessionId: string;
50
+ readonly sessionFile: string | undefined;
51
+ readonly messages: readonly AgentMessage[];
52
+ readonly systemPrompt?: string;
53
+ readonly agent?: { state: { tools: readonly { name: string }[] } };
54
+ subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
55
+ prompt(text: string): Promise<void>;
56
+ steer?(text: string): Promise<void>;
57
+ abort?(): Promise<void>;
58
+ dispose(): void;
59
+ }
60
+ export interface SessionInput { cwd: string; model: ModelSpec; tools: readonly string[]; sessionLabel: string; agentDir?: string; customTools?: readonly ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string }
61
+ export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
62
+
63
+ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel): ModelSpec {
64
+ if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
65
+ const parsed = parseModelReference(value);
66
+ return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
67
+ }
68
+ function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
69
+
70
+ function text(messages: readonly AgentMessage[]): string {
71
+ const message = [...messages].reverse().find((item) => item.role === "assistant");
72
+ if (!message || !Array.isArray(message.content)) return "";
73
+ return message.content.filter((part: unknown): part is { type: "text"; text: string } => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string").map((part) => part.text).join("");
74
+ }
75
+
76
+ function accounting(messages: readonly AgentMessage[]): AgentAccounting {
77
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
78
+ for (const message of messages) if (message.role === "assistant" && message.usage) {
79
+ total.input += message.usage.input;
80
+ total.output += message.usage.output;
81
+ total.cacheRead += message.usage.cacheRead;
82
+ total.cacheWrite += message.usage.cacheWrite;
83
+ total.cost += message.usage.cost.total;
84
+ }
85
+ return total;
86
+ }
87
+
88
+ export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
89
+ const agentDir = input.agentDir ?? getAgentDir();
90
+ const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
91
+ manager.appendSessionInfo(input.sessionLabel);
92
+ const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
93
+ const model = modelRuntime.getModel(input.model.provider, input.model.model);
94
+ if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
95
+ const customTools = [...(input.customTools ?? []), ...(input.resultTool ? [input.resultTool] : [])];
96
+ const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
97
+ const resourceLoader = input.systemPromptAppend ? new DefaultResourceLoader({ cwd: input.cwd, agentDir, appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] }) : undefined;
98
+ if (resourceLoader) await resourceLoader.reload();
99
+ const { session } = await createAgentSession({ cwd: input.cwd, agentDir, modelRuntime, model, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
100
+ return session;
101
+ }
102
+
103
+
104
+
105
+ export class WorkflowAgentExecutor {
106
+ constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
107
+
108
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string } {
109
+ const role = options.role;
110
+ const definition = role ? this.root.agentDefinitions?.[role] : undefined;
111
+ if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
112
+ if (role && (options.model !== undefined || options.thinking !== undefined || options.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
113
+ const requested = options.tools !== undefined ? options.tools : definition?.tools !== undefined ? definition.tools : options.effectiveTools !== undefined ? options.effectiveTools : inheritedTools !== undefined ? inheritedTools : [...this.root.tools];
114
+ const forbidden = requested.find((tool) => !this.root.tools.has(tool));
115
+ if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
116
+ const model = parseModel(options.model ?? definition?.model, this.root.model, options.thinking ?? definition?.thinking);
117
+ const availableModels = this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
118
+ if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${modelCapability(model)}`);
119
+ return { model, tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
120
+ }
121
+
122
+ async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
123
+ if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
124
+ 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");
125
+ const resolved = this.resolve(options);
126
+ let cwd: string;
127
+ if (options.parent) {
128
+ if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
129
+ if (options.worktreeOwner) {
130
+ if (!this.root.runStore) throw new WorkflowError("WORKTREE_FAILED", "Worktree inheritance requires a persisted run");
131
+ cwd = (await this.root.runStore.validateWorktree(options.worktreeOwner, options.cwd)).cwd;
132
+ } else {
133
+ if (options.cwd !== this.root.cwd) throw new WorkflowError("INVALID_METADATA", "Shared-tree children must inherit the root cwd");
134
+ cwd = this.root.cwd;
135
+ }
136
+ } else if (options.worktreeOwner) {
137
+ if (!this.root.runStore) throw new WorkflowError("WORKTREE_FAILED", "Worktree scope requires a persisted run");
138
+ const worktree = await this.root.runStore.worktree(options.worktreeOwner);
139
+ if (options.cwd && resolvePath(options.cwd) !== resolvePath(worktree.cwd)) throw new WorkflowError("WORKTREE_FAILED", "Agent cwd does not match its owned worktree");
140
+ cwd = worktree.cwd;
141
+ } else {
142
+ if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
143
+ cwd = this.root.cwd;
144
+ }
145
+ const attempts: AgentAttempt[] = [];
146
+ const maxAttempts = (options.retries ?? 0) + 1;
147
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
148
+ const started = Date.now();
149
+ let accepted = false;
150
+ let schemaResult: JsonValue | undefined;
151
+ const hasSchemaResult = () => schemaResult !== undefined;
152
+ const resultTool = options.schema ? {
153
+ name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
154
+ async execute(_id: string, value: unknown) {
155
+ if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
156
+ 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 };
157
+ schemaResult = structuredClone(value) as JsonValue;
158
+ void session?.abort?.();
159
+ return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
160
+ },
161
+ } as ToolDefinition : undefined;
162
+ let session: NativeSession | undefined;
163
+ const toolCalls = new Map<string, AgentToolCallProgress>();
164
+ let activity: AgentActivity | undefined;
165
+ let progress = Promise.resolve();
166
+ let unsubscribe: (() => void) | undefined;
167
+ let systemPromptTurn = 0;
168
+ let systemPromptWrite = Promise.resolve();
169
+ let systemPromptWriteError: unknown;
170
+ const flushSystemPrompts = async () => {
171
+ await systemPromptWrite;
172
+ if (systemPromptWriteError) throw new WorkflowError("INTERNAL_ERROR", `Failed to persist effective system prompt: ${systemPromptWriteError instanceof Error ? systemPromptWriteError.message : typeof systemPromptWriteError === "string" ? systemPromptWriteError : "unknown error"}`);
173
+ };
174
+ const report = (persist: boolean) => {
175
+ if (!session || !options.onProgress) return;
176
+ const update = { accounting: accounting(session.messages), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
177
+ progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
178
+ };
179
+ try {
180
+ session = await this.createSession({ cwd, model: resolved.model, tools: resolved.tools, sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(this.root.agentDir ? { agentDir: this.root.agentDir } : {}), ...(customTools.length ? { customTools } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPromptAppend ? { systemPromptAppend: resolved.systemPromptAppend } : {}) });
181
+ await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile) });
182
+ unsubscribe = session.subscribe?.((event) => {
183
+ if (event.type === "agent_start" && session?.systemPrompt !== undefined && this.root.runStore) {
184
+ systemPromptTurn += 1;
185
+ const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
186
+ systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
187
+ }
188
+ if (event.type === "message_start" && event.message.role === "assistant") { activity = { kind: "text", text: "responding" }; report(false); }
189
+ if (event.type === "message_end") { activity = undefined; if (event.message.role === "assistant") report(true); }
190
+ if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
191
+ if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); }
192
+ });
193
+ report(false);
194
+ if (setSteer) {
195
+ if (!session.steer) throw new WorkflowError("INTERNAL_ERROR", "Native Pi session does not support steering");
196
+ setSteer((message) => session?.steer?.(message));
197
+ }
198
+ const context = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? attempts.at(-1)?.error?.message ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
199
+ await promptWithProviderPause(session, `${context}\n\nTask:\n${task}`, remaining(options.timeoutMs, started), signal, this.root.providerPause);
200
+ if (options.schema) {
201
+ accepted = true;
202
+ try { await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), signal, this.root.providerPause); }
203
+ catch (error) { if (!hasSchemaResult()) throw error; }
204
+ if (!hasSchemaResult()) {
205
+ try { 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), signal, this.root.providerPause); }
206
+ catch (error) { if (!hasSchemaResult()) throw error; }
207
+ }
208
+ if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
209
+ }
210
+ const value = options.schema ? schemaResult as JsonValue : text(session.messages);
211
+ if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
212
+ report(true);
213
+ await progress;
214
+ await flushSystemPrompts();
215
+ unsubscribe?.();
216
+ const attemptAccounting = accounting(session.messages);
217
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting });
218
+ session.dispose();
219
+ return { value, attempts, cwd };
220
+ } catch (error) {
221
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
222
+ if (session) {
223
+ report(true);
224
+ await progress;
225
+ try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
226
+ unsubscribe?.();
227
+ const attemptAccounting = accounting(session.messages);
228
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting });
229
+ session.dispose();
230
+ }
231
+ if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
232
+ if (attempt === maxAttempts || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED") throw Object.assign(typed, { attempts });
233
+ beforeRetry?.();
234
+ }
235
+ }
236
+ throw new WorkflowError("AGENT_FAILED", "Agent execution failed");
237
+ }
238
+ }
239
+
240
+ export interface ScheduledAgentOptions {
241
+ label: string;
242
+ requestedLabel?: string;
243
+ parentBreadcrumb?: string;
244
+ cwd: string;
245
+ tools: readonly string[];
246
+ worktreeOwner?: string;
247
+ model?: string;
248
+ thinking?: ThinkingLevel;
249
+ role?: string;
250
+ schema?: JsonSchema;
251
+ retries?: number;
252
+ timeoutMs?: number | null;
253
+ }
254
+
255
+ export type ScheduledAgentResult =
256
+ | { id: string; ok: true; value: JsonValue }
257
+ | { id: string; ok: false; error: { code: string; message: string } };
258
+
259
+ export interface ScheduledAgentInput {
260
+ id: string;
261
+ runId: string;
262
+ parentId?: string;
263
+ prompt: string;
264
+ options: Readonly<ScheduledAgentOptions>;
265
+ signal: AbortSignal;
266
+ setSteer: (handler: (message: string) => void | Promise<void>) => void;
267
+ }
268
+
269
+ export type ScheduledAgentRunner = (input: ScheduledAgentInput) => Promise<JsonValue>;
270
+
271
+ type ScheduledNode = {
272
+ id: string;
273
+ runId: string;
274
+ parentId?: string;
275
+ options: Readonly<ScheduledAgentOptions>;
276
+ children: Set<string>;
277
+ collected: boolean;
278
+ state: "queued" | "running" | "waiting_for_child" | "completed" | "failed" | "cancelled";
279
+ controller: AbortController;
280
+ promise: Promise<ScheduledAgentResult>;
281
+ resolve: (result: ScheduledAgentResult) => void;
282
+ task: () => Promise<void>;
283
+ restored: boolean;
284
+ steer?: (message: string) => void | Promise<void>;
285
+ };
286
+
287
+ type ScheduledRun = { limit: number; maxAgentLaunches: number; logical: number; active: number; queue: Array<() => void> };
288
+ export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
289
+ type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
290
+
291
+ export class FairAgentScheduler {
292
+ readonly #runs = new Map<string, ScheduledRun>();
293
+ readonly #nodes = new Map<string, ScheduledNode>();
294
+ #runOrder: string[] = [];
295
+ #cursor = 0;
296
+ #active = 0;
297
+ #nextId = 0;
298
+ #persistence = Promise.resolve();
299
+
300
+ constructor(private readonly runner: ScheduledAgentRunner, readonly sessionLimit = 16, private readonly writeOwnership?: OwnershipWriter) {
301
+ if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16) throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
302
+ }
303
+
304
+ addRun(runId: string, limit = 8, maxAgentLaunches = 1000): void {
305
+ if (this.#runs.has(runId)) throw new WorkflowError("DUPLICATE_NAME", `Scheduler run already exists: ${runId}`);
306
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit || !Number.isInteger(maxAgentLaunches) || maxAgentLaunches < 1) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency or maxAgentLaunches");
307
+ this.#runs.set(runId, { limit, maxAgentLaunches, logical: 0, active: 0, queue: [] });
308
+ this.#runOrder.push(runId);
309
+ }
310
+
311
+ spawn(runId: string, prompt: string, options: ScheduledAgentOptions, parentId?: string): { id: string; result: Promise<ScheduledAgentResult> } {
312
+ const run = this.#runs.get(runId);
313
+ if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
314
+ const parent = parentId ? this.#nodes.get(parentId) : undefined;
315
+ if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
316
+ const effective = this.#inherit(parent, options);
317
+ if (++run.logical > run.maxAgentLaunches) { run.logical -= 1; throw new WorkflowError("RUN_LIMIT_EXCEEDED", `Run ${runId} exceeded maxAgentLaunches`); }
318
+ const id = `${runId}:${String(++this.#nextId)}`;
319
+ let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
320
+ const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
321
+ const node: ScheduledNode = { id, runId, ...(parentId ? { parentId } : {}), options: effective, children: new Set<string>(), collected: false, state: "queued", controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: false };
322
+ node.task = async () => {
323
+ if (node.controller.signal.aborted) { this.#release(node.runId); return; }
324
+ node.state = "running";
325
+ this.#persist(runId);
326
+ try {
327
+ const value = await this.runner({ id, runId, ...(parentId ? { parentId } : {}), prompt, options: effective, signal: node.controller.signal, setSteer: (handler) => { node.steer = handler; } });
328
+ this.#settle(node, { id, ok: true, value });
329
+ } catch (error) {
330
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
331
+ this.#settle(node, { id, ok: false, error: { code: typed.code, message: typed.message } });
332
+ }
333
+ };
334
+ this.#nodes.set(id, node);
335
+ parent?.children.add(id);
336
+ this.#persist(runId);
337
+ this.#enqueue(runId, () => { void node.task(); });
338
+ return { id, result: promise };
339
+ }
340
+
341
+ async result(parentId: string, childId: string): Promise<ScheduledAgentResult> {
342
+ const parent = this.#node(parentId);
343
+ const child = this.#node(childId);
344
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Results are scoped to direct children");
345
+ child.collected = true;
346
+ parent.state = "waiting_for_child";
347
+ this.#persist(parent.runId);
348
+ this.#release(parent.runId);
349
+ const outcome = await child.promise;
350
+ await new Promise<void>((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
351
+ parent.state = "running";
352
+ if (parent.controller.signal.aborted) throw new WorkflowError("CANCELLED", "Parent agent cancelled");
353
+ this.#persist(parent.runId);
354
+ return outcome;
355
+ }
356
+
357
+ async steer(parentId: string, childId: string, message: string): Promise<void> {
358
+ const child = this.#node(childId);
359
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Steering is scoped to direct children");
360
+ if (child.state !== "running" && child.state !== "waiting_for_child") throw new WorkflowError("AGENT_FAILED", "Child is not running");
361
+ if (!child.steer) throw new WorkflowError("AGENT_FAILED", "Child has not registered a steering handler");
362
+ await child.steer(message);
363
+ }
364
+
365
+ cancel(id: string): void { this.#cancelTree(this.#node(id)); }
366
+
367
+ cancelChildren(id: string): void {
368
+ for (const childId of this.#node(id).children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
369
+ }
370
+
371
+ async cancelRun(runId: string): Promise<void> {
372
+ const run = this.#runs.get(runId);
373
+ if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
374
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
375
+ for (const node of nodes) if (!node.parentId) this.#cancelTree(node);
376
+ await Promise.all(nodes.map(({ promise }) => promise));
377
+ if (nodes.every(({ restored }) => restored)) run.logical = 0;
378
+ }
379
+
380
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
381
+ toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
382
+ const parent = this.#node(parentId);
383
+ if (!parent.options.tools.includes("agent")) return [];
384
+ const agentTool = {
385
+ name: "agent", label: "Child Agent", description: "Start a direct child agent",
386
+ 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()])) }),
387
+ execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null }) => {
388
+ 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");
389
+ 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;
390
+ const options = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, ...(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 }) };
391
+ const child = this.spawn(parent.runId, params.prompt, options, parentId);
392
+ return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
393
+ },
394
+ } as ToolDefinition;
395
+ const resultTool = {
396
+ name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
397
+ parameters: Type.Object({ id: Type.String() }),
398
+ execute: async (_id: string, params: { id: string }) => { const value = await this.result(parentId, params.id); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; },
399
+ } as ToolDefinition;
400
+ const steerTool = {
401
+ name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
402
+ parameters: Type.Object({ id: Type.String(), message: Type.String() }),
403
+ execute: async (_id: string, params: { id: string; message: string }) => { await this.steer(parentId, params.id, params.message); return { content: [{ type: "text" as const, text: "Steering delivered." }], details: {} }; },
404
+ } as ToolDefinition;
405
+ return [agentTool, resultTool, steerTool];
406
+ }
407
+
408
+ snapshot(): readonly OwnershipRecord[] {
409
+ return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
410
+ }
411
+
412
+ restoreRun(runId: string, limit: number, maxAgentLaunches: number, ownership: readonly OwnershipRecord[]): void {
413
+ this.addRun(runId, limit, maxAgentLaunches);
414
+ const run = this.#runs.get(runId) as ScheduledRun;
415
+ for (const record of ownership) {
416
+ if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
417
+ let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
418
+ const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
419
+ const node: ScheduledNode = { id: record.id, runId, ...(record.parentId ? { parentId: record.parentId } : {}), options: this.#inherit(undefined, record.options), children: new Set(), collected: false, state: record.state, controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: true };
420
+ this.#nodes.set(node.id, node);
421
+ run.logical += 1;
422
+ this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
423
+ if (record.state === "completed") resolveResult({ id: node.id, ok: true, value: null });
424
+ else if (record.state === "failed" || record.state === "cancelled") resolveResult({ id: node.id, ok: false, error: { code: record.state === "cancelled" ? "CANCELLED" : "AGENT_FAILED", message: `Persisted agent ${record.state}` } });
425
+ }
426
+ for (const node of this.#nodes.values()) if (node.runId === runId && node.parentId) this.#nodes.get(node.parentId)?.children.add(node.id);
427
+ }
428
+
429
+ async flush(): Promise<void> { await this.#persistence; }
430
+
431
+ #inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
432
+ const unknown = Object.keys(options).find((key) => !["label", "requestedLabel", "parentBreadcrumb", "cwd", "tools", "worktreeOwner", "model", "thinking", "role", "schema", "retries", "timeoutMs"].includes(key));
433
+ if (unknown) throw new WorkflowError("INVALID_METADATA", `Unsupported child agent option: ${unknown}`);
434
+ if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
435
+ if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]) });
436
+ if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
437
+ const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
438
+ if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
439
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
440
+ }
441
+ /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
442
+
443
+ #enqueue(runId: string, start: () => void): void { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
444
+
445
+ #dispatch(): void {
446
+ while (this.#active < this.sessionLimit && this.#runOrder.length) {
447
+ let selected: string | undefined;
448
+ for (let checked = 0; checked < this.#runOrder.length; checked += 1) {
449
+ const index = (this.#cursor + checked) % this.#runOrder.length;
450
+ const id = this.#runOrder[index];
451
+ const run = id ? this.#runs.get(id) : undefined;
452
+ if (id && run && run.active < run.limit && run.queue.length) { selected = id; this.#cursor = (index + 1) % this.#runOrder.length; break; }
453
+ }
454
+ if (!selected) return;
455
+ const run = this.#runs.get(selected) as ScheduledRun;
456
+ const start = run.queue.shift() as () => void;
457
+ run.active += 1; this.#active += 1; start();
458
+ }
459
+ }
460
+
461
+ #release(runId: string): void {
462
+ const run = this.#runs.get(runId);
463
+ if (run && run.active > 0) { run.active -= 1; this.#active -= 1; this.#dispatch(); }
464
+ }
465
+
466
+ #settle(node: ScheduledNode, result: ScheduledAgentResult): void {
467
+ if (["completed", "failed", "cancelled"].includes(node.state)) return;
468
+ const heldPermit = node.state === "running";
469
+ node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
470
+ this.#persist(node.runId);
471
+ if (heldPermit) this.#release(node.runId);
472
+ for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }
473
+ node.resolve(result);
474
+ }
475
+
476
+ #cancelTree(node: ScheduledNode): void {
477
+ if (["completed", "failed", "cancelled"].includes(node.state)) return;
478
+ node.controller.abort();
479
+ for (const childId of node.children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
480
+ if (node.state === "queued" || node.restored) this.#settle(node, { id: node.id, ok: false, error: { code: "CANCELLED", message: "Agent cancelled" } });
481
+ }
482
+
483
+ #node(id: string): ScheduledNode {
484
+ const node = this.#nodes.get(id);
485
+ if (!node) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown owned agent: ${id}`);
486
+ return node;
487
+ }
488
+
489
+ #persist(runId: string): void {
490
+ if (!this.writeOwnership) return;
491
+ const ownership = this.snapshot().filter(({ id }) => id.startsWith(`${runId}:`));
492
+ this.#persistence = this.#persistence.then(() => this.writeOwnership?.(runId, ownership)).then(() => undefined);
493
+ }
494
+ }
495
+
496
+ function resolvePath(path: string): string { return path.replace(/[\\/]+$/, ""); }
497
+
498
+ function requiredFile(file: string | undefined): string {
499
+ if (!file) throw new WorkflowError("INTERNAL_ERROR", "Workflow agents require persisted native Pi sessions");
500
+ return file;
501
+ }
502
+
503
+ function remaining(timeoutMs: number | null | undefined, started: number): number | null | undefined {
504
+ return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
505
+ }
506
+
507
+ function providerLimited(error: unknown): boolean {
508
+ if (!error || typeof error !== "object") return false;
509
+ const candidate = error as { status?: unknown; code?: unknown };
510
+ return candidate.status === 429 || candidate.code === 429 || candidate.code === "rate_limit_exceeded" || candidate.code === "RATE_LIMITED";
511
+ }
512
+
513
+ async function promptWithProviderPause(session: NativeSession, text: string, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, pause?: () => Promise<void>): Promise<void> {
514
+ for (;;) {
515
+ try { await withTimeout(session.prompt(text), timeoutMs, signal, session); return; }
516
+ catch (error) { if (!pause || !providerLimited(error)) throw error; await pause(); }
517
+ }
518
+ }
519
+
520
+ async function withTimeout(work: Promise<void>, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, session: NativeSession): Promise<void> {
521
+ if (signal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
522
+ let timer: NodeJS.Timeout | undefined;
523
+ let abort: (() => void) | undefined;
524
+ const state = { interrupted: false };
525
+ const timeout = timeoutMs ? new Promise<never>((_, reject) => { timer = setTimeout(() => { state.interrupted = true; reject(new WorkflowError("AGENT_TIMEOUT", "Agent attempt timed out")); }, timeoutMs); }) : new Promise<never>(() => {});
526
+ const cancelled = signal ? new Promise<never>((_, reject) => { abort = () => { state.interrupted = true; reject(new WorkflowError("CANCELLED", "Agent cancelled")); }; signal.addEventListener("abort", abort, { once: true }); }) : new Promise<never>(() => {});
527
+ try { await Promise.race([work, timeout, cancelled]); }
528
+ finally { if (timer) clearTimeout(timer); if (abort) signal?.removeEventListener("abort", abort); if (state.interrupted) await session.abort?.(); }
529
+ }