pi-agent-flow 1.4.1 → 1.4.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/ambient.d.ts CHANGED
@@ -46,6 +46,10 @@ declare module "@mariozechner/pi-coding-agent" {
46
46
  renderCall?: (...args: any[]) => any;
47
47
  renderResult?: (...args: any[]) => any;
48
48
  };
49
+ /** Test-only exports provided by tests/__mocks__/pi-coding-agent.ts. */
50
+ export const bashToolExecuteCalls: any[][];
51
+ export function __setBashToolExecuteImpl(fn: (...args: any[]) => Promise<any>): void;
52
+ export function __resetBashToolMock(): void;
49
53
  }
50
54
 
51
55
  declare module "@mariozechner/pi-tui" {
package/src/flow.ts CHANGED
@@ -29,12 +29,14 @@ const DEFAULT_FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
29
29
  const FLOW_TIME_BUDGET_WARNING_MS = 2 * 60 * 1000; // warn 2 min before kill
30
30
  const FLOW_FINAL_URGE_MS = 30 * 1000; // final urge 30 s before kill
31
31
  const REPORTING_GRACE_MS = 10_000; // grace period after timeout for agent to report findings
32
+ const FLOW_TOOL_SUMMARY_GRACE_MS = FLOW_FINAL_URGE_MS; // bash/tool abort lead time so the agent can summarize
32
33
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
33
34
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
34
35
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
35
36
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
36
37
  const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
37
38
  const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
39
+ const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
38
40
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
39
41
  const PI_OFFLINE_ENV = "PI_OFFLINE";
40
42
 
@@ -217,7 +219,7 @@ function buildFlowArgs(
217
219
 
218
220
  const timeBudgetHint =
219
221
  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`
222
+ ? `Time budget: ${Math.round(timeoutMs / 1000)}s total. Long-running tools may be interrupted near the deadline to preserve final-summary time; if a tool reports [Flow timeout], stop tool use and output structured findings immediately.\n`
221
223
  : "";
222
224
 
223
225
  const activation =
@@ -443,6 +445,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
443
445
  const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
444
446
  const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
445
447
  const propagatedStack = [...parentFlowStack, normalizedFlowName];
448
+ const proportionalGraceMs = Math.floor(effectiveTimeout * 0.1);
449
+ const minimumGraceMs = effectiveTimeout >= 10_000 ? 1_000 : Math.floor(effectiveTimeout / 2);
450
+ const toolSummaryGraceMs = Math.min(
451
+ FLOW_TOOL_SUMMARY_GRACE_MS,
452
+ Math.max(0, effectiveTimeout),
453
+ Math.max(minimumGraceMs, proportionalGraceMs),
454
+ );
446
455
  const { command, prefixArgs } = resolveFlowSpawn();
447
456
  const proc = spawn(command, [...prefixArgs, ...piArgs], {
448
457
  cwd: taskCwd ?? cwd,
@@ -458,7 +467,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
458
467
  [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
459
468
  [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
460
469
  [PI_OFFLINE_ENV]: "1",
461
- [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
470
+ ...(effectiveTimeout > 0 ? {
471
+ [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
472
+ [FLOW_TOOL_SUMMARY_GRACE_ENV]: String(toolSummaryGraceMs),
473
+ } : {
474
+ [FLOW_DEADLINE_ENV]: "",
475
+ [FLOW_TOOL_SUMMARY_GRACE_ENV]: "0",
476
+ }),
462
477
  },
463
478
  });
464
479
 
@@ -471,6 +486,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
471
486
  proc.stdin.on("error", () => {
472
487
  /* ignore broken pipe on fast exits */
473
488
  });
489
+ proc.stdin.end();
474
490
 
475
491
  let abortHandler: (() => void) | undefined;
476
492
  let buffer = "";
@@ -582,7 +598,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
582
598
  else signal.addEventListener("abort", abortHandler, { once: true });
583
599
  }
584
600
 
585
- // Execution timeout — two-stage: urge message, then stdin injection, then grace, then hard kill
601
+ // Execution timeout — two-stage: parent-side warnings, tool-level deadline abort, then grace, then hard kill
586
602
  if (effectiveTimeout > 0) {
587
603
  // Warning timer: notify the parent UI that the child is about to be killed.
588
604
  // NOTE: True mid-flight injection into the child's context requires pi-core
@@ -620,21 +636,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
620
636
  result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
621
637
  emitUpdate();
622
638
 
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
639
+ // Grace period before hard kill
638
640
  const graceTimer = setTimeout(() => {
639
641
  if (didClose || settled) return;
640
642
  result.stopReason = "timeout";
package/src/timed-bash.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  * Wraps the built-in bash tool to append execution-time classification
5
5
  * to every result. This gives the LLM concrete feedback to self-correct
6
6
  * strategy (e.g. switch from bash grep to grep tool, batch commands, etc.).
7
+ *
8
+ * Child flows also receive a hard deadline from the parent runner. When a
9
+ * bash command is still running near that deadline, this wrapper aborts just
10
+ * the bash tool and returns an explicit instruction to stop using tools and
11
+ * summarize. That preserves the child agent process long enough to produce
12
+ * its final structured report instead of being killed while a shell command is
13
+ * still active.
7
14
  */
8
15
 
9
16
  import { createBashToolDefinition } from "@mariozechner/pi-coding-agent";
@@ -21,6 +28,10 @@ export interface TimingReport {
21
28
  label: string;
22
29
  }
23
30
 
31
+ const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
32
+ const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
33
+ const DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS = 30_000;
34
+
24
35
  /** Classify duration into user-defined tiers with actionable feedback. */
25
36
  export function classifyDuration(ms: number): TimingReport {
26
37
  const s = ms / 1000;
@@ -56,6 +67,90 @@ export function formatTimingAppendix(report: TimingReport): string {
56
67
  return `\n\n[Execution time: ${report.label}]`;
57
68
  }
58
69
 
70
+ function parsePositiveSafeInteger(raw: unknown): number | null {
71
+ if (typeof raw !== "string" || !raw.trim()) return null;
72
+ const parsed = Number(raw);
73
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
74
+ }
75
+
76
+ function parseNonNegativeSafeInteger(raw: unknown): number | null {
77
+ if (typeof raw !== "string" || !raw.trim()) return null;
78
+ const parsed = Number(raw);
79
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
80
+ }
81
+
82
+ function getFlowDeadlineMs(): number | null {
83
+ return parsePositiveSafeInteger(process.env[FLOW_DEADLINE_ENV]);
84
+ }
85
+
86
+ function getFlowToolSummaryGraceMs(): number {
87
+ return parseNonNegativeSafeInteger(process.env[FLOW_TOOL_SUMMARY_GRACE_ENV]) ?? DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS;
88
+ }
89
+
90
+ function formatDeadlineAppendix(): string {
91
+ return "\n\n[Flow timeout] Bash command was interrupted to preserve time for the final flow summary. Stop running tools and return structured findings now.";
92
+ }
93
+
94
+ function appendTextToToolResult(result: any, text: string): void {
95
+ const textItem = result?.content?.find?.((c: any) => c.type === "text");
96
+ if (textItem && typeof textItem.text === "string") {
97
+ textItem.text += text;
98
+ } else if (Array.isArray(result?.content)) {
99
+ result.content.push({ type: "text", text: text.trim() });
100
+ }
101
+ }
102
+
103
+ function createDeadlineSignal(parentSignal: AbortSignal | undefined): {
104
+ signal: AbortSignal | undefined;
105
+ cleanup: () => void;
106
+ wasDeadlineAbort: () => boolean;
107
+ } {
108
+ const deadlineMs = getFlowDeadlineMs();
109
+ if (!deadlineMs) {
110
+ return { signal: parentSignal, cleanup: () => undefined, wasDeadlineAbort: () => false };
111
+ }
112
+
113
+ const summaryGraceMs = getFlowToolSummaryGraceMs();
114
+ const abortAtMs = deadlineMs - summaryGraceMs;
115
+ const delayMs = abortAtMs - Date.now();
116
+
117
+ const controller = new AbortController();
118
+ let deadlineAbort = false;
119
+ let timer: NodeJS.Timeout | undefined;
120
+ let relayParentAbort: (() => void) | undefined;
121
+
122
+ const abortForDeadline = () => {
123
+ if (controller.signal.aborted) return;
124
+ deadlineAbort = true;
125
+ controller.abort(new Error("Flow deadline reached while bash command was running."));
126
+ };
127
+
128
+ if (parentSignal?.aborted) {
129
+ controller.abort(parentSignal.reason);
130
+ } else if (parentSignal) {
131
+ relayParentAbort = () => controller.abort(parentSignal.reason);
132
+ parentSignal.addEventListener("abort", relayParentAbort, { once: true });
133
+ }
134
+
135
+ if (delayMs <= 0) {
136
+ abortForDeadline();
137
+ } else {
138
+ timer = setTimeout(abortForDeadline, delayMs);
139
+ timer.unref?.();
140
+ }
141
+
142
+ return {
143
+ signal: controller.signal,
144
+ cleanup: () => {
145
+ if (timer) clearTimeout(timer);
146
+ if (parentSignal && relayParentAbort) {
147
+ parentSignal.removeEventListener("abort", relayParentAbort);
148
+ }
149
+ },
150
+ wasDeadlineAbort: () => deadlineAbort,
151
+ };
152
+ }
153
+
59
154
  /**
60
155
  * Create a timed bash tool definition that wraps the built-in one.
61
156
  * Extensions override built-in tools by name, so this replaces the
@@ -93,11 +188,12 @@ export function createTimedBashToolDefinition(
93
188
  ctx: any,
94
189
  ) {
95
190
  const start = Date.now();
191
+ const deadlineSignal = createDeadlineSignal(signal);
96
192
  try {
97
193
  const result = await original.execute(
98
194
  toolCallId,
99
195
  params,
100
- signal,
196
+ deadlineSignal.signal,
101
197
  onUpdate,
102
198
  ctx,
103
199
  );
@@ -105,13 +201,10 @@ export function createTimedBashToolDefinition(
105
201
  const report = classifyDuration(duration);
106
202
  const appendix = formatTimingAppendix(report);
107
203
 
108
- const textItem = result.content?.find(
109
- (c: any) => c.type === "text",
110
- );
111
- if (textItem && typeof textItem.text === "string") {
112
- textItem.text += appendix;
113
- } else if (result.content) {
114
- result.content.push({ type: "text", text: appendix.trim() });
204
+ appendTextToToolResult(result, appendix);
205
+ if (deadlineSignal.wasDeadlineAbort()) {
206
+ appendTextToToolResult(result, formatDeadlineAppendix());
207
+ result.isError = true;
115
208
  }
116
209
  return result;
117
210
  } catch (err: any) {
@@ -119,10 +212,22 @@ export function createTimedBashToolDefinition(
119
212
  const report = classifyDuration(duration);
120
213
  const appendix = formatTimingAppendix(report);
121
214
 
215
+ if (deadlineSignal.wasDeadlineAbort()) {
216
+ const message = typeof err?.message === "string" && err.message.trim()
217
+ ? `${err.message}${appendix}${formatDeadlineAppendix()}`
218
+ : `${appendix.trim()}${formatDeadlineAppendix()}`;
219
+ return {
220
+ content: [{ type: "text", text: message }],
221
+ isError: true,
222
+ };
223
+ }
224
+
122
225
  if (err?.message && typeof err.message === "string") {
123
226
  err.message += appendix;
124
227
  }
125
228
  throw err;
229
+ } finally {
230
+ deadlineSignal.cleanup();
126
231
  }
127
232
  },
128
233
  };