pi-agent-flow 1.0.8 → 1.2.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/README.md +159 -45
- package/agents/audit.md +56 -0
- package/agents/build.md +60 -0
- package/agents/craft.md +53 -0
- package/agents/debug.md +33 -15
- package/agents/ideas.md +51 -0
- package/agents/scout.md +51 -0
- package/package.json +12 -15
- package/{agents.ts → src/agents.ts} +15 -12
- package/src/ambient.d.ts +85 -0
- package/src/batch.ts +1221 -0
- package/src/cli-args.ts +283 -0
- package/src/config.ts +305 -0
- package/{flow.ts → src/flow.ts} +120 -33
- package/{hooks.ts → src/hooks.ts} +31 -13
- package/src/index.ts +1002 -0
- package/{render-utils.ts → src/render-utils.ts} +5 -5
- package/{render.ts → src/render.ts} +66 -30
- package/src/runner-events.ts +692 -0
- package/{types.ts → src/types.ts} +5 -2
- package/src/web-tool.ts +663 -0
- package/agents/architect.md +0 -35
- package/agents/brainstorm.md +0 -29
- package/agents/code.md +0 -55
- package/agents/explore.md +0 -29
- package/agents/review.md +0 -35
- package/config.ts +0 -67
- package/index.ts +0 -645
- package/runner-cli.js +0 -254
- package/runner-events.js +0 -405
package/src/index.ts
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Flow Extension (fork-only)
|
|
3
|
+
*
|
|
4
|
+
* Delegates tasks to specialized flow states running as isolated pi processes.
|
|
5
|
+
* Each flow receives a forked snapshot of the current session context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
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";
|
|
19
|
+
import { renderFlowCall, renderFlowResult } from "./render.js";
|
|
20
|
+
import { getFlowSummaryText } from "./runner-events.js";
|
|
21
|
+
import { runHooks } from "./hooks.js";
|
|
22
|
+
import { mapFlowConcurrent, runFlow } from "./flow.js";
|
|
23
|
+
import {
|
|
24
|
+
type SingleResult,
|
|
25
|
+
type FlowDetails,
|
|
26
|
+
emptyFlowUsage,
|
|
27
|
+
isFlowError,
|
|
28
|
+
isFlowSuccess,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
import { createBatchTool, createBatchReadTool } from "./batch.js";
|
|
31
|
+
import {
|
|
32
|
+
createWebTool,
|
|
33
|
+
looksLikeUrlPrompt,
|
|
34
|
+
looksLikeWebSearchPrompt,
|
|
35
|
+
} from "./web-tool.js";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Limits
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MAX_DELEGATION_DEPTH = 3;
|
|
42
|
+
const DEFAULT_PREVENT_CYCLE_DELEGATION = true;
|
|
43
|
+
const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
|
|
44
|
+
const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
|
|
45
|
+
const FLOW_STACK_ENV = "PI_FLOW_STACK";
|
|
46
|
+
const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
|
|
47
|
+
export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Tool parameter schema
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const FlowItem = Type.Object({
|
|
54
|
+
type: Type.String({
|
|
55
|
+
description: "Flow type. Matching is case-insensitive. Must correspond to an available flow name such as scout, debug, build, craft, audit, or ideas.",
|
|
56
|
+
}),
|
|
57
|
+
intent: Type.String({
|
|
58
|
+
description: "Clear, specific mission for this flow.",
|
|
59
|
+
}),
|
|
60
|
+
aim: Type.String({
|
|
61
|
+
description: "Extreme short intent — one sentence, 5-7 words, headline-style summary of what this flow does.",
|
|
62
|
+
}),
|
|
63
|
+
cwd: Type.Optional(
|
|
64
|
+
Type.String({ description: "Working directory override for this flow." }),
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const FlowParams = Type.Object({
|
|
69
|
+
flow: Type.Array(FlowItem, {
|
|
70
|
+
description:
|
|
71
|
+
"Array of flow tasks to execute. Each runs in its own forked process. " +
|
|
72
|
+
'Example: { flow: [{ type: "scout", "intent": "Find all authentication-related code and trace JWT validation", "aim": "Find auth code and trace JWT" }, { type: "build", "intent": "Fix the bug in user registration", "aim": "Fix registration bug" }] }',
|
|
73
|
+
minItems: 1,
|
|
74
|
+
}),
|
|
75
|
+
confirmProjectFlows: Type.Optional(
|
|
76
|
+
Type.Boolean({
|
|
77
|
+
description: "Whether to prompt the user before running project-local flows. Default: true.",
|
|
78
|
+
default: true,
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const inheritedCliArgs = parseFlowCliArgs(process.argv);
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
interface FlowDepthConfig {
|
|
90
|
+
currentDepth: number;
|
|
91
|
+
maxDepth: number;
|
|
92
|
+
canDelegate: boolean;
|
|
93
|
+
ancestorFlowStack: string[];
|
|
94
|
+
preventCycles: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface SessionSnapshotSource {
|
|
98
|
+
getHeader: () => unknown;
|
|
99
|
+
getBranch: () => unknown[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildForkSessionSnapshotJsonl(
|
|
103
|
+
sessionManager: SessionSnapshotSource,
|
|
104
|
+
): string | null {
|
|
105
|
+
const header = sessionManager.getHeader();
|
|
106
|
+
if (!header || typeof header !== "object") return null;
|
|
107
|
+
|
|
108
|
+
const branchEntries = sessionManager.getBranch();
|
|
109
|
+
const lines = [JSON.stringify(header)];
|
|
110
|
+
for (const entry of branchEntries) lines.push(JSON.stringify(entry));
|
|
111
|
+
return `${lines.join("\n")}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseNonNegativeInt(raw: unknown): number | null {
|
|
115
|
+
if (typeof raw !== "string") return null;
|
|
116
|
+
const trimmed = raw.trim();
|
|
117
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
118
|
+
const parsed = Number(trimmed);
|
|
119
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseBoolean(raw: unknown): boolean | null {
|
|
123
|
+
if (typeof raw === "boolean") return raw;
|
|
124
|
+
if (typeof raw !== "string") return null;
|
|
125
|
+
const normalized = raw.trim().toLowerCase();
|
|
126
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
127
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseFlowStack(raw: unknown): string[] | null {
|
|
132
|
+
if (raw === undefined) return [];
|
|
133
|
+
if (typeof raw !== "string") return null;
|
|
134
|
+
if (!raw.trim()) return [];
|
|
135
|
+
|
|
136
|
+
let parsed: unknown;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!Array.isArray(parsed)) return null;
|
|
144
|
+
if (!parsed.every((value) => typeof value === "string")) return null;
|
|
145
|
+
return parsed
|
|
146
|
+
.map((value) => value.trim().toLowerCase())
|
|
147
|
+
.filter((value) => value.length > 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getMaxDepthFlagFromArgv(argv: string[]): string | null {
|
|
151
|
+
for (let i = 2; i < argv.length; i++) {
|
|
152
|
+
const arg = argv[i];
|
|
153
|
+
if (arg === "--flow-max-depth") {
|
|
154
|
+
return argv[i + 1] ?? "";
|
|
155
|
+
}
|
|
156
|
+
if (arg.startsWith("--flow-max-depth=")) {
|
|
157
|
+
return arg.slice("--flow-max-depth=".length);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getPreventCyclesFlagFromArgv(
|
|
164
|
+
argv: string[],
|
|
165
|
+
): string | boolean | null {
|
|
166
|
+
for (let i = 2; i < argv.length; i++) {
|
|
167
|
+
const arg = argv[i];
|
|
168
|
+
if (arg === "--flow-prevent-cycles") {
|
|
169
|
+
const maybeValue = argv[i + 1];
|
|
170
|
+
if (maybeValue !== undefined && !maybeValue.startsWith("--")) {
|
|
171
|
+
return maybeValue;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (arg === "--no-flow-prevent-cycles") return false;
|
|
176
|
+
if (arg.startsWith("--flow-prevent-cycles=")) {
|
|
177
|
+
return arg.slice("--flow-prevent-cycles=".length);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveFlowDepthConfig(pi: ExtensionAPI): FlowDepthConfig {
|
|
184
|
+
const depthRaw = process.env[FLOW_DEPTH_ENV];
|
|
185
|
+
const parsedDepth = parseNonNegativeInt(depthRaw);
|
|
186
|
+
if (depthRaw !== undefined && parsedDepth === null) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`[pi-agent-flow] Ignoring invalid ${FLOW_DEPTH_ENV}="${depthRaw}". Expected a non-negative integer.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const currentDepth = parsedDepth ?? 0;
|
|
192
|
+
|
|
193
|
+
const stackRaw = process.env[FLOW_STACK_ENV];
|
|
194
|
+
const ancestorFlowStack = parseFlowStack(stackRaw);
|
|
195
|
+
if (stackRaw !== undefined && ancestorFlowStack === null) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`[pi-agent-flow] Ignoring invalid ${FLOW_STACK_ENV} value. Expected a JSON array of flow names.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const envMaxDepthRaw = process.env[FLOW_MAX_DEPTH_ENV];
|
|
202
|
+
const envMaxDepth = parseNonNegativeInt(envMaxDepthRaw);
|
|
203
|
+
if (envMaxDepthRaw !== undefined && envMaxDepth === null) {
|
|
204
|
+
console.warn(
|
|
205
|
+
`[pi-agent-flow] Ignoring invalid ${FLOW_MAX_DEPTH_ENV}="${envMaxDepthRaw}". Expected a non-negative integer.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const argvFlagRaw = getMaxDepthFlagFromArgv(process.argv);
|
|
210
|
+
const argvFlagMaxDepth =
|
|
211
|
+
argvFlagRaw !== null ? parseNonNegativeInt(argvFlagRaw) : null;
|
|
212
|
+
if (argvFlagRaw !== null && argvFlagMaxDepth === null) {
|
|
213
|
+
console.warn(
|
|
214
|
+
`[pi-agent-flow] Ignoring invalid --flow-max-depth value "${argvFlagRaw}". Expected a non-negative integer.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const runtimeFlagValue = pi.getFlag("flow-max-depth");
|
|
219
|
+
const runtimeFlagMaxDepth =
|
|
220
|
+
typeof runtimeFlagValue === "string"
|
|
221
|
+
? parseNonNegativeInt(runtimeFlagValue)
|
|
222
|
+
: null;
|
|
223
|
+
if (
|
|
224
|
+
argvFlagRaw === null &&
|
|
225
|
+
typeof runtimeFlagValue === "string" &&
|
|
226
|
+
runtimeFlagMaxDepth === null
|
|
227
|
+
) {
|
|
228
|
+
console.warn(
|
|
229
|
+
`[pi-agent-flow] Ignoring invalid --flow-max-depth value "${runtimeFlagValue}". Expected a non-negative integer.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const envPreventCyclesRaw = process.env[FLOW_PREVENT_CYCLES_ENV];
|
|
234
|
+
const envPreventCycles = parseBoolean(envPreventCyclesRaw);
|
|
235
|
+
if (envPreventCyclesRaw !== undefined && envPreventCycles === null) {
|
|
236
|
+
console.warn(
|
|
237
|
+
`[pi-agent-flow] Ignoring invalid ${FLOW_PREVENT_CYCLES_ENV}="${envPreventCyclesRaw}". Expected true/false.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const argvPreventCyclesRaw = getPreventCyclesFlagFromArgv(process.argv);
|
|
242
|
+
const argvPreventCycles =
|
|
243
|
+
typeof argvPreventCyclesRaw === "boolean"
|
|
244
|
+
? argvPreventCyclesRaw
|
|
245
|
+
: parseBoolean(argvPreventCyclesRaw);
|
|
246
|
+
if (
|
|
247
|
+
typeof argvPreventCyclesRaw === "string" &&
|
|
248
|
+
argvPreventCycles === null
|
|
249
|
+
) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`[pi-agent-flow] Ignoring invalid --flow-prevent-cycles value "${argvPreventCyclesRaw}". Expected true/false.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const runtimePreventCyclesRaw = pi.getFlag("flow-prevent-cycles");
|
|
256
|
+
const runtimePreventCycles = parseBoolean(runtimePreventCyclesRaw);
|
|
257
|
+
if (
|
|
258
|
+
argvPreventCyclesRaw === null &&
|
|
259
|
+
runtimePreventCyclesRaw !== undefined &&
|
|
260
|
+
runtimePreventCycles === null
|
|
261
|
+
) {
|
|
262
|
+
console.warn(
|
|
263
|
+
`[pi-agent-flow] Ignoring invalid --flow-prevent-cycles value "${String(runtimePreventCyclesRaw)}". Expected true/false.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const flagMaxDepth = argvFlagMaxDepth ?? runtimeFlagMaxDepth;
|
|
268
|
+
const maxDepth = flagMaxDepth ?? envMaxDepth ?? DEFAULT_MAX_DELEGATION_DEPTH;
|
|
269
|
+
const preventCycles =
|
|
270
|
+
argvPreventCycles ??
|
|
271
|
+
runtimePreventCycles ??
|
|
272
|
+
envPreventCycles ??
|
|
273
|
+
DEFAULT_PREVENT_CYCLE_DELEGATION;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
currentDepth,
|
|
277
|
+
maxDepth,
|
|
278
|
+
canDelegate: currentDepth < maxDepth,
|
|
279
|
+
ancestorFlowStack: ancestorFlowStack ?? [],
|
|
280
|
+
preventCycles,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function makeFlowDetailsFactory(projectFlowsDir: string | null) {
|
|
285
|
+
return (results: SingleResult[]): FlowDetails => ({
|
|
286
|
+
mode: "flow",
|
|
287
|
+
delegationMode: "fork",
|
|
288
|
+
projectAgentsDir: projectFlowsDir,
|
|
289
|
+
results,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getFlowCycleViolations(
|
|
294
|
+
requestedNames: Set<string>,
|
|
295
|
+
ancestorFlowStack: string[],
|
|
296
|
+
): string[] {
|
|
297
|
+
if (requestedNames.size === 0 || ancestorFlowStack.length === 0) return [];
|
|
298
|
+
const stackSet = new Set(ancestorFlowStack);
|
|
299
|
+
return Array.from(requestedNames).filter((name) => stackSet.has(name));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Get project-local flows referenced by the current request. */
|
|
303
|
+
function getRequestedProjectFlows(
|
|
304
|
+
flows: FlowConfig[],
|
|
305
|
+
requestedNames: Set<string>,
|
|
306
|
+
): FlowConfig[] {
|
|
307
|
+
return Array.from(requestedNames)
|
|
308
|
+
.map((name) => flows.find((f) => f.name === name.toLowerCase()))
|
|
309
|
+
.filter((f): f is FlowConfig => f?.source === "project");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Prompt the user to confirm project-local flows if needed.
|
|
314
|
+
* Returns false if the user declines.
|
|
315
|
+
*/
|
|
316
|
+
async function confirmProjectFlowsIfNeeded(
|
|
317
|
+
projectFlows: FlowConfig[],
|
|
318
|
+
projectFlowsDir: string | null,
|
|
319
|
+
ctx: { ui: { confirm: (title: string, body: string) => Promise<boolean> } },
|
|
320
|
+
): Promise<boolean> {
|
|
321
|
+
if (projectFlows.length === 0) return true;
|
|
322
|
+
|
|
323
|
+
const names = projectFlows.map((f) => f.name).join(", ");
|
|
324
|
+
const dir = projectFlowsDir ?? "(unknown)";
|
|
325
|
+
return ctx.ui.confirm(
|
|
326
|
+
"Run project-local flows?",
|
|
327
|
+
`Flows: ${names}\nSource: ${dir}\n\nProject flows are repo-controlled. Only continue for trusted repositories.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Sliding system prompt (short, inserted before latest user message)
|
|
333
|
+
// Always active for root flows. Appended to the static system prompt in
|
|
334
|
+
// before_agent_start, and inserted as a separate system message immediately
|
|
335
|
+
// before the latest user message by the context handler.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
const SLIDING_PROMPT_OPEN_TAG = "<pi-flow-sliding-system>";
|
|
339
|
+
const SLIDING_PROMPT_CLOSE_TAG = "</pi-flow-sliding-system>";
|
|
340
|
+
|
|
341
|
+
const SLIDING_PROMPT =
|
|
342
|
+
`${SLIDING_PROMPT_OPEN_TAG}\n` +
|
|
343
|
+
`You are operating with pi-agent-flow routing.\n` +
|
|
344
|
+
`If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
|
|
345
|
+
`${SLIDING_PROMPT_CLOSE_TAG}`;
|
|
346
|
+
|
|
347
|
+
const SLIDING_PROMPT_RE = new RegExp(
|
|
348
|
+
SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
|
|
349
|
+
"[\\s\\S]*?" +
|
|
350
|
+
SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
351
|
+
"g",
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
/** Strip any old sliding system prompt tags from a string. */
|
|
355
|
+
function stripSlidingPromptText(text: string): string {
|
|
356
|
+
return text.replace(SLIDING_PROMPT_RE, "");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Strip sliding prompt tags from content (string or text-part array). */
|
|
360
|
+
function stripSlidingPromptFromContent(
|
|
361
|
+
content: string | { type: string; text?: string }[],
|
|
362
|
+
): string | { type: string; text?: string }[] {
|
|
363
|
+
if (typeof content === "string") {
|
|
364
|
+
return stripSlidingPromptText(content);
|
|
365
|
+
}
|
|
366
|
+
return content.map((c) => {
|
|
367
|
+
if (c.type === "text" && typeof c.text === "string") {
|
|
368
|
+
return { ...c, text: stripSlidingPromptText(c.text) };
|
|
369
|
+
}
|
|
370
|
+
return c;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Check whether content (string or text-part array) contains the sliding tag. */
|
|
375
|
+
function contentContainsSlidingTag(content: any): boolean {
|
|
376
|
+
if (typeof content === "string") {
|
|
377
|
+
return content.includes(SLIDING_PROMPT_OPEN_TAG);
|
|
378
|
+
}
|
|
379
|
+
if (Array.isArray(content)) {
|
|
380
|
+
return content.some(
|
|
381
|
+
(part: any) =>
|
|
382
|
+
part.type === "text" &&
|
|
383
|
+
typeof part.text === "string" &&
|
|
384
|
+
part.text.includes(SLIDING_PROMPT_OPEN_TAG),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
|
|
391
|
+
* Returns the sanitized messages and a flag indicating whether anything changed.
|
|
392
|
+
*/
|
|
393
|
+
function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
|
|
394
|
+
let changed = false;
|
|
395
|
+
const result = messages
|
|
396
|
+
.filter((msg) => {
|
|
397
|
+
// Remove dedicated sliding system prompt messages
|
|
398
|
+
if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
|
|
399
|
+
changed = true;
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
})
|
|
404
|
+
.map((msg) => {
|
|
405
|
+
// Also strip stray tags embedded in user/assistant messages
|
|
406
|
+
if (!("content" in msg)) return msg;
|
|
407
|
+
const stripped = stripSlidingPromptFromContent(msg.content);
|
|
408
|
+
if (isJsonEqual(stripped, msg.content)) return msg;
|
|
409
|
+
changed = true;
|
|
410
|
+
return { ...msg, content: stripped };
|
|
411
|
+
});
|
|
412
|
+
return { messages: result, changed };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Build a system message containing the sliding prompt. */
|
|
416
|
+
function makeSlidingPromptMessage(referenceMessage?: any): any {
|
|
417
|
+
return {
|
|
418
|
+
role: "system",
|
|
419
|
+
content: SLIDING_PROMPT,
|
|
420
|
+
timestamp: referenceMessage?.timestamp,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const REASONING_PART_TYPES = new Set([
|
|
425
|
+
"thinking",
|
|
426
|
+
"reasoning",
|
|
427
|
+
"reasoning_content",
|
|
428
|
+
"reasoningContent",
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
const REASONING_FIELDS = [
|
|
432
|
+
"thinking",
|
|
433
|
+
"thinkingSignature",
|
|
434
|
+
"thinking_signature",
|
|
435
|
+
"reasoning",
|
|
436
|
+
"reasoningContent",
|
|
437
|
+
"reasoning_content",
|
|
438
|
+
"reasoningSignature",
|
|
439
|
+
"reasoning_signature",
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
function stripReasoningFromAssistantMessage(message: any): {
|
|
443
|
+
message: any;
|
|
444
|
+
changed: boolean;
|
|
445
|
+
} {
|
|
446
|
+
let next = message;
|
|
447
|
+
let changed = false;
|
|
448
|
+
|
|
449
|
+
for (const field of REASONING_FIELDS) {
|
|
450
|
+
if (field in next) {
|
|
451
|
+
if (next === message) next = { ...message };
|
|
452
|
+
delete next[field];
|
|
453
|
+
changed = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (Array.isArray(message.content)) {
|
|
458
|
+
const filteredContent = message.content.filter(
|
|
459
|
+
(part: any) => !REASONING_PART_TYPES.has(part?.type),
|
|
460
|
+
);
|
|
461
|
+
if (filteredContent.length !== message.content.length) {
|
|
462
|
+
if (next === message) next = { ...message };
|
|
463
|
+
next.content = filteredContent;
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { message: next, changed };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isJsonEqual(a: any, b: any): boolean {
|
|
472
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Sanitize a fork session snapshot JSONL to remove only non-inheritable
|
|
477
|
+
* artifacts before passing full parent context to child flows: sliding system
|
|
478
|
+
* prompts, legacy reminders, and assistant reasoning/thinking.
|
|
479
|
+
*/
|
|
480
|
+
function sanitizeForkSnapshot(snapshot: string | null): string | null {
|
|
481
|
+
if (!snapshot) return snapshot;
|
|
482
|
+
|
|
483
|
+
const lines = snapshot.trimEnd().split("\n");
|
|
484
|
+
const sanitizedLines: string[] = [];
|
|
485
|
+
|
|
486
|
+
for (const line of lines) {
|
|
487
|
+
let entry: any;
|
|
488
|
+
try {
|
|
489
|
+
entry = JSON.parse(line);
|
|
490
|
+
} catch {
|
|
491
|
+
sanitizedLines.push(line);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let changed = false;
|
|
496
|
+
|
|
497
|
+
// Drop sliding system prompt messages entirely.
|
|
498
|
+
if (
|
|
499
|
+
entry?.type === "message" &&
|
|
500
|
+
entry.message?.role === "system" &&
|
|
501
|
+
contentContainsSlidingTag(entry.message?.content)
|
|
502
|
+
) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (entry?.type === "message" && entry.message) {
|
|
507
|
+
let message = entry.message;
|
|
508
|
+
|
|
509
|
+
if (message.role === "assistant") {
|
|
510
|
+
const stripped = stripReasoningFromAssistantMessage(message);
|
|
511
|
+
message = stripped.message;
|
|
512
|
+
changed ||= stripped.changed;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if ("content" in message) {
|
|
516
|
+
const originalContent = message.content;
|
|
517
|
+
const strippedContent = stripSlidingPromptFromContent(originalContent);
|
|
518
|
+
|
|
519
|
+
if (!isJsonEqual(strippedContent, originalContent)) {
|
|
520
|
+
message = {
|
|
521
|
+
...message,
|
|
522
|
+
content: strippedContent,
|
|
523
|
+
};
|
|
524
|
+
changed = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (changed) {
|
|
529
|
+
entry = { ...entry, message };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
sanitizedLines.push(changed ? JSON.stringify(entry) : line);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return `${sanitizedLines.join("\n")}\n`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function computeActiveTools(optimize: boolean): string[] {
|
|
540
|
+
return optimize
|
|
541
|
+
? ["batch_read", "bash", "flow"]
|
|
542
|
+
: ["read", "write", "edit", "batch", "bash", "flow", "web"];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// Extension entry point
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
export default function (pi: ExtensionAPI) {
|
|
550
|
+
pi.registerFlag("flow-max-depth", {
|
|
551
|
+
description: "Maximum allowed flow delegation depth (default: 3).",
|
|
552
|
+
type: "string",
|
|
553
|
+
});
|
|
554
|
+
pi.registerFlag("flow-prevent-cycles", {
|
|
555
|
+
description: "Block delegating to flows already in the current delegation stack (default: true).",
|
|
556
|
+
type: "boolean",
|
|
557
|
+
});
|
|
558
|
+
pi.registerFlag("flow-model-config", {
|
|
559
|
+
description: "Named flow model strategy from settings.json flowModelConfigs.",
|
|
560
|
+
type: "string",
|
|
561
|
+
});
|
|
562
|
+
pi.registerFlag("flow-lite-model", {
|
|
563
|
+
description: "Model for lite-tier flows (scout, debug).",
|
|
564
|
+
type: "string",
|
|
565
|
+
});
|
|
566
|
+
pi.registerFlag("flow-flash-model", {
|
|
567
|
+
description: "Model for flash-tier flows (build, audit).",
|
|
568
|
+
type: "string",
|
|
569
|
+
});
|
|
570
|
+
pi.registerFlag("flow-full-model", {
|
|
571
|
+
description: "Model for full-tier flows (ideas, craft).",
|
|
572
|
+
type: "string",
|
|
573
|
+
});
|
|
574
|
+
pi.registerFlag("tool-optimize", {
|
|
575
|
+
description: "Use the unified batch tool instead of separate read/write/edit tools (default: true).",
|
|
576
|
+
type: "boolean",
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const depthConfig = resolveFlowDepthConfig(pi);
|
|
580
|
+
const { currentDepth, maxDepth, canDelegate, ancestorFlowStack, preventCycles } =
|
|
581
|
+
depthConfig;
|
|
582
|
+
|
|
583
|
+
// toolOptimize: CLI flag > env var > settings.json > default (true)
|
|
584
|
+
let toolOptimize = true;
|
|
585
|
+
const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
|
|
586
|
+
if (envToolOptimize !== undefined) {
|
|
587
|
+
const parsed = parseBoolean(envToolOptimize);
|
|
588
|
+
if (parsed !== null) toolOptimize = parsed;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let discoveredFlows: FlowConfig[] = [];
|
|
592
|
+
let loadedFlowModelConfigs: LoadedFlowModelConfigs = {
|
|
593
|
+
selectedName: "default",
|
|
594
|
+
configs: { default: {} },
|
|
595
|
+
strategy: {},
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Auto-discover flows on session start
|
|
599
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
600
|
+
const discovery = discoverFlows(ctx.cwd, "all");
|
|
601
|
+
discoveredFlows = discovery.flows;
|
|
602
|
+
loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
|
|
603
|
+
|
|
604
|
+
// Resolve toolOptimize: CLI flag > env var > settings.json > default
|
|
605
|
+
const cliFlag = pi.getFlag("tool-optimize");
|
|
606
|
+
if (typeof cliFlag === "boolean") {
|
|
607
|
+
toolOptimize = cliFlag;
|
|
608
|
+
} else if (typeof cliFlag === "string") {
|
|
609
|
+
const parsed = parseBoolean(cliFlag);
|
|
610
|
+
if (parsed !== null) toolOptimize = parsed;
|
|
611
|
+
} else {
|
|
612
|
+
const flowSettings = loadFlowSettings(ctx.cwd);
|
|
613
|
+
if (typeof flowSettings.toolOptimize === "boolean") {
|
|
614
|
+
toolOptimize = flowSettings.toolOptimize;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Only restrict tools for the main orchestrator (depth 0).
|
|
619
|
+
// Child flows (depth > 0) receive their tools via --tools CLI arg;
|
|
620
|
+
// overriding them here would strip bash/batch from children.
|
|
621
|
+
if (currentDepth === 0) {
|
|
622
|
+
pi.setActiveTools(computeActiveTools(toolOptimize));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Register batch and batch_read so they are available for main agent and child flows.
|
|
626
|
+
if (toolOptimize) {
|
|
627
|
+
pi.registerTool(createBatchReadTool());
|
|
628
|
+
pi.registerTool(createBatchTool());
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Re-apply active tools every turn to survive registry refreshes.
|
|
633
|
+
// Skip for child flows — they get tools from --tools CLI arg.
|
|
634
|
+
pi.on("turn_start", () => {
|
|
635
|
+
if (currentDepth > 0) return;
|
|
636
|
+
pi.setActiveTools(computeActiveTools(toolOptimize));
|
|
637
|
+
});
|
|
638
|
+
// Inject available flows into the system prompt.
|
|
639
|
+
// Skip entirely for child flows (depth > 0) — they get their instructions
|
|
640
|
+
// from the 4-part prompt structure in buildFlowArgs and have no web tool.
|
|
641
|
+
pi.on("before_agent_start", async (event) => {
|
|
642
|
+
if (currentDepth > 0) return undefined;
|
|
643
|
+
|
|
644
|
+
const prompt = event.prompt;
|
|
645
|
+
const hasUrl = looksLikeUrlPrompt(prompt);
|
|
646
|
+
const likelyNeedsWeb = looksLikeWebSearchPrompt(prompt);
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
const webInstructions: string[] = [];
|
|
650
|
+
if (hasUrl) {
|
|
651
|
+
webInstructions.push(
|
|
652
|
+
"The prompt includes a URL. Use web tool with op: { o: 'fetch', u: '<url>' } before answering about that page.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (likelyNeedsWeb) {
|
|
656
|
+
webInstructions.push(
|
|
657
|
+
"The prompt likely needs external or current info. Prefer web tool with op: [{ o: 'search', q: '<query>' }] over memory.",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let systemPrompt = event.systemPrompt;
|
|
662
|
+
if (!toolOptimize && webInstructions.length > 0) {
|
|
663
|
+
systemPrompt +=
|
|
664
|
+
"\n\n## pi-web steering\n" +
|
|
665
|
+
webInstructions.map((line) => `- ${line}`).join("\n");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Append sliding prompt to static system prompt unconditionally.
|
|
669
|
+
systemPrompt += "\n\n" + SLIDING_PROMPT;
|
|
670
|
+
|
|
671
|
+
if (!canDelegate || discoveredFlows.length === 0) {
|
|
672
|
+
return { systemPrompt };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
systemPrompt:
|
|
677
|
+
systemPrompt +
|
|
678
|
+
`\n\n## Flows
|
|
679
|
+
|
|
680
|
+
Before acting, reason about whether to dive into a flow:
|
|
681
|
+
|
|
682
|
+
- [scout] — when you need to understand first. Find files, trace code paths, map architecture.
|
|
683
|
+
- [debug] — when something is broken. Investigate logs, errors, stack traces to find root cause.
|
|
684
|
+
- [build] — when you are ready to build. Implement features, fix bugs, write tests.
|
|
685
|
+
- [craft] — when you need a plan. Design structure, break down requirements before building.
|
|
686
|
+
- [audit] — when you need to verify and remediate. Audit security, quality, and correctness; fix safe issues directly.
|
|
687
|
+
- [ideas] — when you need fresh ideas. Use inherited context as background while exploring alternatives.
|
|
688
|
+
|
|
689
|
+
Multiple independent flows? Batch them into one call:
|
|
690
|
+
|
|
691
|
+
✅ { "flow": [{ "type": "scout", "intent": "..." }, { "type": "audit", "intent": "..." }] }
|
|
692
|
+
❌ Two separate calls — wastes time
|
|
693
|
+
|
|
694
|
+
Each call renders as:
|
|
695
|
+
|
|
696
|
+
• flow [scout] — Map the full directory structure...
|
|
697
|
+
• flow [audit] — Audit security and quality, then fix safe issues...
|
|
698
|
+
|
|
699
|
+
Each flow returns:
|
|
700
|
+
|
|
701
|
+
flow [type] accomplished
|
|
702
|
+
|
|
703
|
+
[Summary] — what happened and current status
|
|
704
|
+
[Done] — completed work with file:line references and verification results
|
|
705
|
+
[Not Done] — incomplete items, skipped checks, blockers, and reasons
|
|
706
|
+
[Next Steps] — specific recommended follow-up or next flow
|
|
707
|
+
|
|
708
|
+
### Guards
|
|
709
|
+
- Depth: ${currentDepth}/${maxDepth} | Cycles: ${preventCycles ? "blocked" : "off"} | Stack: ${ancestorFlowStack.length > 0 ? ancestorFlowStack.join(" -> ") : "(root)"}
|
|
710
|
+
`,
|
|
711
|
+
};
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Sliding system prompt: insert as a separate system message immediately
|
|
715
|
+
// before the latest user message each turn. Strips from the static
|
|
716
|
+
// systemPrompt to avoid duplication, then inserts separately.
|
|
717
|
+
// Skipped for child flows (depth > 0) — they have explicit <mission> directives.
|
|
718
|
+
pi.on("context", async (event) => {
|
|
719
|
+
if (currentDepth > 0) return undefined;
|
|
720
|
+
|
|
721
|
+
// Always strip old sliding prompt messages to prevent accumulation
|
|
722
|
+
const { messages, changed: messagesChanged } = stripSlidingPromptsFromMessages(event.messages);
|
|
723
|
+
|
|
724
|
+
// Find latest user message
|
|
725
|
+
const userIndices = messages
|
|
726
|
+
.map((m: any, i: number) => (m.role === "user" ? i : -1))
|
|
727
|
+
.filter((i: number) => i !== -1);
|
|
728
|
+
|
|
729
|
+
if (userIndices.length === 0) {
|
|
730
|
+
// No user message yet: keep sliding prompt in the static system prompt only.
|
|
731
|
+
return messagesChanged ? { messages } : undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Strip sliding from the static systemPrompt so it only appears once,
|
|
735
|
+
// as a separate message right before the latest user message.
|
|
736
|
+
let systemPrompt = event.systemPrompt;
|
|
737
|
+
let systemPromptChanged = false;
|
|
738
|
+
if (typeof systemPrompt === "string") {
|
|
739
|
+
const stripped = stripSlidingPromptText(systemPrompt);
|
|
740
|
+
if (stripped !== systemPrompt) {
|
|
741
|
+
systemPrompt = stripped;
|
|
742
|
+
systemPromptChanged = true;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const lastUserIndex = userIndices[userIndices.length - 1];
|
|
747
|
+
const modified = [
|
|
748
|
+
...messages.slice(0, lastUserIndex),
|
|
749
|
+
makeSlidingPromptMessage(messages[lastUserIndex]),
|
|
750
|
+
...messages.slice(lastUserIndex),
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
const result: any = { messages: modified };
|
|
754
|
+
if (systemPromptChanged) {
|
|
755
|
+
result.systemPrompt = systemPrompt;
|
|
756
|
+
}
|
|
757
|
+
return result;
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// Register the web tool
|
|
761
|
+
pi.registerTool(createWebTool());
|
|
762
|
+
|
|
763
|
+
// Register the flow tool
|
|
764
|
+
if (canDelegate) {
|
|
765
|
+
pi.registerTool({
|
|
766
|
+
name: "flow",
|
|
767
|
+
label: "Flow",
|
|
768
|
+
description: [
|
|
769
|
+
"If you cannot answer from your current context, you are forbidden from guessing.",
|
|
770
|
+
"You MUST enter to the following flow states, with tool call method.",
|
|
771
|
+
"",
|
|
772
|
+
"Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
|
|
773
|
+
'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
|
|
774
|
+
"States: scout, debug, build, craft, audit, ideas.",
|
|
775
|
+
"Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
|
|
776
|
+
].join("\n"),
|
|
777
|
+
parameters: FlowParams,
|
|
778
|
+
|
|
779
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
780
|
+
const discovery = discoverFlows(ctx.cwd, "all");
|
|
781
|
+
const { flows } = discovery;
|
|
782
|
+
const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
|
|
783
|
+
|
|
784
|
+
const cliFlowModelConfig =
|
|
785
|
+
typeof pi.getFlag("flow-model-config") === "string"
|
|
786
|
+
? (pi.getFlag("flow-model-config") as string)
|
|
787
|
+
: inheritedCliArgs.flowModelConfig;
|
|
788
|
+
const selectedFlowModelConfig = selectFlowModelStrategy(
|
|
789
|
+
loadedFlowModelConfigs.configs,
|
|
790
|
+
cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const getTierOverride = (tier: "lite" | "flash" | "full"): string | undefined => {
|
|
794
|
+
const flagName =
|
|
795
|
+
tier === "lite"
|
|
796
|
+
? "flow-lite-model"
|
|
797
|
+
: tier === "flash"
|
|
798
|
+
? "flow-flash-model"
|
|
799
|
+
: "flow-full-model";
|
|
800
|
+
const runtimeValue = pi.getFlag(flagName);
|
|
801
|
+
if (typeof runtimeValue === "string" && runtimeValue.trim()) return runtimeValue.trim();
|
|
802
|
+
const inheritedValue = inheritedCliArgs.tieredModels?.[tier];
|
|
803
|
+
return typeof inheritedValue === "string" && inheritedValue.trim() ? inheritedValue.trim() : undefined;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const shouldFailover = (result: SingleResult): boolean => {
|
|
807
|
+
if (result.stopReason === "aborted") return false;
|
|
808
|
+
const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
|
|
809
|
+
if (!text.trim()) return false;
|
|
810
|
+
if (text.includes("permission") || text.includes("invalid tool") || text.includes("bad settings")) {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
return result.exitCode > 0;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// Build the full fork session snapshot and sanitize only non-inheritable
|
|
817
|
+
// artifacts before passing it to child flows.
|
|
818
|
+
const forkSessionSnapshotJsonl = sanitizeForkSnapshot(
|
|
819
|
+
buildForkSessionSnapshotJsonl(ctx.sessionManager),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
// Collect all requested flow names
|
|
823
|
+
const requested = new Set<string>(params.flow.map((f: any) => f.type.toLowerCase()));
|
|
824
|
+
|
|
825
|
+
// Cycle check
|
|
826
|
+
if (preventCycles) {
|
|
827
|
+
const violations = getFlowCycleViolations(requested, ancestorFlowStack);
|
|
828
|
+
if (violations.length > 0) {
|
|
829
|
+
const stack = ancestorFlowStack.join(" -> ") || "(root)";
|
|
830
|
+
return {
|
|
831
|
+
content: [{
|
|
832
|
+
type: "text",
|
|
833
|
+
text: `Blocked: cycle detected. Flow(s) in stack: ${violations.join(", ")}\nStack: ${stack}`,
|
|
834
|
+
}],
|
|
835
|
+
details: makeDetails([]),
|
|
836
|
+
isError: true,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Project flow confirmation
|
|
842
|
+
const shouldConfirm = params.confirmProjectFlows ?? true;
|
|
843
|
+
if (shouldConfirm) {
|
|
844
|
+
const projectFlows = getRequestedProjectFlows(flows, requested);
|
|
845
|
+
if (projectFlows.length > 0) {
|
|
846
|
+
if (ctx.hasUI) {
|
|
847
|
+
const ok = await confirmProjectFlowsIfNeeded(projectFlows, discovery.projectFlowsDir, ctx);
|
|
848
|
+
if (!ok) {
|
|
849
|
+
return {
|
|
850
|
+
content: [{ type: "text", text: "Canceled: project-local flows not approved." }],
|
|
851
|
+
details: makeDetails([]),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
const names = projectFlows.map((f) => f.name).join(", ");
|
|
856
|
+
return {
|
|
857
|
+
content: [{
|
|
858
|
+
type: "text",
|
|
859
|
+
text: `Blocked: project-local flow confirmation required in non-UI mode.\nFlows: ${names}\nRe-run with confirmProjectFlows: false if trusted.`,
|
|
860
|
+
}],
|
|
861
|
+
details: makeDetails([]),
|
|
862
|
+
isError: true,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Run all flows in parallel
|
|
869
|
+
const allResults: SingleResult[] = new Array(params.flow.length);
|
|
870
|
+
for (let i = 0; i < params.flow.length; i++) {
|
|
871
|
+
allResults[i] = {
|
|
872
|
+
type: params.flow[i].type,
|
|
873
|
+
agentSource: "unknown",
|
|
874
|
+
intent: params.flow[i].intent,
|
|
875
|
+
aim: params.flow[i].aim,
|
|
876
|
+
exitCode: -1,
|
|
877
|
+
messages: [],
|
|
878
|
+
stderr: "",
|
|
879
|
+
usage: emptyFlowUsage(),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
let lastStreamingText = "";
|
|
884
|
+
let lastEmittedSignature: string | undefined;
|
|
885
|
+
const emitProgress = (streamingText?: string) => {
|
|
886
|
+
if (!onUpdate) return;
|
|
887
|
+
if (streamingText !== undefined) lastStreamingText = streamingText;
|
|
888
|
+
const text = lastStreamingText || "";
|
|
889
|
+
const signature =
|
|
890
|
+
text +
|
|
891
|
+
"|" +
|
|
892
|
+
allResults
|
|
893
|
+
.map(
|
|
894
|
+
(r) =>
|
|
895
|
+
`${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.errorMessage ?? ""}`,
|
|
896
|
+
)
|
|
897
|
+
.join(";");
|
|
898
|
+
if (signature === lastEmittedSignature) return;
|
|
899
|
+
lastEmittedSignature = signature;
|
|
900
|
+
onUpdate({
|
|
901
|
+
content: [{ type: "text", text }],
|
|
902
|
+
details: makeDetails([...allResults]),
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
if (onUpdate) emitProgress();
|
|
907
|
+
|
|
908
|
+
const results = await mapFlowConcurrent(params.flow, 4, async (item: any, index: number) => {
|
|
909
|
+
const normalizedType = item.type.toLowerCase();
|
|
910
|
+
const targetFlow = flows.find((f) => f.name === normalizedType);
|
|
911
|
+
const effectiveMaxDepth =
|
|
912
|
+
targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
|
|
913
|
+
|
|
914
|
+
const shouldInheritContext = targetFlow?.inheritContext !== false;
|
|
915
|
+
const tier = getFlowTier(normalizedType);
|
|
916
|
+
const { candidates } = resolveFlowModelCandidates({
|
|
917
|
+
tier,
|
|
918
|
+
flowModel: targetFlow?.model,
|
|
919
|
+
cliTierOverride: getTierOverride(tier),
|
|
920
|
+
strategy: selectedFlowModelConfig.strategy,
|
|
921
|
+
fallbackModel: inheritedCliArgs.fallbackModel,
|
|
922
|
+
});
|
|
923
|
+
const attemptModels = candidates.length > 0 ? candidates : [undefined];
|
|
924
|
+
const attemptedModels: string[] = [];
|
|
925
|
+
let result = allResults[index];
|
|
926
|
+
|
|
927
|
+
for (let attempt = 0; attempt < attemptModels.length; attempt++) {
|
|
928
|
+
const candidateModel = attemptModels[attempt];
|
|
929
|
+
if (candidateModel) attemptedModels.push(candidateModel);
|
|
930
|
+
result = await runFlow({
|
|
931
|
+
cwd: ctx.cwd,
|
|
932
|
+
flows,
|
|
933
|
+
flowName: normalizedType,
|
|
934
|
+
intent: item.intent,
|
|
935
|
+
aim: item.aim,
|
|
936
|
+
taskCwd: item.cwd,
|
|
937
|
+
forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
|
|
938
|
+
parentDepth: currentDepth,
|
|
939
|
+
parentFlowStack: ancestorFlowStack,
|
|
940
|
+
maxDepth: effectiveMaxDepth,
|
|
941
|
+
preventCycles,
|
|
942
|
+
toolOptimize,
|
|
943
|
+
model: candidateModel,
|
|
944
|
+
signal,
|
|
945
|
+
onUpdate: (partial) => {
|
|
946
|
+
if (partial.details?.results[0]) {
|
|
947
|
+
allResults[index] = partial.details.results[0];
|
|
948
|
+
emitProgress(partial.content?.[0]?.text);
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
makeDetails,
|
|
952
|
+
});
|
|
953
|
+
allResults[index] = result;
|
|
954
|
+
emitProgress();
|
|
955
|
+
if (isFlowSuccess(result) || signal?.aborted) break;
|
|
956
|
+
if (attempt < attemptModels.length - 1 && shouldFailover(result)) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (result && !isFlowSuccess(result) && attemptedModels.length > 1) {
|
|
963
|
+
const summary = `Model failover attempts: ${attemptedModels.join(" -> ")}`;
|
|
964
|
+
const baseStderr = result.stderr.trim();
|
|
965
|
+
result.stderr = baseStderr ? `${baseStderr}\n\n${summary}` : summary;
|
|
966
|
+
allResults[index] = result;
|
|
967
|
+
emitProgress();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return result;
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// Build tool result with FULL flow output — no truncation
|
|
974
|
+
const successCount = results.filter((r) => isFlowSuccess(r)).length;
|
|
975
|
+
const flowReports = results.map((r) => {
|
|
976
|
+
const output = getFlowSummaryText(r);
|
|
977
|
+
const status = isFlowError(r) ? "failed" : "accomplished";
|
|
978
|
+
return `flow [${r.type}] ${status}\n\n${output}`;
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// Post-flow hooks — inject advisory messages
|
|
982
|
+
const advisors = runHooks(params.flow, results);
|
|
983
|
+
const advisorBlock = advisors.length > 0
|
|
984
|
+
? "\n\n---\n\n💡 " + advisors.join("\n💡 ")
|
|
985
|
+
: "";
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
content: [{
|
|
989
|
+
type: "text" as const,
|
|
990
|
+
text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}${advisorBlock}`,
|
|
991
|
+
}],
|
|
992
|
+
details: makeDetails(results),
|
|
993
|
+
};
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
renderCall: (args, theme) => renderFlowCall(args, theme),
|
|
997
|
+
renderResult: (result, { expanded }, theme, args) =>
|
|
998
|
+
renderFlowResult(result, expanded, theme, args),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
}
|