pi-agent-flow 1.2.7 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.2.7",
3
+ "version": "1.4.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,6 +11,7 @@ export const MAX_BYTES = 50 * 1024; // 50KB
11
11
  export const SAFE_FULL_READ_LIMIT = 300;
12
12
  export const TARGETED_READ_LINE_LIMIT = 1000;
13
13
  export const MAX_CONTEXT_MAP_ENTRIES = 100;
14
+ export const MAX_TOTAL_RESULT_LINES = 1500;
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Types
@@ -16,6 +16,7 @@ import {
16
16
  MAX_BYTES,
17
17
  SAFE_FULL_READ_LIMIT,
18
18
  TARGETED_READ_LINE_LIMIT,
19
+ MAX_TOTAL_RESULT_LINES,
19
20
  } from "./constants.js";
20
21
  import {
21
22
  normalizeToLF,
@@ -212,6 +213,8 @@ export async function executeOperations(
212
213
  const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
213
214
  const errors: { path: string; op: string; message: string; hint?: string }[] = [];
214
215
  const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
216
+ const aggregateLimitSkipped: { path: string }[] = [];
217
+ let aggregateLinesRead = 0;
215
218
  const includeLimitWarnings = options.includeLimitWarnings ?? true;
216
219
 
217
220
  for (const op of operations) {
@@ -232,6 +235,18 @@ export async function executeOperations(
232
235
 
233
236
  switch (op.o) {
234
237
  case "read": {
238
+ if (aggregateLinesRead >= MAX_TOTAL_RESULT_LINES) {
239
+ results.push({
240
+ op: "read",
241
+ path: op.p,
242
+ status: "skipped",
243
+ error: `Skipped: aggregate line limit of ${MAX_TOTAL_RESULT_LINES} already reached. Use separate batch/batch_read calls.`,
244
+ });
245
+ counts.skipped++;
246
+ aggregateLimitSkipped.push({ path: op.p });
247
+ break;
248
+ }
249
+
235
250
  // Access check before reading
236
251
  try {
237
252
  await fs.access(resolvedPath);
@@ -293,6 +308,8 @@ export async function executeOperations(
293
308
  });
294
309
  }
295
310
 
311
+ aggregateLinesRead += linesRead;
312
+
296
313
  results.push({
297
314
  op: "read",
298
315
  path: op.p,
@@ -404,7 +421,7 @@ export async function executeOperations(
404
421
  }
405
422
  }
406
423
  // Build the enhanced summary and content text
407
- const summary = buildSummary(counts, errors, truncatedFiles);
424
+ const summary = buildSummary(counts, errors, truncatedFiles, aggregateLimitSkipped);
408
425
  const contentText = buildContentText(summary, results);
409
426
 
410
427
  return { summary, contentText, results };
@@ -418,6 +435,7 @@ function buildSummary(
418
435
  counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
419
436
  errors: { path: string; op: string; message: string; hint?: string }[],
420
437
  truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
438
+ aggregateLimitSkipped: { path: string }[] = [],
421
439
  ): string {
422
440
  const totalSuccess =
423
441
  counts.read + counts.write + counts.edit + counts.delete;
@@ -471,6 +489,13 @@ function buildSummary(
471
489
  }
472
490
  }
473
491
 
492
+ // Aggregate line limit warnings
493
+ if (aggregateLimitSkipped.length > 0) {
494
+ parts.push(
495
+ ` ⚠ Aggregate line limit (${MAX_TOTAL_RESULT_LINES}) reached — skipped ${aggregateLimitSkipped.length} read${aggregateLimitSkipped.length > 1 ? "s" : ""}: ${aggregateLimitSkipped.map((s) => s.path).join(", ")}`,
496
+ );
497
+ }
498
+
474
499
  return parts.join("\n");
475
500
  }
476
501
 
package/src/flow.ts CHANGED
@@ -20,17 +20,19 @@ import {
20
20
  getFlowOutput,
21
21
  normalizeFlowResult,
22
22
  } from "./types.js";
23
- import { extractStructuredOutput } from "./structured-output.js";
23
+ import { extractStructuredOutput, enrichStructuredOutputCommands } from "./structured-output.js";
24
24
 
25
25
  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
29
30
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
30
31
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
31
32
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
32
33
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
33
34
  const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
35
+ const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
34
36
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
35
37
  const PI_OFFLINE_ENV = "PI_OFFLINE";
36
38
 
@@ -140,6 +142,7 @@ function buildFlowArgs(
140
142
  maxDepth: number = 0,
141
143
  toolOptimize: boolean = false,
142
144
  structuredOutput: boolean = true,
145
+ timeoutMs?: number,
143
146
  ): string[] {
144
147
  const args: string[] = [
145
148
  "--mode",
@@ -210,11 +213,17 @@ function buildFlowArgs(
210
213
  ? `You may delegate to sub-flows (depth ${currentDepth}/${effectiveMaxDepth}).`
211
214
  : `You may NOT delegate to sub-flows (depth limit reached).`;
212
215
 
216
+ const timeBudgetHint =
217
+ timeoutMs && timeoutMs > 0
218
+ ? `Time budget: ${Math.round(timeoutMs / 1000)}s total. You will be hard-killed when time expires — plan and prioritize accordingly.\n`
219
+ : "";
220
+
213
221
  const activation =
214
222
  `\n\n<activation flow="${flow.name}" depth="${currentDepth}" tools="${availableTools}">\n` +
215
223
  `You are a [${flow.name}] agent operating at depth ${currentDepth}.\n` +
216
224
  `Available tools: ${availableTools}.\n` +
217
225
  `${delegationRule}\n` +
226
+ `${timeBudgetHint}` +
218
227
  `Do not attempt to use any tool outside the available set — it will fail.\n` +
219
228
  `</activation>`;
220
229
 
@@ -227,6 +236,8 @@ function buildFlowArgs(
227
236
  `\n\n## Structured Output\n\n` +
228
237
  `End your response with a JSON code block containing:\n` +
229
238
  `\n` +
239
+ `For every command you ran, include the exact verbatim command string in the \\"command\\" field (not a paraphrase or summary).\n` +
240
+ `\n` +
230
241
  `\`\`\`json\n` +
231
242
  `{\n` +
232
243
  ` "version": "1.0",\n` +
@@ -239,7 +250,7 @@ function buildFlowArgs(
239
250
  ` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
240
251
  ` ],\n` +
241
252
  ` "commands": [\n` +
242
- ` { "command": "npm test", "tool": "bash", "target": ".", "result": "success", "output": "12 passing, 2 failing", "purpose": "Run test suite to verify fix" }\n` +
253
+ ` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash" }\n` +
243
254
  ` ],\n` +
244
255
  ` "notDone": [\n` +
245
256
  ` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
@@ -404,6 +415,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
404
415
  forkSessionTmpPath = forkTmp.filePath;
405
416
  }
406
417
 
418
+ // Resolve timeout: explicit option > env var > default (10 min)
419
+ const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
420
+ const envTimeout = envTimeoutRaw !== undefined ? (() => {
421
+ const n = Number(envTimeoutRaw);
422
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
423
+ })() : null;
424
+ const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
425
+
407
426
  try {
408
427
  const piArgs = buildFlowArgs(
409
428
  flow,
@@ -414,17 +433,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
414
433
  maxDepth,
415
434
  toolOptimize,
416
435
  structuredOutput,
436
+ effectiveTimeout,
417
437
  );
418
438
  let wasAborted = false;
419
439
 
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
-
428
440
  const exitCode = await new Promise<number>((resolve) => {
429
441
  const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
430
442
  const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
@@ -444,6 +456,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
444
456
  [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
445
457
  [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
446
458
  [PI_OFFLINE_ENV]: "1",
459
+ [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
447
460
  },
448
461
  });
449
462
 
@@ -560,6 +573,23 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
560
573
 
561
574
  // Execution timeout — kill child if it runs too long
562
575
  if (effectiveTimeout > 0) {
576
+ // Warning timer: notify the parent UI that the child is about to be killed.
577
+ // NOTE: True mid-flight injection into the child's context requires pi-core
578
+ // support for out-of-band messages. Until then, the agent only knows its
579
+ // budget from the initial prompt; this warning is parent-side only.
580
+ const warningMs = effectiveTimeout - FLOW_TIME_BUDGET_WARNING_MS;
581
+ if (warningMs > 0) {
582
+ const warnTimer = setTimeout(() => {
583
+ if (didClose || settled) return;
584
+ const remainingSec = Math.round(FLOW_TIME_BUDGET_WARNING_MS / 1000);
585
+ const warnMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. The agent should wrap up now.`;
586
+ result.stderr += warnMsg;
587
+ // Force an update so the parent UI shows the warning immediately.
588
+ emitUpdate();
589
+ }, warningMs);
590
+ warnTimer.unref();
591
+ }
592
+
563
593
  const timeoutTimer = setTimeout(() => {
564
594
  if (didClose || settled) return;
565
595
  result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
@@ -586,7 +616,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
586
616
  const flowText = getFlowOutput(normalized.messages);
587
617
  const extracted = extractStructuredOutput(flowText);
588
618
  if (extracted) {
589
- normalized.structuredOutput = extracted;
619
+ normalized.structuredOutput = enrichStructuredOutputCommands(extracted, normalized.messages);
590
620
  }
591
621
  }
592
622
 
package/src/index.ts CHANGED
@@ -448,11 +448,7 @@ function renderCompressedFlowResult(r: CompressedFlowResult): string {
448
448
  parts.push(`Files:\n${fileLines.join("\n")}`);
449
449
  }
450
450
  if (r.commands?.length) {
451
- const cmdLines = r.commands.map((c) => {
452
- const result = c.result ? ` (${c.result})` : "";
453
- const purpose = c.purpose ? ` — ${c.purpose}` : "";
454
- return ` ${c.tool ?? "cmd"}: ${c.command}${result}${purpose}`;
455
- });
451
+ const cmdLines = r.commands.map((c) => ` ${c.tool ?? "cmd"}: ${c.command}`);
456
452
  parts.push(`Commands:\n${cmdLines.join("\n")}`);
457
453
  }
458
454
  if (r.error) parts.push(`Error: ${r.error}`);
@@ -5,6 +5,7 @@
5
5
  * and validates it against the FlowStructuredOutput schema.
6
6
  */
7
7
 
8
+ import type { Message } from "@mariozechner/pi-ai";
8
9
  import type { Action, CommandEntry, FileEntry, FlowStructuredOutput, NotDoneItem } from "./types.js";
9
10
 
10
11
  type FlowStatus = FlowStructuredOutput["status"];
@@ -95,3 +96,64 @@ export function extractStructuredOutput(text: string): FlowStructuredOutput | un
95
96
  ...(parsed.extensions !== undefined ? { extensions: parsed.extensions } : {}),
96
97
  };
97
98
  }
99
+
100
+ /**
101
+ * Walk the message history and mechanically replace paraphrased bash commands
102
+ * in a structured-output `commands` array with the exact verbatim strings from
103
+ * the actual tool calls. This fixes the common LLM behaviour of summarising
104
+ * `curl -s -X POST …` as `"curl GAWA baseline"`.
105
+ *
106
+ * Batch operations are flattened so that bash commands nested inside a batch
107
+ * call are included in the same chronological order they were executed.
108
+ */
109
+ export function enrichStructuredOutputCommands(
110
+ structuredOutput: FlowStructuredOutput,
111
+ messages: Message[],
112
+ ): FlowStructuredOutput {
113
+ // Collect actual verbatim bash commands (including those inside batch ops)
114
+ const actualBashCommands: string[] = [];
115
+ for (const msg of messages) {
116
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
117
+ for (const part of msg.content) {
118
+ if (part.type !== "toolCall") continue;
119
+ const name =
120
+ part.name ||
121
+ (part as unknown as { toolName?: string }).toolName ||
122
+ "";
123
+ const args = part.arguments || part.input || {};
124
+
125
+ if (name === "bash") {
126
+ const cmd = (args.command as string) || "";
127
+ if (cmd) actualBashCommands.push(cmd);
128
+ } else if (name === "batch") {
129
+ const ops = Array.isArray(args.o)
130
+ ? args.o
131
+ : Array.isArray(args.op)
132
+ ? args.op
133
+ : Array.isArray(args.operations)
134
+ ? args.operations
135
+ : [];
136
+ for (const op of ops) {
137
+ if (!op) continue;
138
+ const opType = (op.o ?? op.op) as string;
139
+ if (opType === "bash" && op.command) {
140
+ actualBashCommands.push(op.command as string);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ if (actualBashCommands.length === 0) return structuredOutput;
148
+
149
+ // Replace paraphrased bash commands with actual verbatim ones, in order.
150
+ let bashIdx = 0;
151
+ const enrichedCommands = structuredOutput.commands.map((cmd) => {
152
+ if (cmd.tool === "bash" && bashIdx < actualBashCommands.length) {
153
+ return { command: actualBashCommands[bashIdx++], tool: "bash" };
154
+ }
155
+ return { command: cmd.command, tool: cmd.tool };
156
+ });
157
+
158
+ return { ...structuredOutput, commands: enrichedCommands };
159
+ }
package/src/types.ts CHANGED
@@ -39,18 +39,10 @@ export interface FileEntry {
39
39
 
40
40
  /** Structured command/tool invocation entry in a flow's output. */
41
41
  export interface CommandEntry {
42
- /** The command string or tool call representation. */
42
+ /** The exact verbatim command string or tool call that was executed. */
43
43
  command: string;
44
44
  /** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
45
45
  tool?: string;
46
- /** Working directory or target scope (file path, URL, etc.). */
47
- target?: string;
48
- /** Whether it succeeded, failed, or was aborted. */
49
- result?: "success" | "failure" | "partial" | "aborted";
50
- /** High-level output or error excerpt (not full stdout). */
51
- output?: string;
52
- /** Why this command was run (1 sentence). */
53
- purpose?: string;
54
46
  }
55
47
 
56
48
  /** Compressed representation of a flow result for child context inheritance. */