pi-agent-flow 1.1.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.
@@ -7,8 +7,15 @@
7
7
 
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
- import { type FlowConfig, discoverFlows } from "./agents.js";
11
- import { loadFlowModels, loadFlowSettings, type FlowModelConfig } from "./config.js";
10
+ import { type FlowConfig, discoverFlows, getFlowTier } from "./agents.js";
11
+ import {
12
+ loadFlowModelConfigs,
13
+ loadFlowSettings,
14
+ resolveFlowModelCandidates,
15
+ selectFlowModelStrategy,
16
+ type LoadedFlowModelConfigs,
17
+ } from "./config.js";
18
+ import { parseFlowCliArgs } from "./cli-args.js";
12
19
  import { renderFlowCall, renderFlowResult } from "./render.js";
13
20
  import { getFlowSummaryText } from "./runner-events.js";
14
21
  import { runHooks } from "./hooks.js";
@@ -16,11 +23,16 @@ import { mapFlowConcurrent, runFlow } from "./flow.js";
16
23
  import {
17
24
  type SingleResult,
18
25
  type FlowDetails,
26
+ type CompressedFlowResult,
27
+ type FileEntry,
28
+ type CommandEntry,
19
29
  emptyFlowUsage,
20
30
  isFlowError,
21
31
  isFlowSuccess,
32
+ getFlowOutput,
22
33
  } from "./types.js";
23
- import { createBatchTool } from "./batch.js";
34
+ import { extractStructuredOutput } from "./structured-output.js";
35
+ import { createBatchTool, createBatchReadTool } from "./batch.js";
24
36
  import {
25
37
  createWebTool,
26
38
  looksLikeUrlPrompt,
@@ -73,6 +85,8 @@ const FlowParams = Type.Object({
73
85
  ),
74
86
  });
75
87
 
88
+ const inheritedCliArgs = parseFlowCliArgs(process.argv);
89
+
76
90
  // ---------------------------------------------------------------------------
77
91
  // Helpers
78
92
  // ---------------------------------------------------------------------------
@@ -97,23 +111,8 @@ function buildForkSessionSnapshotJsonl(
97
111
  if (!header || typeof header !== "object") return null;
98
112
 
99
113
  const branchEntries = sessionManager.getBranch();
100
-
101
- // Trim the current conversation turn from the fork.
102
- let trimFrom = branchEntries.length;
103
- for (let i = branchEntries.length - 1; i >= 0; i--) {
104
- const entry = branchEntries[i] as Record<string, unknown>;
105
- if (entry?.type === "message") {
106
- const msg = entry.message as Record<string, unknown> | undefined;
107
- if (msg?.role === "user") {
108
- trimFrom = i;
109
- break;
110
- }
111
- }
112
- }
113
-
114
- const trimmedEntries = branchEntries.slice(0, trimFrom);
115
114
  const lines = [JSON.stringify(header)];
116
- for (const entry of trimmedEntries) lines.push(JSON.stringify(entry));
115
+ for (const entry of branchEntries) lines.push(JSON.stringify(entry));
117
116
  return `${lines.join("\n")}\n`;
118
117
  }
119
118
 
@@ -335,46 +334,363 @@ async function confirmProjectFlowsIfNeeded(
335
334
  }
336
335
 
337
336
  // ---------------------------------------------------------------------------
338
- // Reminder flow injection (sliding always on latest user message)
337
+ // Sliding system prompt (short, inserted before latest user message)
338
+ // Always active for root flows. Appended to the static system prompt in
339
+ // before_agent_start, and inserted as a separate system message immediately
340
+ // before the latest user message by the context handler.
339
341
  // ---------------------------------------------------------------------------
340
342
 
341
- const REMINDER_TEXT =
342
- "\n\n[reminder_flow: If the answer is in context, reply; otherwise, delegate to the appropriate flow.]";
343
+ const SLIDING_PROMPT_OPEN_TAG = "<pi-flow-sliding-system>";
344
+ const SLIDING_PROMPT_CLOSE_TAG = "</pi-flow-sliding-system>";
345
+
346
+ const SLIDING_PROMPT =
347
+ `${SLIDING_PROMPT_OPEN_TAG}\n` +
348
+ `You are operating with pi-agent-flow routing.\n` +
349
+ `If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
350
+ `${SLIDING_PROMPT_CLOSE_TAG}`;
351
+
352
+ const SLIDING_PROMPT_RE = new RegExp(
353
+ SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
354
+ "[\\s\\S]*?" +
355
+ SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
356
+ "g",
357
+ );
358
+
359
+ /** Strip any old sliding system prompt tags from a string. */
360
+ function stripSlidingPromptText(text: string): string {
361
+ return text.replace(SLIDING_PROMPT_RE, "");
362
+ }
343
363
 
344
- function stripReminder(
364
+ /** Strip sliding prompt tags from content (string or text-part array). */
365
+ function stripSlidingPromptFromContent(
345
366
  content: string | { type: string; text?: string }[],
346
367
  ): string | { type: string; text?: string }[] {
347
368
  if (typeof content === "string") {
348
- return content.replace(REMINDER_TEXT, "");
369
+ return stripSlidingPromptText(content);
349
370
  }
350
371
  return content.map((c) => {
351
372
  if (c.type === "text" && typeof c.text === "string") {
352
- return { ...c, text: c.text.replace(REMINDER_TEXT, "") };
373
+ return { ...c, text: stripSlidingPromptText(c.text) };
353
374
  }
354
375
  return c;
355
376
  });
356
377
  }
357
378
 
358
- function appendReminder(
359
- content: string | { type: string; text?: string }[],
360
- ): string | { type: string; text?: string }[] {
379
+ /** Check whether content (string or text-part array) contains the sliding tag. */
380
+ function contentContainsSlidingTag(content: any): boolean {
361
381
  if (typeof content === "string") {
362
- return content + REMINDER_TEXT;
382
+ return content.includes(SLIDING_PROMPT_OPEN_TAG);
383
+ }
384
+ if (Array.isArray(content)) {
385
+ return content.some(
386
+ (part: any) =>
387
+ part.type === "text" &&
388
+ typeof part.text === "string" &&
389
+ part.text.includes(SLIDING_PROMPT_OPEN_TAG),
390
+ );
391
+ }
392
+ return false;
393
+ }
394
+
395
+ /** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
396
+ * Returns the sanitized messages and a flag indicating whether anything changed.
397
+ */
398
+ function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
399
+ let changed = false;
400
+ const result = messages
401
+ .filter((msg) => {
402
+ // Remove dedicated sliding system prompt messages
403
+ if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
404
+ changed = true;
405
+ return false;
406
+ }
407
+ return true;
408
+ })
409
+ .map((msg) => {
410
+ // Also strip stray tags embedded in user/assistant messages
411
+ if (!("content" in msg)) return msg;
412
+ const stripped = stripSlidingPromptFromContent(msg.content);
413
+ if (isJsonEqual(stripped, msg.content)) return msg;
414
+ changed = true;
415
+ return { ...msg, content: stripped };
416
+ });
417
+ return { messages: result, changed };
418
+ }
419
+
420
+ /** Build a system message containing the sliding prompt. */
421
+ function makeSlidingPromptMessage(referenceMessage?: any): any {
422
+ return {
423
+ role: "system",
424
+ content: SLIDING_PROMPT,
425
+ timestamp: referenceMessage?.timestamp,
426
+ };
427
+ }
428
+
429
+ const REASONING_PART_TYPES = new Set([
430
+ "thinking",
431
+ "reasoning",
432
+ "reasoning_content",
433
+ "reasoningContent",
434
+ ]);
435
+
436
+ const REASONING_FIELDS = [
437
+ "thinking",
438
+ "thinkingSignature",
439
+ "thinking_signature",
440
+ "reasoning",
441
+ "reasoningContent",
442
+ "reasoning_content",
443
+ "reasoningSignature",
444
+ "reasoning_signature",
445
+ ];
446
+
447
+ function stripReasoningFromAssistantMessage(message: any): {
448
+ message: any;
449
+ changed: boolean;
450
+ } {
451
+ let next = message;
452
+ let changed = false;
453
+
454
+ for (const field of REASONING_FIELDS) {
455
+ if (field in next) {
456
+ if (next === message) next = { ...message };
457
+ delete next[field];
458
+ changed = true;
459
+ }
460
+ }
461
+
462
+ if (Array.isArray(message.content)) {
463
+ const filteredContent = message.content.filter(
464
+ (part: any) => !REASONING_PART_TYPES.has(part?.type),
465
+ );
466
+ if (filteredContent.length !== message.content.length) {
467
+ if (next === message) next = { ...message };
468
+ next.content = filteredContent;
469
+ changed = true;
470
+ }
471
+ }
472
+
473
+ return { message: next, changed };
474
+ }
475
+
476
+ function isJsonEqual(a: any, b: any): boolean {
477
+ return JSON.stringify(a) === JSON.stringify(b);
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));
363
621
  }
364
- const copy = [...content];
365
- const lastTextIdx = copy.map((c, i) => (c.type === "text" ? i : -1)).filter((i) => i !== -1).pop();
366
- if (lastTextIdx === undefined) {
367
- copy.push({ type: "text", text: REMINDER_TEXT });
368
- } else {
369
- const block = copy[lastTextIdx] as { type: "text"; text: string };
370
- copy[lastTextIdx] = { ...block, text: block.text + REMINDER_TEXT };
622
+
623
+ return `${result.join("\n")}\n`;
624
+ }
625
+
626
+ /**
627
+ * Sanitize a fork session snapshot JSONL to remove only non-inheritable
628
+ * artifacts before passing full parent context to child flows: sliding system
629
+ * prompts, legacy reminders, and assistant reasoning/thinking.
630
+ */
631
+ function sanitizeForkSnapshot(snapshot: string | null): string | null {
632
+ if (!snapshot) return snapshot;
633
+
634
+ const lines = snapshot.trimEnd().split("\n");
635
+ const sanitizedLines: string[] = [];
636
+
637
+ for (const line of lines) {
638
+ let entry: any;
639
+ try {
640
+ entry = JSON.parse(line);
641
+ } catch {
642
+ sanitizedLines.push(line);
643
+ continue;
644
+ }
645
+
646
+ let changed = false;
647
+
648
+ // Drop sliding system prompt messages entirely.
649
+ if (
650
+ entry?.type === "message" &&
651
+ entry.message?.role === "system" &&
652
+ contentContainsSlidingTag(entry.message?.content)
653
+ ) {
654
+ continue;
655
+ }
656
+
657
+ if (entry?.type === "message" && entry.message) {
658
+ let message = entry.message;
659
+
660
+ if (message.role === "assistant") {
661
+ const stripped = stripReasoningFromAssistantMessage(message);
662
+ message = stripped.message;
663
+ changed ||= stripped.changed;
664
+ }
665
+
666
+ if ("content" in message) {
667
+ const originalContent = message.content;
668
+ const strippedContent = stripSlidingPromptFromContent(originalContent);
669
+
670
+ if (!isJsonEqual(strippedContent, originalContent)) {
671
+ message = {
672
+ ...message,
673
+ content: strippedContent,
674
+ };
675
+ changed = true;
676
+ }
677
+ }
678
+
679
+ if (changed) {
680
+ entry = { ...entry, message };
681
+ }
682
+ }
683
+
684
+ sanitizedLines.push(changed ? JSON.stringify(entry) : line);
371
685
  }
372
- return copy;
686
+
687
+ const sanitized = `${sanitizedLines.join("\n")}\n`;
688
+ return compressFlowToolResults(sanitized, flowResultCache);
373
689
  }
374
690
 
375
691
  function computeActiveTools(optimize: boolean): string[] {
376
692
  return optimize
377
- ? ["batch", "bash", "flow", "web"]
693
+ ? ["batch_read", "bash", "flow"]
378
694
  : ["read", "write", "edit", "batch", "bash", "flow", "web"];
379
695
  }
380
696
 
@@ -391,6 +707,10 @@ export default function (pi: ExtensionAPI) {
391
707
  description: "Block delegating to flows already in the current delegation stack (default: true).",
392
708
  type: "boolean",
393
709
  });
710
+ pi.registerFlag("flow-model-config", {
711
+ description: "Named flow model strategy from settings.json flowModelConfigs.",
712
+ type: "string",
713
+ });
394
714
  pi.registerFlag("flow-lite-model", {
395
715
  description: "Model for lite-tier flows (scout, debug).",
396
716
  type: "string",
@@ -414,6 +734,8 @@ export default function (pi: ExtensionAPI) {
414
734
 
415
735
  // toolOptimize: CLI flag > env var > settings.json > default (true)
416
736
  let toolOptimize = true;
737
+ // structuredOutput: settings.json > default (true)
738
+ let structuredOutput = true;
417
739
  const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
418
740
  if (envToolOptimize !== undefined) {
419
741
  const parsed = parseBoolean(envToolOptimize);
@@ -421,13 +743,17 @@ export default function (pi: ExtensionAPI) {
421
743
  }
422
744
 
423
745
  let discoveredFlows: FlowConfig[] = [];
424
- let flowModelConfig: FlowModelConfig = {};
746
+ let loadedFlowModelConfigs: LoadedFlowModelConfigs = {
747
+ selectedName: "default",
748
+ configs: { default: {} },
749
+ strategy: {},
750
+ };
425
751
 
426
752
  // Auto-discover flows on session start
427
753
  pi.on("session_start", async (_event, ctx) => {
428
754
  const discovery = discoverFlows(ctx.cwd, "all");
429
755
  discoveredFlows = discovery.flows;
430
- flowModelConfig = loadFlowModels(ctx.cwd);
756
+ loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
431
757
 
432
758
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
433
759
  const cliFlag = pi.getFlag("tool-optimize");
@@ -441,6 +767,9 @@ export default function (pi: ExtensionAPI) {
441
767
  if (typeof flowSettings.toolOptimize === "boolean") {
442
768
  toolOptimize = flowSettings.toolOptimize;
443
769
  }
770
+ if (typeof flowSettings.structuredOutput === "boolean") {
771
+ structuredOutput = flowSettings.structuredOutput;
772
+ }
444
773
  }
445
774
 
446
775
  // Only restrict tools for the main orchestrator (depth 0).
@@ -450,8 +779,9 @@ export default function (pi: ExtensionAPI) {
450
779
  pi.setActiveTools(computeActiveTools(toolOptimize));
451
780
  }
452
781
 
453
- // Register batch so it is available for both main agent and child flows.
782
+ // Register batch and batch_read so they are available for main agent and child flows.
454
783
  if (toolOptimize) {
784
+ pi.registerTool(createBatchReadTool());
455
785
  pi.registerTool(createBatchTool());
456
786
  }
457
787
  });
@@ -486,14 +816,17 @@ export default function (pi: ExtensionAPI) {
486
816
  }
487
817
 
488
818
  let systemPrompt = event.systemPrompt;
489
- if (webInstructions.length > 0) {
819
+ if (!toolOptimize && webInstructions.length > 0) {
490
820
  systemPrompt +=
491
821
  "\n\n## pi-web steering\n" +
492
822
  webInstructions.map((line) => `- ${line}`).join("\n");
493
823
  }
494
824
 
825
+ // Append sliding prompt to static system prompt unconditionally.
826
+ systemPrompt += "\n\n" + SLIDING_PROMPT;
827
+
495
828
  if (!canDelegate || discoveredFlows.length === 0) {
496
- return webInstructions.length > 0 ? { systemPrompt } : undefined;
829
+ return { systemPrompt };
497
830
  }
498
831
 
499
832
  return {
@@ -507,8 +840,8 @@ Before acting, reason about whether to dive into a flow:
507
840
  - [debug] — when something is broken. Investigate logs, errors, stack traces to find root cause.
508
841
  - [build] — when you are ready to build. Implement features, fix bugs, write tests.
509
842
  - [craft] — when you need a plan. Design structure, break down requirements before building.
510
- - [audit] — when you need to verify. Audit security, quality, correctness.
511
- - [ideas] — when you need fresh ideas. Start from a clean slate with only the intent.
843
+ - [audit] — when you need to verify and remediate. Audit security, quality, and correctness; fix safe issues directly.
844
+ - [ideas] — when you need fresh ideas. Use inherited context as background while exploring alternatives.
512
845
 
513
846
  Multiple independent flows? Batch them into one call:
514
847
 
@@ -518,16 +851,16 @@ Multiple independent flows? Batch them into one call:
518
851
  Each call renders as:
519
852
 
520
853
  • flow [scout] — Map the full directory structure...
521
- • flow [audit] — Audit security and quality...
854
+ • flow [audit] — Audit security and quality, then fix safe issues...
522
855
 
523
856
  Each flow returns:
524
857
 
525
858
  flow [type] accomplished
526
859
 
527
- [Summary] — what happened
528
- [Done] — completed with file:line references
529
- [Not Done] — incomplete items and reasons
530
- [Next Steps] — recommended follow-up
860
+ [Summary] — what happened and current status
861
+ [Done] — completed work with file:line references and verification results
862
+ [Not Done] — incomplete items, skipped checks, blockers, and reasons
863
+ [Next Steps] — specific recommended follow-up or next flow
531
864
 
532
865
  ### Guards
533
866
  - Depth: ${currentDepth}/${maxDepth} | Cycles: ${preventCycles ? "blocked" : "off"} | Stack: ${ancestorFlowStack.length > 0 ? ancestorFlowStack.join(" -> ") : "(root)"}
@@ -535,32 +868,50 @@ flow [type] accomplished
535
868
  };
536
869
  });
537
870
 
538
- // Sliding reminder: strip from all earlier user messages, append to latest.
539
- // Skip for child flows they have explicit <mission> instructions and
540
- // injecting the reminder is noise.
871
+ // Sliding system prompt: insert as a separate system message immediately
872
+ // before the latest user message each turn. Strips from the static
873
+ // systemPrompt to avoid duplication, then inserts separately.
874
+ // Skipped for child flows (depth > 0) — they have explicit <mission> directives.
541
875
  pi.on("context", async (event) => {
542
876
  if (currentDepth > 0) return undefined;
543
877
 
544
- const messages = event.messages;
878
+ // Always strip old sliding prompt messages to prevent accumulation
879
+ const { messages, changed: messagesChanged } = stripSlidingPromptsFromMessages(event.messages);
880
+
881
+ // Find latest user message
545
882
  const userIndices = messages
546
883
  .map((m: any, i: number) => (m.role === "user" ? i : -1))
547
884
  .filter((i: number) => i !== -1);
548
885
 
549
- if (userIndices.length === 0) return undefined;
550
-
551
- const lastUserIndex = userIndices[userIndices.length - 1];
552
-
553
- const modified = messages.map((msg: any, idx: number) => {
554
- if (msg.role !== "user") return msg;
886
+ if (userIndices.length === 0) {
887
+ // No user message yet: keep sliding prompt in the static system prompt only.
888
+ return messagesChanged ? { messages } : undefined;
889
+ }
555
890
 
556
- const content = stripReminder(msg.content);
557
- if (idx === lastUserIndex) {
558
- return { ...msg, content: appendReminder(content) };
891
+ // Strip sliding from the static systemPrompt so it only appears once,
892
+ // as a separate message right before the latest user message.
893
+ let systemPrompt = event.systemPrompt;
894
+ let systemPromptChanged = false;
895
+ if (typeof systemPrompt === "string") {
896
+ const stripped = stripSlidingPromptText(systemPrompt);
897
+ if (stripped !== systemPrompt) {
898
+ systemPrompt = stripped;
899
+ systemPromptChanged = true;
559
900
  }
560
- return { ...msg, content };
561
- });
901
+ }
562
902
 
563
- return { messages: modified };
903
+ const lastUserIndex = userIndices[userIndices.length - 1];
904
+ const modified = [
905
+ ...messages.slice(0, lastUserIndex),
906
+ makeSlidingPromptMessage(messages[lastUserIndex]),
907
+ ...messages.slice(lastUserIndex),
908
+ ];
909
+
910
+ const result: any = { messages: modified };
911
+ if (systemPromptChanged) {
912
+ result.systemPrompt = systemPrompt;
913
+ }
914
+ return result;
564
915
  });
565
916
 
566
917
  // Register the web tool
@@ -577,26 +928,53 @@ flow [type] accomplished
577
928
  "",
578
929
  "Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
579
930
  'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
580
- "States: scout (tanken), debug (kensh\u014d), build (shokunin), craft (keikaku), audit (kanshi), ideas (mushin).",
931
+ "States: scout, debug, build, craft, audit, ideas.",
581
932
  "Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
582
933
  ].join("\n"),
583
934
  parameters: FlowParams,
584
935
 
585
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
936
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
586
937
  const discovery = discoverFlows(ctx.cwd, "all");
938
+ flowResultCache.clear();
587
939
  const { flows } = discovery;
588
940
  const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
589
941
 
590
- // Resolve tiered models: CLI flags override settings.json overrides parent --model
591
- const tieredModels = {
592
- lite: (pi.getFlag("flow-lite-model") as string | undefined) ?? flowModelConfig.lite,
593
- flash: (pi.getFlag("flow-flash-model") as string | undefined) ?? flowModelConfig.flash,
594
- full: (pi.getFlag("flow-full-model") as string | undefined) ?? flowModelConfig.full,
942
+ const cliFlowModelConfig =
943
+ typeof pi.getFlag("flow-model-config") === "string"
944
+ ? (pi.getFlag("flow-model-config") as string)
945
+ : inheritedCliArgs.flowModelConfig;
946
+ const selectedFlowModelConfig = selectFlowModelStrategy(
947
+ loadedFlowModelConfigs.configs,
948
+ cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
949
+ );
950
+
951
+ const getTierOverride = (tier: "lite" | "flash" | "full"): string | undefined => {
952
+ const flagName =
953
+ tier === "lite"
954
+ ? "flow-lite-model"
955
+ : tier === "flash"
956
+ ? "flow-flash-model"
957
+ : "flow-full-model";
958
+ const runtimeValue = pi.getFlag(flagName);
959
+ if (typeof runtimeValue === "string" && runtimeValue.trim()) return runtimeValue.trim();
960
+ const inheritedValue = inheritedCliArgs.tieredModels?.[tier];
961
+ return typeof inheritedValue === "string" && inheritedValue.trim() ? inheritedValue.trim() : undefined;
595
962
  };
596
963
 
597
- // Build fork session snapshot (shared across all flows that inherit context)
598
- const forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
599
- ctx.sessionManager,
964
+ const shouldFailover = (result: SingleResult): boolean => {
965
+ if (result.stopReason === "aborted") return false;
966
+ const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
967
+ if (!text.trim()) return false;
968
+ if (text.includes("permission") || text.includes("invalid tool") || text.includes("bad settings")) {
969
+ return false;
970
+ }
971
+ return result.exitCode > 0;
972
+ };
973
+
974
+ // Build the full fork session snapshot and sanitize only non-inheritable
975
+ // artifacts before passing it to child flows.
976
+ const forkSessionSnapshotJsonl = sanitizeForkSnapshot(
977
+ buildForkSessionSnapshotJsonl(ctx.sessionManager),
600
978
  );
601
979
 
602
980
  // Collect all requested flow names
@@ -661,13 +1039,22 @@ flow [type] accomplished
661
1039
  }
662
1040
 
663
1041
  let lastStreamingText = "";
664
- let lastEmittedText: string | undefined;
1042
+ let lastEmittedSignature: string | undefined;
665
1043
  const emitProgress = (streamingText?: string) => {
666
1044
  if (!onUpdate) return;
667
1045
  if (streamingText !== undefined) lastStreamingText = streamingText;
668
1046
  const text = lastStreamingText || "";
669
- if (text === lastEmittedText) return;
670
- lastEmittedText = text;
1047
+ const signature =
1048
+ text +
1049
+ "|" +
1050
+ allResults
1051
+ .map(
1052
+ (r) =>
1053
+ `${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.errorMessage ?? ""}`,
1054
+ )
1055
+ .join(";");
1056
+ if (signature === lastEmittedSignature) return;
1057
+ lastEmittedSignature = signature;
671
1058
  onUpdate({
672
1059
  content: [{ type: "text", text }],
673
1060
  details: makeDetails([...allResults]),
@@ -683,34 +1070,66 @@ flow [type] accomplished
683
1070
  targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
684
1071
 
685
1072
  const shouldInheritContext = targetFlow?.inheritContext !== false;
686
- const result = await runFlow({
687
- cwd: ctx.cwd,
688
- flows,
689
- flowName: normalizedType,
690
- intent: item.intent,
691
- aim: item.aim,
692
- taskCwd: item.cwd,
693
- forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
694
- parentDepth: currentDepth,
695
- parentFlowStack: ancestorFlowStack,
696
- maxDepth: effectiveMaxDepth,
697
- preventCycles,
698
- toolOptimize,
699
- tieredModels,
700
- signal,
701
- onUpdate: (partial) => {
702
- if (partial.details?.results[0]) {
703
- allResults[index] = partial.details.results[0];
704
- emitProgress(partial.content?.[0]?.text);
705
- }
706
- },
707
- makeDetails,
1073
+ const tier = getFlowTier(normalizedType);
1074
+ const { candidates } = resolveFlowModelCandidates({
1075
+ tier,
1076
+ flowModel: targetFlow?.model,
1077
+ cliTierOverride: getTierOverride(tier),
1078
+ strategy: selectedFlowModelConfig.strategy,
1079
+ fallbackModel: inheritedCliArgs.fallbackModel,
708
1080
  });
709
- allResults[index] = result;
710
- emitProgress();
1081
+ const attemptModels = candidates.length > 0 ? candidates : [undefined];
1082
+ const attemptedModels: string[] = [];
1083
+ let result = allResults[index];
1084
+
1085
+ for (let attempt = 0; attempt < attemptModels.length; attempt++) {
1086
+ const candidateModel = attemptModels[attempt];
1087
+ if (candidateModel) attemptedModels.push(candidateModel);
1088
+ result = await runFlow({
1089
+ cwd: ctx.cwd,
1090
+ flows,
1091
+ flowName: normalizedType,
1092
+ intent: item.intent,
1093
+ aim: item.aim,
1094
+ taskCwd: item.cwd,
1095
+ forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
1096
+ parentDepth: currentDepth,
1097
+ parentFlowStack: ancestorFlowStack,
1098
+ maxDepth: effectiveMaxDepth,
1099
+ preventCycles,
1100
+ toolOptimize,
1101
+ structuredOutput,
1102
+ model: candidateModel,
1103
+ signal,
1104
+ onUpdate: (partial) => {
1105
+ if (partial.details?.results[0]) {
1106
+ allResults[index] = partial.details.results[0];
1107
+ emitProgress(partial.content?.[0]?.text);
1108
+ }
1109
+ },
1110
+ makeDetails,
1111
+ });
1112
+ allResults[index] = result;
1113
+ emitProgress();
1114
+ if (isFlowSuccess(result) || signal?.aborted) break;
1115
+ if (attempt < attemptModels.length - 1 && shouldFailover(result)) {
1116
+ continue;
1117
+ }
1118
+ break;
1119
+ }
1120
+
1121
+ if (result && !isFlowSuccess(result) && attemptedModels.length > 1) {
1122
+ const summary = `Model failover attempts: ${attemptedModels.join(" -> ")}`;
1123
+ const baseStderr = result.stderr.trim();
1124
+ result.stderr = baseStderr ? `${baseStderr}\n\n${summary}` : summary;
1125
+ allResults[index] = result;
1126
+ emitProgress();
1127
+ }
1128
+
711
1129
  return result;
712
1130
  });
713
1131
 
1132
+ cacheFlowResults(toolCallId, results);
714
1133
  // Build tool result with FULL flow output — no truncation
715
1134
  const successCount = results.filter((r) => isFlowSuccess(r)).length;
716
1135
  const flowReports = results.map((r) => {