pi-dynamic-workflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sandbox.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Evaluates the LLM-authored workflow script body in a `node:vm` context with
3
+ * the orchestration hooks injected.
4
+ */
5
+
6
+ import * as vm from "node:vm";
7
+ import type { ScriptHooks } from "./types.ts";
8
+
9
+ export class WorkflowScriptError extends Error {
10
+ constructor(kind: "syntax" | "runtime", cause: unknown) {
11
+ const err = cause instanceof Error ? cause : new Error(String(cause));
12
+ const stackLine = err.stack?.split("\n").find((line) => line.includes("<workflow>")) ?? "";
13
+ super(`Workflow script ${kind} error: ${err.message}${stackLine ? `\n${stackLine.trim()}` : ""}`);
14
+ this.name = "WorkflowScriptError";
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Cap on the script's *initial synchronous slice* (code up to the first await).
20
+ * Catches the common LLM mistake of a top-level busy loop (`while (true) {}`)
21
+ * before it can wedge pi's event loop. Continuations after an await run as host
22
+ * microtasks and are NOT covered — that residual hang requires worker threads
23
+ * to fix and is accepted as a known limitation.
24
+ */
25
+ const SYNC_SLICE_TIMEOUT_MS = 5000;
26
+
27
+ /**
28
+ * Determinism guards: scripts must produce the same agent() call sequence on
29
+ * every run so a resumed run can replay cached results. Wall-clock and
30
+ * randomness would silently break that, so they throw with guidance instead.
31
+ */
32
+ function blocked(name: string): () => never {
33
+ return () => {
34
+ throw new Error(
35
+ `${name} is blocked in workflow scripts: runs must be deterministic so they can be resumed. ` +
36
+ "Pass timestamps or random seeds in via `args` instead.",
37
+ );
38
+ };
39
+ }
40
+
41
+ const guardedMath = new Proxy(Math, {
42
+ get(target, prop, receiver) {
43
+ if (prop === "random") return blocked("Math.random()");
44
+ return Reflect.get(target, prop, receiver);
45
+ },
46
+ });
47
+
48
+ const guardedDate = new Proxy(Date, {
49
+ construct(target, argArray, newTarget) {
50
+ if (argArray.length === 0) blocked("new Date() without arguments")();
51
+ return Reflect.construct(target, argArray, newTarget);
52
+ },
53
+ get(target, prop, receiver) {
54
+ if (prop === "now") return blocked("Date.now()");
55
+ return Reflect.get(target, prop, receiver);
56
+ },
57
+ apply() {
58
+ return blocked("Date()")();
59
+ },
60
+ });
61
+
62
+ /** Run a plain async JS body with the hooks in scope. Returns the script's return value. */
63
+ export async function runWorkflowScript(
64
+ source: string,
65
+ hooks: ScriptHooks,
66
+ options?: { syncTimeoutMs?: number },
67
+ ): Promise<unknown> {
68
+ const sandboxConsole = {
69
+ log: (...parts: unknown[]) => hooks.log(parts.map(stringify).join(" ")),
70
+ error: (...parts: unknown[]) => hooks.log(parts.map(stringify).join(" ")),
71
+ warn: (...parts: unknown[]) => hooks.log(parts.map(stringify).join(" ")),
72
+ info: (...parts: unknown[]) => hooks.log(parts.map(stringify).join(" ")),
73
+ };
74
+
75
+ // SECURITY NOTE: node:vm provides isolation hygiene, not a security boundary.
76
+ // The injected hooks are host-realm functions, so a determined script can reach
77
+ // host globals via e.g. `agent.constructor("return process")()`. This is
78
+ // acceptable here because the script author is the session LLM, which already
79
+ // has full tool access through pi itself. Do not treat this context as a
80
+ // jail for untrusted third-party code.
81
+ const context = vm.createContext({
82
+ agent: hooks.agent,
83
+ parallel: hooks.parallel,
84
+ pipeline: hooks.pipeline,
85
+ phase: hooks.phase,
86
+ log: hooks.log,
87
+ args: hooks.args,
88
+ budget: hooks.budget,
89
+ workflow: hooks.workflow,
90
+ console: sandboxConsole,
91
+ setTimeout,
92
+ clearTimeout,
93
+ // Shadow the context's realm intrinsics with the determinism guards.
94
+ Math: guardedMath,
95
+ Date: guardedDate,
96
+ });
97
+
98
+ let script: vm.Script;
99
+ try {
100
+ script = new vm.Script(`(async () => {\n${source}\n})()`, { filename: "<workflow>" });
101
+ } catch (error) {
102
+ throw new WorkflowScriptError("syntax", error);
103
+ }
104
+
105
+ try {
106
+ return await script.runInContext(context, { timeout: options?.syncTimeoutMs ?? SYNC_SLICE_TIMEOUT_MS });
107
+ } catch (error) {
108
+ if (error instanceof WorkflowAbortError) throw error;
109
+ throw new WorkflowScriptError("runtime", error);
110
+ }
111
+ }
112
+
113
+ /** Thrown by hooks when the parent tool call is aborted; passes through untouched. */
114
+ export class WorkflowAbortError extends Error {
115
+ constructor() {
116
+ super("Workflow aborted");
117
+ this.name = "WorkflowAbortError";
118
+ }
119
+ }
120
+
121
+ function stringify(value: unknown): string {
122
+ if (typeof value === "string") return value;
123
+ try {
124
+ return JSON.stringify(value);
125
+ } catch {
126
+ return String(value);
127
+ }
128
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Concurrency control and deterministic combinators for workflow scripts.
3
+ */
4
+
5
+ import * as os from "node:os";
6
+
7
+ export function defaultConcurrency(): number {
8
+ return Math.max(2, Math.min(8, os.cpus().length - 2));
9
+ }
10
+
11
+ /** Simple counting semaphore capping concurrent subagent subprocesses. */
12
+ export class Semaphore {
13
+ private available: number;
14
+ private waiters: Array<() => void> = [];
15
+
16
+ constructor(capacity: number) {
17
+ this.available = Math.max(1, capacity);
18
+ }
19
+
20
+ async acquire(): Promise<void> {
21
+ if (this.available > 0) {
22
+ this.available--;
23
+ return;
24
+ }
25
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
26
+ }
27
+
28
+ release(): void {
29
+ const next = this.waiters.shift();
30
+ if (next) next();
31
+ else this.available++;
32
+ }
33
+
34
+ async run<T>(fn: () => Promise<T>): Promise<T> {
35
+ await this.acquire();
36
+ try {
37
+ return await fn();
38
+ } finally {
39
+ this.release();
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Run all thunks concurrently. A thrown/rejected thunk resolves to `null`
46
+ * (the batch never rejects), so scripts can `.filter(Boolean)`.
47
+ */
48
+ export async function parallel(thunks: Array<() => Promise<unknown>>): Promise<unknown[]> {
49
+ return Promise.all(
50
+ thunks.map(async (thunk) => {
51
+ try {
52
+ return await thunk();
53
+ } catch {
54
+ return null;
55
+ }
56
+ }),
57
+ );
58
+ }
59
+
60
+ export type PipelineStage = (prev: unknown, item: unknown, index: number) => Promise<unknown> | unknown;
61
+
62
+ /**
63
+ * Each item flows through all stages independently with no cross-item barrier.
64
+ * A throwing stage drops the item to `null` and skips its remaining stages.
65
+ */
66
+ export async function pipeline(items: unknown[], ...stages: PipelineStage[]): Promise<unknown[]> {
67
+ return Promise.all(
68
+ items.map(async (item, index) => {
69
+ let value: unknown = item;
70
+ for (const stage of stages) {
71
+ try {
72
+ value = await stage(value, item, index);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ return value;
78
+ }),
79
+ );
80
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Schema-enforced structured output for subagents.
3
+ *
4
+ * Generates a temporary pi extension that registers a terminating `emit_result`
5
+ * tool whose parameters are the caller-supplied JSON schema. pi validates tool
6
+ * arguments against the schema (and retries the model on validation errors), so
7
+ * the parent only needs to extract the final `emit_result` call arguments.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ export const EMIT_RESULT_TOOL = "emit_result";
15
+
16
+ export interface StructuredOutputExtension {
17
+ /** Path to the generated extension file (pass to `pi -e`). */
18
+ path: string;
19
+ /** Remove the temp file and directory. */
20
+ cleanup(): void;
21
+ }
22
+
23
+ export async function createStructuredOutputExtension(
24
+ schema: Record<string, unknown>,
25
+ ): Promise<StructuredOutputExtension> {
26
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-workflow-schema-"));
27
+ const filePath = path.join(dir, "structured-output.ts");
28
+
29
+ const source = `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { Type } from "typebox";
31
+
32
+ const schema = ${JSON.stringify(schema, null, "\t")} as const;
33
+
34
+ export default function (pi: ExtensionAPI) {
35
+ pi.registerTool({
36
+ name: ${JSON.stringify(EMIT_RESULT_TOOL)},
37
+ label: "Emit Result",
38
+ description:
39
+ "Emit the final structured result for this task. Call this exactly once, as your last action, with the complete answer matching the required schema.",
40
+ promptSnippet: "Emit the final structured result (required last action)",
41
+ promptGuidelines: [
42
+ "You MUST finish by calling ${EMIT_RESULT_TOOL} exactly once with the final answer matching its parameter schema. Do not answer in plain text.",
43
+ ],
44
+ parameters: Type.Unsafe(schema),
45
+ async execute(_toolCallId, params) {
46
+ return {
47
+ content: [{ type: "text", text: "Structured result recorded." }],
48
+ details: params,
49
+ terminate: true,
50
+ };
51
+ },
52
+ });
53
+ }
54
+ `;
55
+
56
+ await fs.promises.writeFile(filePath, source, { encoding: "utf-8", mode: 0o600 });
57
+
58
+ return {
59
+ path: filePath,
60
+ cleanup() {
61
+ try {
62
+ fs.unlinkSync(filePath);
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ try {
67
+ fs.rmdirSync(dir);
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Runs a single pi subagent as a subprocess (`pi --mode json -p --no-session`)
3
+ * and parses its newline-delimited JSON event stream.
4
+ */
5
+
6
+ import { spawn } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
11
+ import { createStructuredOutputExtension, EMIT_RESULT_TOOL } from "./structured.ts";
12
+ import { addUsage, emptyUsage, type UsageStats } from "./types.ts";
13
+
14
+ export interface SubagentRequest {
15
+ prompt: string;
16
+ model?: string;
17
+ tools?: string[];
18
+ cwd: string;
19
+ schema?: Record<string, unknown>;
20
+ signal?: AbortSignal;
21
+ /** Wall-clock cap in ms; on expiry the subprocess is killed and the result is a non-abort failure. */
22
+ timeoutMs?: number;
23
+ /** Replaces the subagent's system prompt (`pi --system-prompt`). */
24
+ systemPrompt?: string;
25
+ /** Appended to the system prompt in order (`pi --append-system-prompt`, via temp files). */
26
+ appendSystemPrompt?: string[];
27
+ /** Called on each parsed event so the parent can stream progress. */
28
+ onEvent?: (update: SubagentProgress) => void;
29
+ }
30
+
31
+ export interface SubagentProgress {
32
+ /** Short human-readable description of the latest activity. */
33
+ activity: string;
34
+ usage: UsageStats;
35
+ }
36
+
37
+ export interface SubagentResult {
38
+ ok: boolean;
39
+ /** Last assistant text output. */
40
+ outputText: string;
41
+ /** Parsed `emit_result` arguments when a schema was supplied. */
42
+ structured?: unknown;
43
+ usage: UsageStats;
44
+ model?: string;
45
+ stopReason?: string;
46
+ errorMessage?: string;
47
+ exitCode: number;
48
+ aborted: boolean;
49
+ }
50
+
51
+ /** Env var counting nested subagent depth to prevent runaway recursion (fork bombs). */
52
+ const DEPTH_ENV = "PI_WORKFLOW_DEPTH";
53
+ const MAX_SUBAGENT_DEPTH = 3;
54
+
55
+ /**
56
+ * Resolve how to invoke pi.
57
+ *
58
+ * Unlike the official subagent example, this only reuses `process.argv[1]`
59
+ * when it plausibly IS pi's CLI entry point. Blindly re-invoking argv[1]
60
+ * would fork-bomb the machine if this module is ever loaded outside pi
61
+ * (e.g. from a test harness: argv[1] is the harness, which re-runs itself).
62
+ */
63
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
64
+ const currentScript = process.argv[1];
65
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
66
+ const looksLikePiEntry = currentScript !== undefined && /^(pi|cli\.(js|ts|mjs))$/.test(path.basename(currentScript));
67
+ if (currentScript && looksLikePiEntry && !isBunVirtualScript && fs.existsSync(currentScript)) {
68
+ return { command: process.execPath, args: [currentScript, ...args] };
69
+ }
70
+
71
+ const execName = path.basename(process.execPath).toLowerCase();
72
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
73
+ if (!isGenericRuntime) {
74
+ return { command: process.execPath, args };
75
+ }
76
+
77
+ return { command: "pi", args };
78
+ }
79
+
80
+ function lastAssistantText(messages: Message[]): string {
81
+ for (let i = messages.length - 1; i >= 0; i--) {
82
+ const msg = messages[i];
83
+ if (msg?.role === "assistant") {
84
+ for (const part of msg.content) {
85
+ if (part.type === "text" && part.text.trim()) return part.text;
86
+ }
87
+ }
88
+ }
89
+ return "";
90
+ }
91
+
92
+ function lastEmitResultArguments(messages: Message[]): unknown {
93
+ for (let i = messages.length - 1; i >= 0; i--) {
94
+ const msg = messages[i];
95
+ if (msg?.role !== "assistant") continue;
96
+ for (let j = msg.content.length - 1; j >= 0; j--) {
97
+ const part = msg.content[j];
98
+ if (part?.type === "toolCall" && part.name === EMIT_RESULT_TOOL) {
99
+ return part.arguments;
100
+ }
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ export async function runSubagent(request: SubagentRequest): Promise<SubagentResult> {
107
+ const { prompt, model, tools, cwd, schema, signal, timeoutMs, systemPrompt, appendSystemPrompt, onEvent } = request;
108
+
109
+ // Hard recursion guard: a subagent that itself spawns workflows could
110
+ // otherwise multiply subprocesses without bound.
111
+ const depth = Number(process.env[DEPTH_ENV] ?? "0") || 0;
112
+ if (depth >= MAX_SUBAGENT_DEPTH) {
113
+ return {
114
+ ok: false,
115
+ outputText: "",
116
+ usage: emptyUsage(),
117
+ exitCode: 1,
118
+ aborted: false,
119
+ errorMessage: `Subagent nesting depth limit (${MAX_SUBAGENT_DEPTH}) reached; refusing to spawn`,
120
+ };
121
+ }
122
+
123
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
124
+ if (model) args.push("--model", model);
125
+ if (tools && tools.length > 0) {
126
+ const toolList = schema ? [...new Set([...tools, EMIT_RESULT_TOOL])] : tools;
127
+ args.push("--tools", toolList.join(","));
128
+ }
129
+ if (systemPrompt) args.push("--system-prompt", systemPrompt);
130
+
131
+ // --append-system-prompt accepts text or a file path; prompt text that
132
+ // happens to look like a path would be misread, so always pass a temp file.
133
+ let promptDir: string | null = null;
134
+ if (appendSystemPrompt && appendSystemPrompt.length > 0) {
135
+ promptDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-workflow-sysprompt-"));
136
+ appendSystemPrompt.forEach((text, i) => {
137
+ const promptPath = path.join(promptDir as string, `append-${i}.md`);
138
+ fs.writeFileSync(promptPath, text, "utf-8");
139
+ args.push("--append-system-prompt", promptPath);
140
+ });
141
+ }
142
+
143
+ let structuredExt: Awaited<ReturnType<typeof createStructuredOutputExtension>> | null = null;
144
+ if (schema) {
145
+ structuredExt = await createStructuredOutputExtension(schema);
146
+ args.push("-e", structuredExt.path);
147
+ }
148
+ args.push(prompt);
149
+
150
+ const messages: Message[] = [];
151
+ const usage = emptyUsage();
152
+ const result: SubagentResult = {
153
+ ok: false,
154
+ outputText: "",
155
+ usage,
156
+ exitCode: 0,
157
+ aborted: false,
158
+ };
159
+ let stderr = "";
160
+ let timedOut = false;
161
+
162
+ try {
163
+ result.exitCode = await new Promise<number>((resolve) => {
164
+ const invocation = getPiInvocation(args);
165
+ const proc = spawn(invocation.command, invocation.args, {
166
+ cwd,
167
+ shell: false,
168
+ stdio: ["ignore", "pipe", "pipe"],
169
+ env: { ...process.env, [DEPTH_ENV]: String(depth + 1) },
170
+ });
171
+ let buffer = "";
172
+
173
+ const processLine = (line: string) => {
174
+ if (!line.trim()) return;
175
+ let event: { type?: string; message?: Message; toolName?: string };
176
+ try {
177
+ event = JSON.parse(line);
178
+ } catch {
179
+ return;
180
+ }
181
+
182
+ if (event.type === "message_end" && event.message) {
183
+ const msg = event.message;
184
+ messages.push(msg);
185
+ if (msg.role === "assistant") {
186
+ const assistant = msg as AssistantMessage;
187
+ usage.turns++;
188
+ if (assistant.usage) {
189
+ addUsage(usage, {
190
+ input: assistant.usage.input || 0,
191
+ output: assistant.usage.output || 0,
192
+ cacheRead: assistant.usage.cacheRead || 0,
193
+ cacheWrite: assistant.usage.cacheWrite || 0,
194
+ cost: assistant.usage.cost?.total || 0,
195
+ turns: 0,
196
+ });
197
+ }
198
+ if (!result.model && assistant.model) result.model = assistant.model;
199
+ if (assistant.stopReason) result.stopReason = assistant.stopReason;
200
+ if (assistant.errorMessage) result.errorMessage = assistant.errorMessage;
201
+
202
+ const text = lastAssistantText([msg]);
203
+ onEvent?.({ activity: text ? text.slice(0, 120) : "thinking...", usage: { ...usage } });
204
+ }
205
+ } else if (event.type === "tool_execution_start" && event.toolName) {
206
+ onEvent?.({ activity: `→ ${event.toolName}`, usage: { ...usage } });
207
+ }
208
+ };
209
+
210
+ proc.stdout.on("data", (data) => {
211
+ buffer += data.toString();
212
+ const lines = buffer.split("\n");
213
+ buffer = lines.pop() || "";
214
+ for (const line of lines) processLine(line);
215
+ });
216
+
217
+ proc.stderr.on("data", (data) => {
218
+ stderr += data.toString();
219
+ });
220
+
221
+ // NOTE: `proc.killed` only means a signal was *sent*, so it cannot
222
+ // detect a child that ignores SIGTERM; check exit status instead.
223
+ let killedByUs = false;
224
+ const terminate = () => {
225
+ killedByUs = true;
226
+ proc.kill("SIGTERM");
227
+ setTimeout(() => {
228
+ if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL");
229
+ }, 5000).unref();
230
+ };
231
+
232
+ let timeoutTimer: NodeJS.Timeout | undefined;
233
+ if (timeoutMs && timeoutMs > 0) {
234
+ timeoutTimer = setTimeout(() => {
235
+ timedOut = true;
236
+ terminate();
237
+ }, timeoutMs);
238
+ timeoutTimer.unref();
239
+ }
240
+
241
+ proc.on("close", (code, killSignal) => {
242
+ if (timeoutTimer) clearTimeout(timeoutTimer);
243
+ if (buffer.trim()) processLine(buffer);
244
+ // A null code means signal death. Our own kills are reported via
245
+ // the aborted/timedOut flags; an external kill (OOM killer, manual
246
+ // SIGKILL) must read as failure, not as a success with partial output.
247
+ if (code === null && !killedByUs) {
248
+ result.errorMessage ??= `pi killed by signal ${killSignal ?? "unknown"}`;
249
+ resolve(1);
250
+ return;
251
+ }
252
+ resolve(code ?? 0);
253
+ });
254
+
255
+ proc.on("error", () => {
256
+ resolve(1);
257
+ });
258
+
259
+ if (signal) {
260
+ const killProc = () => {
261
+ result.aborted = true;
262
+ terminate();
263
+ };
264
+ if (signal.aborted) killProc();
265
+ else signal.addEventListener("abort", killProc, { once: true });
266
+ }
267
+ });
268
+ } finally {
269
+ structuredExt?.cleanup();
270
+ if (promptDir) fs.rmSync(promptDir, { recursive: true, force: true });
271
+ }
272
+
273
+ result.outputText = lastAssistantText(messages);
274
+
275
+ if (result.aborted) {
276
+ result.errorMessage ??= "Subagent aborted";
277
+ return result;
278
+ }
279
+
280
+ // A timed-out child dies via SIGTERM (close code null → 0), so without this
281
+ // check it could masquerade as a successful run with partial output.
282
+ if (timedOut) {
283
+ result.errorMessage = `Subagent timed out after ${timeoutMs}ms`;
284
+ return result;
285
+ }
286
+
287
+ const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
288
+ if (failed) {
289
+ result.errorMessage ??= stderr.trim() || `pi exited with code ${result.exitCode}`;
290
+ return result;
291
+ }
292
+
293
+ if (schema) {
294
+ const structured = lastEmitResultArguments(messages);
295
+ if (structured === undefined) {
296
+ result.errorMessage = `Subagent finished without calling ${EMIT_RESULT_TOOL} (structured output missing)`;
297
+ return result;
298
+ }
299
+ result.structured = structured;
300
+ }
301
+
302
+ result.ok = true;
303
+ return result;
304
+ }