pi-agent-flow 1.2.0 → 1.2.2

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/index.ts CHANGED
@@ -23,10 +23,15 @@ import { mapFlowConcurrent, runFlow } from "./flow.js";
23
23
  import {
24
24
  type SingleResult,
25
25
  type FlowDetails,
26
+ type CompressedFlowResult,
27
+ type FileEntry,
28
+ type CommandEntry,
26
29
  emptyFlowUsage,
27
30
  isFlowError,
28
31
  isFlowSuccess,
32
+ getFlowOutput,
29
33
  } from "./types.js";
34
+ import { extractStructuredOutput } from "./structured-output.js";
30
35
  import { createBatchTool, createBatchReadTool } from "./batch.js";
31
36
  import {
32
37
  createWebTool,
@@ -471,6 +476,152 @@ function stripReasoningFromAssistantMessage(message: any): {
471
476
  function isJsonEqual(a: any, b: any): boolean {
472
477
  return JSON.stringify(a) === JSON.stringify(b);
473
478
  }
479
+ // ---------------------------------------------------------------------------
480
+ // Flow result compression
481
+ // ---------------------------------------------------------------------------
482
+
483
+ /**
484
+ * Module-level cache populated after each flow tool execution.
485
+ * Maps toolCallId → compressed flow result.
486
+ * Consumed by compressFlowToolResults at fork time.
487
+ */
488
+ const flowResultCache = new Map<string, CompressedFlowResult[]>();
489
+
490
+ /**
491
+ * Build compressed representations of flow results and cache them by toolCallId.
492
+ */
493
+ function cacheFlowResults(toolCallId: string, results: SingleResult[]): void {
494
+ for (const result of results) {
495
+ const so = result.structuredOutput;
496
+ if (!so) continue;
497
+ const compressed: CompressedFlowResult = {
498
+ type: result.type,
499
+ status: isFlowError(result) ? "failed" : "accomplished",
500
+ };
501
+ if (so.files.length > 0) compressed.files = so.files;
502
+ if (so.commands.length > 0) compressed.commands = so.commands;
503
+ if (result.errorMessage) compressed.error = result.errorMessage;
504
+ const existing = flowResultCache.get(toolCallId) ?? [];
505
+ existing.push(compressed);
506
+ flowResultCache.set(toolCallId, existing);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Render a compressed flow result as compact text for child context.
512
+ */
513
+ function renderCompressedFlowResult(r: CompressedFlowResult): string {
514
+ const parts: string[] = [`[Flow: ${r.type} ${r.status}]`];
515
+ if (r.files?.length) {
516
+ const fileLines = r.files.map((f) => {
517
+ const role = f.role ? ` (${f.role})` : "";
518
+ const desc = f.description ? ` — ${f.description}` : "";
519
+ return ` ${f.path}${role}${desc}`;
520
+ });
521
+ parts.push(`Files:\n${fileLines.join("\n")}`);
522
+ }
523
+ if (r.commands?.length) {
524
+ const cmdLines = r.commands.map((c) => {
525
+ const result = c.result ? ` (${c.result})` : "";
526
+ const purpose = c.purpose ? ` — ${c.purpose}` : "";
527
+ return ` ${c.tool ?? "cmd"}: ${c.command}${result}${purpose}`;
528
+ });
529
+ parts.push(`Commands:\n${cmdLines.join("\n")}`);
530
+ }
531
+ if (r.error) parts.push(`Error: ${r.error}`);
532
+ return parts.join("\n");
533
+ }
534
+
535
+ /**
536
+ * Compress flow tool results in a sanitized session snapshot.
537
+ *
538
+ * Scans for tool result messages that correspond to flow invocations
539
+ * and replaces their content with compact compressed output.
540
+ */
541
+ export function compressFlowToolResults(snapshot: string, cache: Map<string, CompressedFlowResult[]>): string {
542
+ if (cache.size === 0) return snapshot;
543
+
544
+ const lines = snapshot.trimEnd().split("\n");
545
+ const result: string[] = [];
546
+
547
+ // First pass: map toolCallId → tool name from assistant messages
548
+ const toolCallIdToName = new Map<string, string>();
549
+ for (const line of lines) {
550
+ let entry: any;
551
+ try { entry = JSON.parse(line); } catch { continue; }
552
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") continue;
553
+ const content = entry.message.content;
554
+ if (!Array.isArray(content)) continue;
555
+ for (const part of content) {
556
+ if (part.type === "toolCall" && part.toolCallId && part.name) {
557
+ toolCallIdToName.set(part.toolCallId, part.name);
558
+ }
559
+ }
560
+ }
561
+
562
+ // Second pass: compress flow tool results
563
+ for (const line of lines) {
564
+ let entry: any;
565
+ try { entry = JSON.parse(line); } catch { result.push(line); continue; }
566
+
567
+ if (entry?.type !== "message" || entry.message?.role !== "tool") {
568
+ result.push(line);
569
+ continue;
570
+ }
571
+
572
+ // Extract toolCallId — either from message-level or content-level toolResult
573
+ let toolCallId: string | undefined;
574
+ if (typeof entry.message.toolCallId === "string") {
575
+ toolCallId = entry.message.toolCallId;
576
+ } else if (Array.isArray(entry.message.content)) {
577
+ for (const part of entry.message.content) {
578
+ if (part.type === "toolResult" && part.toolCallId) {
579
+ toolCallId = part.toolCallId;
580
+ break;
581
+ }
582
+ }
583
+ }
584
+
585
+ if (!toolCallId) { result.push(line); continue; }
586
+
587
+ const toolName = toolCallIdToName.get(toolCallId);
588
+ if (toolName !== "flow") { result.push(line); continue; }
589
+
590
+ const compressed = cache.get(toolCallId);
591
+ if (!compressed || compressed.length === 0) { result.push(line); continue; }
592
+
593
+ const rendered = compressed.map(renderCompressedFlowResult).join("\n\n");
594
+
595
+ // Replace content in the tool result message
596
+ if (typeof entry.message.toolCallId === "string") {
597
+ // Format 1: toolCallId at message level, content is text array
598
+ entry = {
599
+ ...entry,
600
+ message: {
601
+ ...entry.message,
602
+ content: [{ type: "text", text: rendered }],
603
+ },
604
+ };
605
+ } else {
606
+ // Format 2: toolCallId inside content array
607
+ entry = {
608
+ ...entry,
609
+ message: {
610
+ ...entry.message,
611
+ content: entry.message.content.map((part: any) =>
612
+ part.type === "toolResult" && part.toolCallId === toolCallId
613
+ ? { ...part, content: rendered }
614
+ : part,
615
+ ),
616
+ },
617
+ };
618
+ }
619
+
620
+ result.push(JSON.stringify(entry));
621
+ }
622
+
623
+ return `${result.join("\n")}\n`;
624
+ }
474
625
 
475
626
  /**
476
627
  * Sanitize a fork session snapshot JSONL to remove only non-inheritable
@@ -533,7 +684,8 @@ function sanitizeForkSnapshot(snapshot: string | null): string | null {
533
684
  sanitizedLines.push(changed ? JSON.stringify(entry) : line);
534
685
  }
535
686
 
536
- return `${sanitizedLines.join("\n")}\n`;
687
+ const sanitized = `${sanitizedLines.join("\n")}\n`;
688
+ return compressFlowToolResults(sanitized, flowResultCache);
537
689
  }
538
690
 
539
691
  function computeActiveTools(optimize: boolean): string[] {
@@ -582,6 +734,8 @@ export default function (pi: ExtensionAPI) {
582
734
 
583
735
  // toolOptimize: CLI flag > env var > settings.json > default (true)
584
736
  let toolOptimize = true;
737
+ // structuredOutput: settings.json > default (true)
738
+ let structuredOutput = true;
585
739
  const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
586
740
  if (envToolOptimize !== undefined) {
587
741
  const parsed = parseBoolean(envToolOptimize);
@@ -613,6 +767,9 @@ export default function (pi: ExtensionAPI) {
613
767
  if (typeof flowSettings.toolOptimize === "boolean") {
614
768
  toolOptimize = flowSettings.toolOptimize;
615
769
  }
770
+ if (typeof flowSettings.structuredOutput === "boolean") {
771
+ structuredOutput = flowSettings.structuredOutput;
772
+ }
616
773
  }
617
774
 
618
775
  // Only restrict tools for the main orchestrator (depth 0).
@@ -776,8 +933,9 @@ flow [type] accomplished
776
933
  ].join("\n"),
777
934
  parameters: FlowParams,
778
935
 
779
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
936
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
780
937
  const discovery = discoverFlows(ctx.cwd, "all");
938
+ flowResultCache.clear();
781
939
  const { flows } = discovery;
782
940
  const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
783
941
 
@@ -940,6 +1098,7 @@ flow [type] accomplished
940
1098
  maxDepth: effectiveMaxDepth,
941
1099
  preventCycles,
942
1100
  toolOptimize,
1101
+ structuredOutput,
943
1102
  model: candidateModel,
944
1103
  signal,
945
1104
  onUpdate: (partial) => {
@@ -970,6 +1129,7 @@ flow [type] accomplished
970
1129
  return result;
971
1130
  });
972
1131
 
1132
+ cacheFlowResults(toolCallId, results);
973
1133
  // Build tool result with FULL flow output — no truncation
974
1134
  const successCount = results.filter((r) => isFlowSuccess(r)).length;
975
1135
  const flowReports = results.map((r) => {
package/src/render.ts CHANGED
@@ -241,6 +241,42 @@ function renderFlowExpanded(
241
241
  // Flow report (structured output)
242
242
  container.addChild(new Spacer(1));
243
243
  container.addChild(new Text(theme.fg("muted", sectionHeader("report")), 0, 0));
244
+
245
+ // Structured output summary (compact badge when available)
246
+ if (r.structuredOutput) {
247
+ const so = r.structuredOutput;
248
+ const statusColor = so.status === "complete" ? "success" : so.status === "partial" ? "warning" : "error";
249
+ container.addChild(new Text(
250
+ `${theme.fg(statusColor, `[${so.status}]`)} ${theme.fg("dim", so.summary)}`,
251
+ 0, 0,
252
+ ));
253
+ if (so.files.length > 0) {
254
+ container.addChild(new Text(theme.fg("dim", `Files: ${so.files.map((f) => f.path).join(", ")}`), 0, 0));
255
+ }
256
+ if (so.commands?.length > 0) {
257
+ const cmdLabels = so.commands.map((c) => {
258
+ const short = c.command.length > 30 ? c.command.slice(0, 30) + "..." : c.command;
259
+ return `${c.tool ?? "cmd"}: ${short}`;
260
+ });
261
+ container.addChild(new Text(theme.fg("dim", `Commands: ${cmdLabels.join(", ")}`), 0, 0));
262
+ }
263
+ if (so.notDone.length > 0) {
264
+ const notDoneText = so.notDone.map((item) => {
265
+ const details = [
266
+ item.reason ? `reason: ${item.reason}` : undefined,
267
+ item.blocker ? `blocker: ${item.blocker}` : undefined,
268
+ item.nextStep ? `next: ${item.nextStep}` : undefined,
269
+ ].filter(Boolean).join("; ");
270
+ return details ? `${item.item} (${details})` : item.item;
271
+ }).join("; ");
272
+ container.addChild(new Text(theme.fg("dim", `Not Done: ${notDoneText}`), 0, 0));
273
+ }
274
+ if (so.nextSteps.length > 0) {
275
+ container.addChild(new Text(theme.fg("dim", `Next: ${so.nextSteps.join("; ")}`), 0, 0));
276
+ }
277
+ container.addChild(new Spacer(1));
278
+ }
279
+
244
280
  if (flowOutput) {
245
281
  container.addChild(new Markdown(flowOutput.trim(), 0, 0, mdTheme));
246
282
  } else {
@@ -294,6 +330,9 @@ function renderFlowCollapsed(
294
330
  if (r.exitCode === -1 && streamingText) {
295
331
  const logContent = tailText(streamingText, contentBudget(10));
296
332
  container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
333
+ } else if (r.structuredOutput?.summary) {
334
+ const logContent = truncateChars(r.structuredOutput.summary, contentBudget(10));
335
+ container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
297
336
  } else if (flowOutput) {
298
337
  const logContent = tailText(flowOutput, contentBudget(10));
299
338
  container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Structured JSON output extraction from flow responses.
3
+ *
4
+ * Parses a JSON code block from the end of the flow's final assistant text
5
+ * and validates it against the FlowStructuredOutput schema.
6
+ */
7
+
8
+ import type { Action, CommandEntry, FileEntry, FlowStructuredOutput, NotDoneItem } from "./types.js";
9
+
10
+ type FlowStatus = FlowStructuredOutput["status"];
11
+
12
+ type StructuredOutputRecord = {
13
+ version: string;
14
+ status: FlowStatus;
15
+ summary: string;
16
+ files?: FileEntry[];
17
+ actions?: Action[];
18
+ commands?: CommandEntry[];
19
+ notDone?: NotDoneItem[];
20
+ nextSteps?: string[];
21
+ reasoning?: string[];
22
+ notes?: string[];
23
+ extensions?: Record<string, unknown>;
24
+ };
25
+
26
+ const VALID_STATUSES: FlowStatus[] = ["complete", "partial", "blocked", "failed"];
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function isOptionalArray(record: Record<string, unknown>, key: string): boolean {
33
+ return record[key] === undefined || Array.isArray(record[key]);
34
+ }
35
+
36
+ /** Minimum required fields to consider parsed JSON a valid structured output. */
37
+ function isValidStructuredOutput(obj: unknown): obj is StructuredOutputRecord {
38
+ if (!isRecord(obj)) return false;
39
+ return (
40
+ typeof obj.version === "string" &&
41
+ typeof obj.status === "string" &&
42
+ VALID_STATUSES.includes(obj.status as FlowStatus) &&
43
+ typeof obj.summary === "string" &&
44
+ isOptionalArray(obj, "files") &&
45
+ isOptionalArray(obj, "actions") &&
46
+ isOptionalArray(obj, "commands") &&
47
+ isOptionalArray(obj, "notDone") &&
48
+ isOptionalArray(obj, "nextSteps") &&
49
+ isOptionalArray(obj, "reasoning") &&
50
+ isOptionalArray(obj, "notes")
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Extract a structured JSON output block from the end of an assistant's text.
56
+ *
57
+ * Looks for a final ```json ... ``` code block, parses it, and validates
58
+ * against the FlowStructuredOutput schema. Returns undefined when the block
59
+ * is missing, malformed, or fails validation.
60
+ */
61
+ export function extractStructuredOutput(text: string): FlowStructuredOutput | undefined {
62
+ if (!text) return undefined;
63
+
64
+ // Find the last ```json ... ``` block in the text.
65
+ // Scan backward from the end to handle multiple blocks.
66
+ const allMatches = [...text.matchAll(/```json\s*([\s\S]*?)\s*```/g)];
67
+ const match = allMatches.length > 0 ? allMatches[allMatches.length - 1] : null;
68
+ if (!match || !match[1]) return undefined;
69
+
70
+ const jsonStr = match[1].trim();
71
+ if (!jsonStr) return undefined;
72
+
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(jsonStr);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+
80
+ if (!isValidStructuredOutput(parsed)) return undefined;
81
+
82
+ // Sanitize: trim string fields and normalize omitted arrays to [] for
83
+ // backward compatibility with earlier structured-output prompts.
84
+ return {
85
+ version: parsed.version.trim(),
86
+ status: parsed.status,
87
+ summary: parsed.summary.trim(),
88
+ files: parsed.files ?? [],
89
+ actions: parsed.actions ?? [],
90
+ commands: parsed.commands ?? [],
91
+ notDone: parsed.notDone ?? [],
92
+ nextSteps: parsed.nextSteps ?? [],
93
+ reasoning: parsed.reasoning ?? [],
94
+ notes: parsed.notes ?? [],
95
+ ...(parsed.extensions !== undefined ? { extensions: parsed.extensions } : {}),
96
+ };
97
+ }
package/src/types.ts CHANGED
@@ -18,6 +18,102 @@ export interface UsageStats {
18
18
  smoothedTps?: number;
19
19
  }
20
20
 
21
+ /** Structured file entry in a flow's output. */
22
+ export interface FileEntry {
23
+ /** Path to the file, relative or absolute. */
24
+ path: string;
25
+ /** Semantic role of this file in the flow's work. */
26
+ role?: "reference" | "read" | "modified" | "created" | "deleted" | "test";
27
+ /** Why this file matters (1 sentence). */
28
+ description?: string;
29
+ /** Short excerpt or snippet (not full content). */
30
+ snippet?: string;
31
+ /** Specific line ranges of interest. */
32
+ ranges?: Array<{
33
+ start: number;
34
+ end: number;
35
+ /** Free-form label like "bug", "fix", "ref", "added". */
36
+ label?: string;
37
+ }>;
38
+ }
39
+
40
+ /** Structured command/tool invocation entry in a flow's output. */
41
+ export interface CommandEntry {
42
+ /** The command string or tool call representation. */
43
+ command: string;
44
+ /** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
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
+ }
55
+
56
+ /** Compressed representation of a flow result for child context inheritance. */
57
+ export interface CompressedFlowResult {
58
+ /** Flow type (scout, build, debug, etc.). */
59
+ type: string;
60
+ /** Execution outcome. */
61
+ status: "accomplished" | "failed" | "aborted";
62
+ /** Files touched, read, or referenced. */
63
+ files?: FileEntry[];
64
+ /** Commands or tool calls executed. */
65
+ commands?: CommandEntry[];
66
+ /** Error message for failed/aborted flows. */
67
+ error?: string;
68
+ }
69
+
70
+ /** Action performed or attempted by a flow. */
71
+ export interface Action {
72
+ type: string;
73
+ description: string;
74
+ target?: string;
75
+ result?: "success" | "failure" | "partial" | "skipped";
76
+ evidence?: string;
77
+ }
78
+
79
+ /** Incomplete, skipped, blocked, or deferred work reported by a flow. */
80
+ export interface NotDoneItem {
81
+ /** The unfinished item. */
82
+ item: string;
83
+ /** Why the item was not completed. */
84
+ reason?: string;
85
+ /** Concrete blocker preventing completion, when applicable. */
86
+ blocker?: string;
87
+ /** Suggested follow-up for this item. */
88
+ nextStep?: string;
89
+ }
90
+
91
+ /** Structured JSON output from a flow run. */
92
+ export interface FlowStructuredOutput {
93
+ /** Schema version for forward compatibility. */
94
+ version: string;
95
+ /** Overall completion status. */
96
+ status: "complete" | "partial" | "blocked" | "failed";
97
+ /** 1–3 sentence summary of what was accomplished. */
98
+ summary: string;
99
+ /** Files touched, read, or referenced. */
100
+ files: FileEntry[];
101
+ /** Actions performed or attempted. */
102
+ actions: Action[];
103
+ /** Commands or tool calls executed during the flow. */
104
+ commands: CommandEntry[];
105
+ /** Incomplete, skipped, blocked, or deferred work. */
106
+ notDone: NotDoneItem[];
107
+ /** Recommended next steps or follow-up flows. */
108
+ nextSteps: string[];
109
+ /** Reasoning chains, hypotheses, inferences made during the flow. */
110
+ reasoning: string[];
111
+ /** Observations, warnings, caveats, side notes. */
112
+ notes: string[];
113
+ /** Escape hatch for flow-specific data (audit findings, debug root cause, etc.). */
114
+ extensions?: Record<string, unknown>;
115
+ }
116
+
21
117
  /** Result of a single flow invocation. */
22
118
  export interface SingleResult {
23
119
  type: string;
@@ -34,6 +130,8 @@ export interface SingleResult {
34
130
  sawAgentEnd?: boolean;
35
131
  /** Live in-progress text for status rendering; not part of the final flow report. */
36
132
  streamingText?: string;
133
+ /** Structured JSON output parsed from the flow's final response. */
134
+ structuredOutput?: FlowStructuredOutput;
37
135
  }
38
136
 
39
137
  /** Metadata attached to every tool result for rendering. */