pi-agent-flow 1.3.0 → 1.4.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/package.json +1 -1
- package/src/flow.ts +89 -13
- package/src/structured-output.ts +42 -6
- package/src/types.ts +3 -1
package/package.json
CHANGED
package/src/flow.ts
CHANGED
|
@@ -26,11 +26,15 @@ const isWindows = process.platform === "win32";
|
|
|
26
26
|
const SIGKILL_TIMEOUT_MS = 5000;
|
|
27
27
|
const AGENT_END_GRACE_MS = 2000;
|
|
28
28
|
const DEFAULT_FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
29
|
+
const FLOW_TIME_BUDGET_WARNING_MS = 2 * 60 * 1000; // warn 2 min before kill
|
|
30
|
+
const FLOW_FINAL_URGE_MS = 30 * 1000; // final urge 30 s before kill
|
|
31
|
+
const REPORTING_GRACE_MS = 10_000; // grace period after timeout for agent to report findings
|
|
29
32
|
const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
|
|
30
33
|
const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
|
|
31
34
|
const FLOW_STACK_ENV = "PI_FLOW_STACK";
|
|
32
35
|
const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
|
|
33
36
|
const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
|
|
37
|
+
const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
|
|
34
38
|
export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
|
|
35
39
|
const PI_OFFLINE_ENV = "PI_OFFLINE";
|
|
36
40
|
|
|
@@ -140,6 +144,7 @@ function buildFlowArgs(
|
|
|
140
144
|
maxDepth: number = 0,
|
|
141
145
|
toolOptimize: boolean = false,
|
|
142
146
|
structuredOutput: boolean = true,
|
|
147
|
+
timeoutMs?: number,
|
|
143
148
|
): string[] {
|
|
144
149
|
const args: string[] = [
|
|
145
150
|
"--mode",
|
|
@@ -210,11 +215,17 @@ function buildFlowArgs(
|
|
|
210
215
|
? `You may delegate to sub-flows (depth ${currentDepth}/${effectiveMaxDepth}).`
|
|
211
216
|
: `You may NOT delegate to sub-flows (depth limit reached).`;
|
|
212
217
|
|
|
218
|
+
const timeBudgetHint =
|
|
219
|
+
timeoutMs && timeoutMs > 0
|
|
220
|
+
? `Time budget: ${Math.round(timeoutMs / 1000)}s total. You will be hard-killed when time expires — plan and prioritize accordingly.\n`
|
|
221
|
+
: "";
|
|
222
|
+
|
|
213
223
|
const activation =
|
|
214
224
|
`\n\n<activation flow="${flow.name}" depth="${currentDepth}" tools="${availableTools}">\n` +
|
|
215
225
|
`You are a [${flow.name}] agent operating at depth ${currentDepth}.\n` +
|
|
216
226
|
`Available tools: ${availableTools}.\n` +
|
|
217
227
|
`${delegationRule}\n` +
|
|
228
|
+
`${timeBudgetHint}` +
|
|
218
229
|
`Do not attempt to use any tool outside the available set — it will fail.\n` +
|
|
219
230
|
`</activation>`;
|
|
220
231
|
|
|
@@ -241,7 +252,7 @@ function buildFlowArgs(
|
|
|
241
252
|
` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
|
|
242
253
|
` ],\n` +
|
|
243
254
|
` "commands": [\n` +
|
|
244
|
-
` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash" }\n` +
|
|
255
|
+
` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash", "executionTime": "1.2s (normal)" }\n` +
|
|
245
256
|
` ],\n` +
|
|
246
257
|
` "notDone": [\n` +
|
|
247
258
|
` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
|
|
@@ -406,6 +417,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
406
417
|
forkSessionTmpPath = forkTmp.filePath;
|
|
407
418
|
}
|
|
408
419
|
|
|
420
|
+
// Resolve timeout: explicit option > env var > default (10 min)
|
|
421
|
+
const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
|
|
422
|
+
const envTimeout = envTimeoutRaw !== undefined ? (() => {
|
|
423
|
+
const n = Number(envTimeoutRaw);
|
|
424
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
425
|
+
})() : null;
|
|
426
|
+
const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
|
|
427
|
+
|
|
409
428
|
try {
|
|
410
429
|
const piArgs = buildFlowArgs(
|
|
411
430
|
flow,
|
|
@@ -416,17 +435,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
416
435
|
maxDepth,
|
|
417
436
|
toolOptimize,
|
|
418
437
|
structuredOutput,
|
|
438
|
+
effectiveTimeout,
|
|
419
439
|
);
|
|
420
440
|
let wasAborted = false;
|
|
421
441
|
|
|
422
|
-
// Resolve timeout: explicit option > env var > default (10 min)
|
|
423
|
-
const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
|
|
424
|
-
const envTimeout = envTimeoutRaw !== undefined ? (() => {
|
|
425
|
-
const n = Number(envTimeoutRaw);
|
|
426
|
-
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
427
|
-
})() : null;
|
|
428
|
-
const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
|
|
429
|
-
|
|
430
442
|
const exitCode = await new Promise<number>((resolve) => {
|
|
431
443
|
const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
|
|
432
444
|
const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
|
|
@@ -446,18 +458,25 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
446
458
|
[FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
|
|
447
459
|
[FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
|
|
448
460
|
[PI_OFFLINE_ENV]: "1",
|
|
461
|
+
[FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
|
|
449
462
|
},
|
|
450
463
|
});
|
|
451
464
|
|
|
465
|
+
let stdinEnded = false;
|
|
466
|
+
const endStdin = () => {
|
|
467
|
+
if (stdinEnded) return;
|
|
468
|
+
stdinEnded = true;
|
|
469
|
+
try { proc.stdin.end(); } catch { /* ignore */ }
|
|
470
|
+
};
|
|
452
471
|
proc.stdin.on("error", () => {
|
|
453
472
|
/* ignore broken pipe on fast exits */
|
|
454
473
|
});
|
|
455
|
-
proc.stdin.end();
|
|
456
474
|
|
|
475
|
+
let abortHandler: (() => void) | undefined;
|
|
457
476
|
let buffer = "";
|
|
458
477
|
let didClose = false;
|
|
459
478
|
let settled = false;
|
|
460
|
-
let
|
|
479
|
+
let timeoutFired = false;
|
|
461
480
|
let semanticCompletionTimer: NodeJS.Timeout | undefined;
|
|
462
481
|
|
|
463
482
|
const clearSemanticCompletionTimer = () => {
|
|
@@ -468,6 +487,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
468
487
|
};
|
|
469
488
|
|
|
470
489
|
const terminateChild = () => {
|
|
490
|
+
endStdin();
|
|
471
491
|
if (isWindows) {
|
|
472
492
|
if (proc.pid !== undefined) {
|
|
473
493
|
const killer = spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)], {
|
|
@@ -491,6 +511,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
491
511
|
const finish = (code: number) => {
|
|
492
512
|
if (settled) return;
|
|
493
513
|
settled = true;
|
|
514
|
+
endStdin();
|
|
494
515
|
clearSemanticCompletionTimer();
|
|
495
516
|
if (signal && abortHandler) {
|
|
496
517
|
signal.removeEventListener("abort", abortHandler);
|
|
@@ -554,18 +575,73 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
554
575
|
abortHandler = () => {
|
|
555
576
|
if (didClose || settled) return;
|
|
556
577
|
wasAborted = true;
|
|
578
|
+
endStdin();
|
|
557
579
|
terminateChild();
|
|
558
580
|
};
|
|
559
581
|
if (signal.aborted) abortHandler();
|
|
560
582
|
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
561
583
|
}
|
|
562
584
|
|
|
563
|
-
// Execution timeout —
|
|
585
|
+
// Execution timeout — two-stage: urge message, then stdin injection, then grace, then hard kill
|
|
564
586
|
if (effectiveTimeout > 0) {
|
|
587
|
+
// Warning timer: notify the parent UI that the child is about to be killed.
|
|
588
|
+
// NOTE: True mid-flight injection into the child's context requires pi-core
|
|
589
|
+
// support for out-of-band messages. Until then, the agent only knows its
|
|
590
|
+
// budget from the initial prompt; this warning is parent-side only.
|
|
591
|
+
const warningMs = effectiveTimeout - FLOW_TIME_BUDGET_WARNING_MS;
|
|
592
|
+
if (warningMs > 0) {
|
|
593
|
+
const warnTimer = setTimeout(() => {
|
|
594
|
+
if (didClose || settled) return;
|
|
595
|
+
const remainingSec = Math.round(FLOW_TIME_BUDGET_WARNING_MS / 1000);
|
|
596
|
+
const warnMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. The agent should wrap up now.`;
|
|
597
|
+
result.stderr += warnMsg;
|
|
598
|
+
// Force an update so the parent UI shows the warning immediately.
|
|
599
|
+
emitUpdate();
|
|
600
|
+
}, warningMs);
|
|
601
|
+
warnTimer.unref();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Final urge timer: stronger warning 30 s before hard timeout
|
|
605
|
+
const urgeMs = effectiveTimeout - FLOW_FINAL_URGE_MS;
|
|
606
|
+
if (urgeMs > 0) {
|
|
607
|
+
const urgeTimer = setTimeout(() => {
|
|
608
|
+
if (didClose || settled) return;
|
|
609
|
+
const remainingSec = Math.round(FLOW_FINAL_URGE_MS / 1000);
|
|
610
|
+
const urgeMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. Stop all work and output your structured findings.`;
|
|
611
|
+
result.stderr += urgeMsg;
|
|
612
|
+
emitUpdate();
|
|
613
|
+
}, urgeMs);
|
|
614
|
+
urgeTimer.unref();
|
|
615
|
+
}
|
|
616
|
+
|
|
565
617
|
const timeoutTimer = setTimeout(() => {
|
|
566
618
|
if (didClose || settled) return;
|
|
619
|
+
timeoutFired = true;
|
|
567
620
|
result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
|
|
568
|
-
|
|
621
|
+
emitUpdate();
|
|
622
|
+
|
|
623
|
+
// Stage 1: Send timeout instruction to child via stdin
|
|
624
|
+
try {
|
|
625
|
+
if (!stdinEnded) {
|
|
626
|
+
proc.stdin.write(
|
|
627
|
+
JSON.stringify({
|
|
628
|
+
type: "flow_timeout",
|
|
629
|
+
instruction: "stop and report all findings immediately",
|
|
630
|
+
}) + "\n",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
/* ignore broken pipe */
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Stage 2: Grace period before hard kill
|
|
638
|
+
const graceTimer = setTimeout(() => {
|
|
639
|
+
if (didClose || settled) return;
|
|
640
|
+
result.stopReason = "timeout";
|
|
641
|
+
result.errorMessage = `Flow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
|
|
642
|
+
terminateChild();
|
|
643
|
+
}, REPORTING_GRACE_MS);
|
|
644
|
+
graceTimer.unref();
|
|
569
645
|
}, effectiveTimeout);
|
|
570
646
|
timeoutTimer.unref();
|
|
571
647
|
}
|
package/src/structured-output.ts
CHANGED
|
@@ -110,8 +110,30 @@ export function enrichStructuredOutputCommands(
|
|
|
110
110
|
structuredOutput: FlowStructuredOutput,
|
|
111
111
|
messages: Message[],
|
|
112
112
|
): FlowStructuredOutput {
|
|
113
|
+
// Build a map of toolCallId -> execution time string from tool result messages.
|
|
114
|
+
const timingMap = new Map<string, string>();
|
|
115
|
+
for (const msg of messages) {
|
|
116
|
+
if (msg.role !== "tool" || !Array.isArray(msg.content)) continue;
|
|
117
|
+
const id =
|
|
118
|
+
(msg as unknown as { toolCallId?: string }).toolCallId ||
|
|
119
|
+
(msg as unknown as { tool_call_id?: string }).tool_call_id ||
|
|
120
|
+
"";
|
|
121
|
+
if (!id) continue;
|
|
122
|
+
const text = msg.content
|
|
123
|
+
.filter((c: { type: string; text?: string }) => c.type === "text" && typeof c.text === "string")
|
|
124
|
+
.map((c: { text: string }) => c.text)
|
|
125
|
+
.join("");
|
|
126
|
+
const match = text.match(/\[Execution time: ([^\]]+)\]/);
|
|
127
|
+
if (match) timingMap.set(id, match[1]);
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
// Collect actual verbatim bash commands (including those inside batch ops)
|
|
114
|
-
|
|
131
|
+
// alongside their toolCallId so we can look up execution time.
|
|
132
|
+
interface BashEntry {
|
|
133
|
+
command: string;
|
|
134
|
+
toolCallId?: string;
|
|
135
|
+
}
|
|
136
|
+
const actualBashEntries: BashEntry[] = [];
|
|
115
137
|
for (const msg of messages) {
|
|
116
138
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
117
139
|
for (const part of msg.content) {
|
|
@@ -121,10 +143,14 @@ export function enrichStructuredOutputCommands(
|
|
|
121
143
|
(part as unknown as { toolName?: string }).toolName ||
|
|
122
144
|
"";
|
|
123
145
|
const args = part.arguments || part.input || {};
|
|
146
|
+
const toolCallId =
|
|
147
|
+
(part as unknown as { toolCallId?: string }).toolCallId ||
|
|
148
|
+
(part as unknown as { tool_call_id?: string }).tool_call_id ||
|
|
149
|
+
"";
|
|
124
150
|
|
|
125
151
|
if (name === "bash") {
|
|
126
152
|
const cmd = (args.command as string) || "";
|
|
127
|
-
if (cmd)
|
|
153
|
+
if (cmd) actualBashEntries.push({ command: cmd, toolCallId });
|
|
128
154
|
} else if (name === "batch") {
|
|
129
155
|
const ops = Array.isArray(args.o)
|
|
130
156
|
? args.o
|
|
@@ -137,20 +163,30 @@ export function enrichStructuredOutputCommands(
|
|
|
137
163
|
if (!op) continue;
|
|
138
164
|
const opType = (op.o ?? op.op) as string;
|
|
139
165
|
if (opType === "bash" && op.command) {
|
|
140
|
-
|
|
166
|
+
// Batch-nested bash ops don't have individual toolCallIds,
|
|
167
|
+
// so execution time can't be correlated.
|
|
168
|
+
actualBashEntries.push({ command: op.command as string });
|
|
141
169
|
}
|
|
142
170
|
}
|
|
143
171
|
}
|
|
144
172
|
}
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
if (
|
|
175
|
+
if (actualBashEntries.length === 0) return structuredOutput;
|
|
148
176
|
|
|
149
177
|
// Replace paraphrased bash commands with actual verbatim ones, in order.
|
|
150
178
|
let bashIdx = 0;
|
|
151
179
|
const enrichedCommands = structuredOutput.commands.map((cmd) => {
|
|
152
|
-
if (cmd.tool === "bash" && bashIdx <
|
|
153
|
-
|
|
180
|
+
if (cmd.tool === "bash" && bashIdx < actualBashEntries.length) {
|
|
181
|
+
const entry = actualBashEntries[bashIdx++];
|
|
182
|
+
const executionTime = entry.toolCallId
|
|
183
|
+
? timingMap.get(entry.toolCallId)
|
|
184
|
+
: undefined;
|
|
185
|
+
return {
|
|
186
|
+
command: entry.command,
|
|
187
|
+
tool: "bash",
|
|
188
|
+
...(executionTime !== undefined ? { executionTime } : {}),
|
|
189
|
+
};
|
|
154
190
|
}
|
|
155
191
|
return { command: cmd.command, tool: cmd.tool };
|
|
156
192
|
});
|
package/src/types.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface CommandEntry {
|
|
|
43
43
|
command: string;
|
|
44
44
|
/** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
|
|
45
45
|
tool?: string;
|
|
46
|
+
/** Execution time classification from the timed bash wrapper (e.g. "3.5s (normal)"). */
|
|
47
|
+
executionTime?: string;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** Compressed representation of a flow result for child context inheritance. */
|
|
@@ -176,7 +178,7 @@ export function isFlowComplete(r: Pick<SingleResult, "messages" | "sawAgentEnd">
|
|
|
176
178
|
export function isFlowSuccess(r: SingleResult): boolean {
|
|
177
179
|
if (r.exitCode === -1) return false;
|
|
178
180
|
if (isFlowComplete(r)) return true;
|
|
179
|
-
return r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted";
|
|
181
|
+
return r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted" && r.stopReason !== "timeout";
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
/** Whether a result represents an error. */
|