pi-agent-flow 0.1.0-alpha.1

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/flow.ts ADDED
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Flow process runner (fork-only).
3
+ *
4
+ * Spawns isolated pi processes with forked session context
5
+ * and streams results back via callbacks.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
+ import type { FlowConfig } from "./agents.js";
14
+ import { parseFlowCliArgs } from "./runner-cli.js";
15
+ import { processFlowJsonLine } from "./runner-events.js";
16
+ import {
17
+ type SingleResult,
18
+ type FlowDetails,
19
+ emptyFlowUsage,
20
+ getFlowOutput,
21
+ normalizeFlowResult,
22
+ } from "./types.js";
23
+
24
+ const isWindows = process.platform === "win32";
25
+ const SIGKILL_TIMEOUT_MS = 5000;
26
+ const AGENT_END_GRACE_MS = 250;
27
+ const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
28
+ const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
29
+ const FLOW_STACK_ENV = "PI_FLOW_STACK";
30
+ const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
31
+ const PI_OFFLINE_ENV = "PI_OFFLINE";
32
+
33
+ type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Process helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Derive the spawn command from the current process context so child invocations
41
+ * work on Unix and Windows without going through a shell wrapper.
42
+ */
43
+ function resolveFlowSpawn(): { command: string; prefixArgs: string[] } {
44
+ const isNode = /[\\/]node(?:\.exe)?$/i.test(process.execPath);
45
+ if (isNode && process.argv[1]) {
46
+ return { command: process.execPath, prefixArgs: [process.argv[1]] };
47
+ }
48
+ return { command: process.execPath, prefixArgs: [] };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Temp file helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function writeFlowSessionToTempFile(
56
+ flowName: string,
57
+ sessionJsonl: string,
58
+ ): { dir: string; filePath: string } {
59
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agent-flow-"));
60
+ const safeName = flowName.replace(/[^\w.-]+/g, "_");
61
+ const filePath = path.join(tmpDir, `flow-${safeName}.jsonl`);
62
+ fs.writeFileSync(filePath, sessionJsonl, { encoding: "utf-8", mode: 0o600 });
63
+ return { dir: tmpDir, filePath };
64
+ }
65
+
66
+ function cleanupFlowTempDir(dir: string | null): void {
67
+ if (!dir) return;
68
+ try {
69
+ fs.rmSync(dir, { recursive: true, force: true });
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Build pi CLI arguments (fork-only)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const inheritedCliArgs = parseFlowCliArgs(process.argv);
80
+
81
+ function buildFlowArgs(
82
+ flow: FlowConfig,
83
+ intent: string,
84
+ forkSessionPath: string | null,
85
+ ): string[] {
86
+ const args: string[] = [
87
+ "--mode",
88
+ "json",
89
+ ...inheritedCliArgs.extensionArgs,
90
+ ...inheritedCliArgs.alwaysProxy,
91
+ "-p",
92
+ ];
93
+
94
+ // Fork mode: always use --session
95
+ if (forkSessionPath) {
96
+ args.push("--session", forkSessionPath);
97
+ }
98
+
99
+ const model = flow.model ?? inheritedCliArgs.fallbackModel;
100
+ if (model) args.push("--model", model);
101
+
102
+ const thinking = flow.thinking ?? inheritedCliArgs.fallbackThinking;
103
+ if (thinking) args.push("--thinking", thinking);
104
+
105
+ if (flow.tools && flow.tools.length > 0) {
106
+ args.push("--tools", flow.tools.join(","));
107
+ } else if (flow.tools === undefined) {
108
+ if (inheritedCliArgs.fallbackTools !== undefined) {
109
+ args.push("--tools", inheritedCliArgs.fallbackTools);
110
+ } else if (inheritedCliArgs.fallbackNoTools) {
111
+ args.push("--no-tools");
112
+ }
113
+ }
114
+
115
+ // No --append-system-prompt: child inherits parent's system prompt for cache hits.
116
+ // Flow instructions go in the intent message instead.
117
+
118
+ const flowDirectives =
119
+ `<flow_directives>\n` +
120
+ `You are a flow state executing a mission. The conversation history above is background context — use it for reference, but your sole focus is the intent below.\n` +
121
+ `</flow_directives>`;
122
+
123
+ const flowInstructions = flow.systemPrompt.trim()
124
+ ? `\n\n<flow_instructions>\n${flow.systemPrompt.trim()}\n</flow_instructions>`
125
+ : "";
126
+
127
+ args.push(`${flowDirectives}${flowInstructions}\n\nIntent: ${intent}`);
128
+ return args;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Public API
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export interface RunFlowOptions {
136
+ /** Fallback working directory when the intent doesn't specify one. */
137
+ cwd: string;
138
+ /** All available flow configs. */
139
+ flows: FlowConfig[];
140
+ /** Name of the flow to run. */
141
+ flowName: string;
142
+ /** Intent description. */
143
+ intent: string;
144
+ /** Optional override working directory. */
145
+ taskCwd?: string;
146
+ /** Serialized parent session snapshot for fork mode. */
147
+ forkSessionSnapshotJsonl: string;
148
+ /** Current delegation depth of the caller process. */
149
+ parentDepth: number;
150
+ /** Delegation stack from the caller process (ancestor flow names). */
151
+ parentFlowStack: string[];
152
+ /** Maximum allowed delegation depth to propagate to child processes. */
153
+ maxDepth: number;
154
+ /** Whether cycle prevention should be enforced in child processes. */
155
+ preventCycles: boolean;
156
+ /** Abort signal for cancellation. */
157
+ signal?: AbortSignal;
158
+ /** Streaming update callback. */
159
+ onUpdate?: FlowUpdateCallback;
160
+ /** Factory to wrap results into FlowDetails. */
161
+ makeDetails: (results: SingleResult[]) => FlowDetails;
162
+ }
163
+
164
+ /**
165
+ * Spawn a single flow process with forked session context.
166
+ *
167
+ * Returns a SingleResult even on failure (exitCode > 0, stderr populated).
168
+ */
169
+ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
170
+ const {
171
+ cwd,
172
+ flows,
173
+ flowName,
174
+ intent,
175
+ taskCwd,
176
+ forkSessionSnapshotJsonl,
177
+ parentDepth,
178
+ parentFlowStack,
179
+ maxDepth,
180
+ preventCycles,
181
+ signal,
182
+ onUpdate,
183
+ makeDetails,
184
+ } = opts;
185
+
186
+ const flow = flows.find((f) => f.name === flowName);
187
+ if (!flow) {
188
+ const available = flows.map((f) => `"${f.name}"`).join(", ") || "none";
189
+ return {
190
+ type: flowName,
191
+ agentSource: "unknown",
192
+ intent,
193
+ exitCode: 1,
194
+ messages: [],
195
+ stderr: `Unknown flow: "${flowName}". Available flows: ${available}.`,
196
+ usage: emptyFlowUsage(),
197
+ };
198
+ }
199
+
200
+ if (!forkSessionSnapshotJsonl || !forkSessionSnapshotJsonl.trim()) {
201
+ return {
202
+ type: flowName,
203
+ agentSource: flow.source,
204
+ intent,
205
+ exitCode: 1,
206
+ messages: [],
207
+ stderr: "Cannot run in fork mode: missing parent session snapshot context.",
208
+ usage: emptyFlowUsage(),
209
+ model: flow.model,
210
+ stopReason: "error",
211
+ errorMessage: "Cannot run in fork mode: missing parent session snapshot context.",
212
+ };
213
+ }
214
+
215
+ const result: SingleResult = {
216
+ type: flowName,
217
+ agentSource: flow.source,
218
+ intent,
219
+ exitCode: -1,
220
+ messages: [],
221
+ stderr: "",
222
+ usage: emptyFlowUsage(),
223
+ model: flow.model,
224
+ };
225
+
226
+ const emitUpdate = () => {
227
+ onUpdate?.({
228
+ content: [
229
+ {
230
+ type: "text",
231
+ text: getFlowOutput(result.messages) || "(running...)",
232
+ },
233
+ ],
234
+ details: makeDetails([result]),
235
+ });
236
+ };
237
+
238
+ // Write forked session snapshot to temp file
239
+ const forkTmp = writeFlowSessionToTempFile(flow.name, forkSessionSnapshotJsonl);
240
+ const forkSessionTmpDir = forkTmp.dir;
241
+ const forkSessionTmpPath = forkTmp.filePath;
242
+
243
+ try {
244
+ const piArgs = buildFlowArgs(
245
+ flow,
246
+ intent,
247
+ forkSessionTmpPath,
248
+ );
249
+ let wasAborted = false;
250
+
251
+ const exitCode = await new Promise<number>((resolve) => {
252
+ const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
253
+ const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
254
+ const propagatedStack = [...parentFlowStack, flowName];
255
+ const { command, prefixArgs } = resolveFlowSpawn();
256
+ const proc = spawn(command, [...prefixArgs, ...piArgs], {
257
+ cwd: taskCwd ?? cwd,
258
+ shell: false,
259
+ stdio: ["pipe", "pipe", "pipe"],
260
+ env: {
261
+ ...process.env,
262
+ [FLOW_DEPTH_ENV]: String(nextDepth),
263
+ [FLOW_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
264
+ [FLOW_STACK_ENV]: JSON.stringify(propagatedStack),
265
+ [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
266
+ [PI_OFFLINE_ENV]: "1",
267
+ },
268
+ });
269
+
270
+ proc.stdin.on("error", () => {
271
+ /* ignore broken pipe on fast exits */
272
+ });
273
+ proc.stdin.end();
274
+
275
+ let buffer = "";
276
+ let didClose = false;
277
+ let settled = false;
278
+ let abortHandler: (() => void) | undefined;
279
+ let semanticCompletionTimer: NodeJS.Timeout | undefined;
280
+
281
+ const clearSemanticCompletionTimer = () => {
282
+ if (semanticCompletionTimer) {
283
+ clearTimeout(semanticCompletionTimer);
284
+ semanticCompletionTimer = undefined;
285
+ }
286
+ };
287
+
288
+ const terminateChild = () => {
289
+ if (isWindows) {
290
+ if (proc.pid !== undefined) {
291
+ const killer = spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)], {
292
+ stdio: "ignore",
293
+ });
294
+ killer.unref();
295
+ }
296
+ return;
297
+ }
298
+
299
+ proc.kill("SIGTERM");
300
+ const sigkillTimer = setTimeout(() => {
301
+ if (!didClose) proc.kill("SIGKILL");
302
+ }, SIGKILL_TIMEOUT_MS);
303
+ sigkillTimer.unref();
304
+ };
305
+
306
+ const finish = (code: number) => {
307
+ if (settled) return;
308
+ settled = true;
309
+ clearSemanticCompletionTimer();
310
+ if (signal && abortHandler) {
311
+ signal.removeEventListener("abort", abortHandler);
312
+ }
313
+ resolve(code);
314
+ };
315
+
316
+ const flushLine = (line: string) => {
317
+ if (processFlowJsonLine(line, result)) emitUpdate();
318
+ maybeFinishFromAgentEnd();
319
+ };
320
+
321
+ const flushBufferedLines = (text: string) => {
322
+ for (const line of text.split(/\r?\n/)) {
323
+ if (line.trim()) flushLine(line);
324
+ }
325
+ };
326
+
327
+ const maybeFinishFromAgentEnd = () => {
328
+ if (!result.sawAgentEnd || didClose || settled) return;
329
+ clearSemanticCompletionTimer();
330
+ semanticCompletionTimer = setTimeout(() => {
331
+ if (didClose || settled || !result.sawAgentEnd) return;
332
+ if (buffer.trim()) {
333
+ flushBufferedLines(buffer);
334
+ buffer = "";
335
+ }
336
+ proc.stdout.removeListener("data", onStdoutData);
337
+ proc.stderr.removeListener("data", onStderrData);
338
+ finish(0);
339
+ terminateChild();
340
+ }, AGENT_END_GRACE_MS);
341
+ semanticCompletionTimer.unref();
342
+ };
343
+
344
+ const onStdoutData = (chunk: Buffer) => {
345
+ buffer += chunk.toString();
346
+ const lines = buffer.split(/\r?\n/);
347
+ buffer = lines.pop() || "";
348
+ for (const line of lines) flushLine(line);
349
+ };
350
+
351
+ const onStderrData = (chunk: Buffer) => {
352
+ result.stderr += chunk.toString();
353
+ };
354
+
355
+ proc.stdout.on("data", onStdoutData);
356
+ proc.stderr.on("data", onStderrData);
357
+
358
+ proc.on("close", (code) => {
359
+ didClose = true;
360
+ if (buffer.trim()) flushBufferedLines(buffer);
361
+ finish(code ?? 0);
362
+ });
363
+
364
+ proc.on("error", (err) => {
365
+ if (!result.stderr.trim()) result.stderr = err.message;
366
+ finish(1);
367
+ });
368
+
369
+ // Abort handling
370
+ if (signal) {
371
+ abortHandler = () => {
372
+ if (didClose || settled) return;
373
+ wasAborted = true;
374
+ terminateChild();
375
+ };
376
+ if (signal.aborted) abortHandler();
377
+ else signal.addEventListener("abort", abortHandler, { once: true });
378
+ }
379
+ });
380
+
381
+ result.exitCode = exitCode;
382
+ return normalizeFlowResult(result, wasAborted);
383
+ } finally {
384
+ cleanupFlowTempDir(forkSessionTmpDir);
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Concurrency helper
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /**
393
+ * Map over items with a bounded number of concurrent workers.
394
+ */
395
+ export async function mapFlowConcurrent<TIn, TOut>(
396
+ items: TIn[],
397
+ concurrency: number,
398
+ fn: (item: TIn, index: number) => Promise<TOut>,
399
+ ): Promise<TOut[]> {
400
+ if (items.length === 0) return [];
401
+ const limit = Math.max(1, Math.min(concurrency, items.length));
402
+ const results: TOut[] = new Array(items.length);
403
+ let nextIndex = 0;
404
+
405
+ const worker = async () => {
406
+ while (true) {
407
+ const i = nextIndex++;
408
+ if (i >= items.length) return;
409
+ results[i] = await fn(items[i], i);
410
+ }
411
+ };
412
+
413
+ await Promise.all(Array.from({ length: limit }, () => worker()));
414
+ return results;
415
+ }