pi-agent-flow 1.2.6 → 1.3.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 +1 -1
- package/agents/scout.md +3 -3
- package/package.json +1 -1
- package/src/agents.ts +16 -1
- package/src/batch/constants.ts +1 -0
- package/src/batch/execute.ts +26 -1
- package/src/executor.ts +31 -12
- package/src/flow.ts +11 -3
- package/src/hooks.ts +73 -12
- package/src/index.ts +44 -134
- package/src/sliding-prompt.ts +143 -0
- package/src/structured-output.ts +62 -0
- package/src/transitions.ts +86 -0
- package/src/types.ts +21 -11
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ The result is faster, cheaper, and cleaner delegation: the main agent remains un
|
|
|
79
79
|
|
|
80
80
|
| Flow | Purpose | Tools | Tier |
|
|
81
81
|
|------|---------|-------|------|
|
|
82
|
-
| `[scout]` | Discover files, trace code paths, map architecture | `
|
|
82
|
+
| `[scout]` | Discover files, trace code paths, map architecture | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
|
|
83
83
|
| `[debug]` | Investigate logs, errors, stack traces, root causes | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
|
|
84
84
|
| `[build]` | Implement features, fix bugs, write tests, ship | `batch`, `bash`, `find`, `grep`, `ls` | `flash` |
|
|
85
85
|
| `[craft]` | Plan structure, break down requirements, design solutions | `batch`, `bash`, `find`, `grep`, `ls` | `full` |
|
package/agents/scout.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: scout
|
|
3
3
|
description: Discover files, trace code paths, map architecture
|
|
4
|
-
tools:
|
|
4
|
+
tools: batch, bash, find, grep, ls
|
|
5
5
|
maxDepth: 0
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -12,8 +12,8 @@ During this scout flow — your mission is to discover relevant context. Move fa
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
14
14
|
1. Survey — use `ls`, `find`, and `grep` to locate relevant files and symbols before reading whole files.
|
|
15
|
-
2. Inspect — use `
|
|
16
|
-
3. If `
|
|
15
|
+
2. Inspect — use `batch` with `o: "read"`, `s: <offset>`, and `l: <limit>` for targeted file reading instead of bash `sed`/`head`/`tail`.
|
|
16
|
+
3. If `batch` returns a context map for a large code/infra file, do not retry the full-file read; use the reported line ranges for targeted follow-up reads.
|
|
17
17
|
4. Trace — follow code paths, dependencies, configuration, and tests that explain the requested area.
|
|
18
18
|
5. Report — cite concrete evidence and stop when the requested context is mapped.
|
|
19
19
|
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -136,7 +136,22 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
|
|
|
136
136
|
|
|
137
137
|
const name = typeof frontmatter.name === "string" ? frontmatter.name.trim().toLowerCase() : "";
|
|
138
138
|
const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
|
|
139
|
-
if (!name || !description)
|
|
139
|
+
if (!name || !description) {
|
|
140
|
+
if (!name) console.warn(`[pi-agent-flow] Skipping flow file "${filePath}": missing or empty 'name' field.`);
|
|
141
|
+
if (!description) console.warn(`[pi-agent-flow] Skipping flow file "${filePath}": missing or empty 'description' field.`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Warn about unknown frontmatter keys
|
|
146
|
+
const knownKeys = new Set([
|
|
147
|
+
"name", "description", "tools", "model", "thinking",
|
|
148
|
+
"maxDepth", "inheritContext", "tier",
|
|
149
|
+
]);
|
|
150
|
+
for (const key of Object.keys(frontmatter)) {
|
|
151
|
+
if (!knownKeys.has(key)) {
|
|
152
|
+
console.warn(`[pi-agent-flow] Unknown frontmatter key "${key}" in "${filePath}". This field will be ignored.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
140
155
|
|
|
141
156
|
let tools: string[] | undefined;
|
|
142
157
|
if (typeof frontmatter.tools === "string") {
|
package/src/batch/constants.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const MAX_BYTES = 50 * 1024; // 50KB
|
|
|
11
11
|
export const SAFE_FULL_READ_LIMIT = 300;
|
|
12
12
|
export const TARGETED_READ_LINE_LIMIT = 1000;
|
|
13
13
|
export const MAX_CONTEXT_MAP_ENTRIES = 100;
|
|
14
|
+
export const MAX_TOTAL_RESULT_LINES = 1500;
|
|
14
15
|
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Types
|
package/src/batch/execute.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MAX_BYTES,
|
|
17
17
|
SAFE_FULL_READ_LIMIT,
|
|
18
18
|
TARGETED_READ_LINE_LIMIT,
|
|
19
|
+
MAX_TOTAL_RESULT_LINES,
|
|
19
20
|
} from "./constants.js";
|
|
20
21
|
import {
|
|
21
22
|
normalizeToLF,
|
|
@@ -212,6 +213,8 @@ export async function executeOperations(
|
|
|
212
213
|
const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
|
|
213
214
|
const errors: { path: string; op: string; message: string; hint?: string }[] = [];
|
|
214
215
|
const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
|
|
216
|
+
const aggregateLimitSkipped: { path: string }[] = [];
|
|
217
|
+
let aggregateLinesRead = 0;
|
|
215
218
|
const includeLimitWarnings = options.includeLimitWarnings ?? true;
|
|
216
219
|
|
|
217
220
|
for (const op of operations) {
|
|
@@ -232,6 +235,18 @@ export async function executeOperations(
|
|
|
232
235
|
|
|
233
236
|
switch (op.o) {
|
|
234
237
|
case "read": {
|
|
238
|
+
if (aggregateLinesRead >= MAX_TOTAL_RESULT_LINES) {
|
|
239
|
+
results.push({
|
|
240
|
+
op: "read",
|
|
241
|
+
path: op.p,
|
|
242
|
+
status: "skipped",
|
|
243
|
+
error: `Skipped: aggregate line limit of ${MAX_TOTAL_RESULT_LINES} already reached. Use separate batch/batch_read calls.`,
|
|
244
|
+
});
|
|
245
|
+
counts.skipped++;
|
|
246
|
+
aggregateLimitSkipped.push({ path: op.p });
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
// Access check before reading
|
|
236
251
|
try {
|
|
237
252
|
await fs.access(resolvedPath);
|
|
@@ -293,6 +308,8 @@ export async function executeOperations(
|
|
|
293
308
|
});
|
|
294
309
|
}
|
|
295
310
|
|
|
311
|
+
aggregateLinesRead += linesRead;
|
|
312
|
+
|
|
296
313
|
results.push({
|
|
297
314
|
op: "read",
|
|
298
315
|
path: op.p,
|
|
@@ -404,7 +421,7 @@ export async function executeOperations(
|
|
|
404
421
|
}
|
|
405
422
|
}
|
|
406
423
|
// Build the enhanced summary and content text
|
|
407
|
-
const summary = buildSummary(counts, errors, truncatedFiles);
|
|
424
|
+
const summary = buildSummary(counts, errors, truncatedFiles, aggregateLimitSkipped);
|
|
408
425
|
const contentText = buildContentText(summary, results);
|
|
409
426
|
|
|
410
427
|
return { summary, contentText, results };
|
|
@@ -418,6 +435,7 @@ function buildSummary(
|
|
|
418
435
|
counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
|
|
419
436
|
errors: { path: string; op: string; message: string; hint?: string }[],
|
|
420
437
|
truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
|
|
438
|
+
aggregateLimitSkipped: { path: string }[] = [],
|
|
421
439
|
): string {
|
|
422
440
|
const totalSuccess =
|
|
423
441
|
counts.read + counts.write + counts.edit + counts.delete;
|
|
@@ -471,6 +489,13 @@ function buildSummary(
|
|
|
471
489
|
}
|
|
472
490
|
}
|
|
473
491
|
|
|
492
|
+
// Aggregate line limit warnings
|
|
493
|
+
if (aggregateLimitSkipped.length > 0) {
|
|
494
|
+
parts.push(
|
|
495
|
+
` ⚠ Aggregate line limit (${MAX_TOTAL_RESULT_LINES}) reached — skipped ${aggregateLimitSkipped.length} read${aggregateLimitSkipped.length > 1 ? "s" : ""}: ${aggregateLimitSkipped.map((s) => s.path).join(", ")}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
474
499
|
return parts.join("\n");
|
|
475
500
|
}
|
|
476
501
|
|
package/src/executor.ts
CHANGED
|
@@ -139,6 +139,24 @@ async function confirmProjectFlowsIfNeeded(
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Cache limits
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
const FLOW_RESULT_CACHE_MAX_ENTRIES = 50;
|
|
147
|
+
|
|
148
|
+
/** Evict oldest entries from the cache when it exceeds the cap. */
|
|
149
|
+
function evictCacheOverflow(cache: Map<string, unknown>): void {
|
|
150
|
+
if (cache.size <= FLOW_RESULT_CACHE_MAX_ENTRIES) return;
|
|
151
|
+
const excess = cache.size - FLOW_RESULT_CACHE_MAX_ENTRIES;
|
|
152
|
+
const keys = cache.keys();
|
|
153
|
+
for (let i = 0; i < excess; i++) {
|
|
154
|
+
const next = keys.next();
|
|
155
|
+
if (next.done) break;
|
|
156
|
+
cache.delete(next.value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
function shouldFailover(result: SingleResult): boolean {
|
|
143
161
|
if (result.stopReason === "aborted") return false;
|
|
144
162
|
const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
|
|
@@ -354,6 +372,7 @@ export async function executeFlows(
|
|
|
354
372
|
existing.push(compressed);
|
|
355
373
|
flowResultCache.set(toolCallId, existing);
|
|
356
374
|
}
|
|
375
|
+
evictCacheOverflow(flowResultCache);
|
|
357
376
|
|
|
358
377
|
// Build tool result
|
|
359
378
|
const successCount = results.filter((r) => isFlowSuccess(r)).length;
|
|
@@ -369,21 +388,21 @@ export async function executeFlows(
|
|
|
369
388
|
? "\n\n---\n\n💡 " + hookResult.advisors.join("\n💡 ")
|
|
370
389
|
: "";
|
|
371
390
|
|
|
372
|
-
// Auto-transition: collect qualifying transitions
|
|
391
|
+
// Auto-transition: collect qualifying transitions.
|
|
392
|
+
// No confidence threshold gating — non-deterministic agents should not
|
|
393
|
+
// have unstable numeric params controlling execution flow.
|
|
373
394
|
const queuedTransitions: Array<{ type: string; intent: string }> = [];
|
|
374
395
|
if (autoTransition && hookResult.autoTransitions.length > 0) {
|
|
375
396
|
for (const transition of hookResult.autoTransitions) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
});
|
|
386
|
-
}
|
|
397
|
+
const normalizedType = transition.type.toLowerCase();
|
|
398
|
+
const flowExists = flows.some((f) => f.name === normalizedType);
|
|
399
|
+
const notAlreadyRequested = !requested.has(normalizedType);
|
|
400
|
+
const noCycles = !preventCycles || !ancestorFlowStack.includes(normalizedType);
|
|
401
|
+
if (flowExists && notAlreadyRequested && noCycles) {
|
|
402
|
+
queuedTransitions.push({
|
|
403
|
+
type: transition.type,
|
|
404
|
+
intent: transition.intent,
|
|
405
|
+
});
|
|
387
406
|
}
|
|
388
407
|
}
|
|
389
408
|
}
|
package/src/flow.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getFlowOutput,
|
|
21
21
|
normalizeFlowResult,
|
|
22
22
|
} from "./types.js";
|
|
23
|
-
import { extractStructuredOutput } from "./structured-output.js";
|
|
23
|
+
import { extractStructuredOutput, enrichStructuredOutputCommands } from "./structured-output.js";
|
|
24
24
|
|
|
25
25
|
const isWindows = process.platform === "win32";
|
|
26
26
|
const SIGKILL_TIMEOUT_MS = 5000;
|
|
@@ -67,6 +67,12 @@ function mergeStreamingUsage(
|
|
|
67
67
|
* work on Unix and Windows without going through a shell wrapper.
|
|
68
68
|
*/
|
|
69
69
|
function resolveFlowSpawn(): { command: string; prefixArgs: string[] } {
|
|
70
|
+
// Support PI_FLOW_SPAWN_COMMAND env var override for exotic runtime
|
|
71
|
+
// environments (e.g. bundled with pkg/nexe where process.argv[1] is unreliable).
|
|
72
|
+
const envOverride = process.env["PI_FLOW_SPAWN_COMMAND"];
|
|
73
|
+
if (envOverride && envOverride.trim()) {
|
|
74
|
+
return { command: envOverride.trim(), prefixArgs: [] };
|
|
75
|
+
}
|
|
70
76
|
const isNode = /[\\/]node(?:\.exe)?$/i.test(process.execPath);
|
|
71
77
|
if (isNode && process.argv[1]) {
|
|
72
78
|
return { command: process.execPath, prefixArgs: [process.argv[1]] };
|
|
@@ -221,6 +227,8 @@ function buildFlowArgs(
|
|
|
221
227
|
`\n\n## Structured Output\n\n` +
|
|
222
228
|
`End your response with a JSON code block containing:\n` +
|
|
223
229
|
`\n` +
|
|
230
|
+
`For every command you ran, include the exact verbatim command string in the \\"command\\" field (not a paraphrase or summary).\n` +
|
|
231
|
+
`\n` +
|
|
224
232
|
`\`\`\`json\n` +
|
|
225
233
|
`{\n` +
|
|
226
234
|
` "version": "1.0",\n` +
|
|
@@ -233,7 +241,7 @@ function buildFlowArgs(
|
|
|
233
241
|
` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
|
|
234
242
|
` ],\n` +
|
|
235
243
|
` "commands": [\n` +
|
|
236
|
-
` { "command": "
|
|
244
|
+
` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash" }\n` +
|
|
237
245
|
` ],\n` +
|
|
238
246
|
` "notDone": [\n` +
|
|
239
247
|
` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
|
|
@@ -580,7 +588,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
|
|
|
580
588
|
const flowText = getFlowOutput(normalized.messages);
|
|
581
589
|
const extracted = extractStructuredOutput(flowText);
|
|
582
590
|
if (extracted) {
|
|
583
|
-
normalized.structuredOutput = extracted;
|
|
591
|
+
normalized.structuredOutput = enrichStructuredOutputCommands(extracted, normalized.messages);
|
|
584
592
|
}
|
|
585
593
|
}
|
|
586
594
|
|
package/src/hooks.ts
CHANGED
|
@@ -28,10 +28,21 @@ export function registerHook(hook: PostFlowHook): void {
|
|
|
28
28
|
else hooks.push(hook);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Unregister a hook by name.
|
|
33
|
+
* Returns true if a hook was removed, false if no hook with that name existed.
|
|
34
|
+
*/
|
|
35
|
+
export function unregisterHook(name: string): boolean {
|
|
36
|
+
const idx = hooks.findIndex((h) => h.name === name);
|
|
37
|
+
if (idx < 0) return false;
|
|
38
|
+
hooks.splice(idx, 1);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
export interface RunHooksResult {
|
|
32
43
|
/** Advisory messages sorted by priority. */
|
|
33
44
|
advisors: string[];
|
|
34
|
-
/** Auto-transitions collected from hooks
|
|
45
|
+
/** Auto-transitions collected from hooks. */
|
|
35
46
|
autoTransitions: AutoTransition[];
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -49,6 +60,9 @@ export function runHooks(
|
|
|
49
60
|
|
|
50
61
|
/**
|
|
51
62
|
* Run all hooks and return both advisors and auto-transitions.
|
|
63
|
+
*
|
|
64
|
+
* Each hook action is wrapped in try/catch so one bad hook cannot crash
|
|
65
|
+
* the pipeline. Failures are reported as advisory warnings.
|
|
52
66
|
*/
|
|
53
67
|
export function runHooksDetailed(
|
|
54
68
|
params: Array<{ type: string; intent: string }>,
|
|
@@ -67,17 +81,24 @@ export function runHooksDetailed(
|
|
|
67
81
|
if (matching.length === 0) continue;
|
|
68
82
|
if (onlyOnSuccess && !matching.every((r) => isFlowSuccess(r))) continue;
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
try {
|
|
85
|
+
const result = hook.action({ results: matching, params });
|
|
86
|
+
if (result) {
|
|
87
|
+
messages.push({ priority: result.priority ?? 0, content: result.content });
|
|
88
|
+
if (result.autoTransition) {
|
|
89
|
+
transitions.push(result.autoTransition);
|
|
90
|
+
}
|
|
75
91
|
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
94
|
+
messages.push({
|
|
95
|
+
priority: 999,
|
|
96
|
+
content: `Hook "${hook.name}" failed: ${errorMessage}`,
|
|
97
|
+
});
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
messages.sort((a, b) => a.priority - b.priority);
|
|
80
|
-
transitions.sort((a, b) => b.confidence - a.confidence);
|
|
81
102
|
return {
|
|
82
103
|
advisors: messages.map((m) => m.content),
|
|
83
104
|
autoTransitions: transitions,
|
|
@@ -99,7 +120,7 @@ export function clearHooks(): void {
|
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
// ---------------------------------------------------------------------------
|
|
102
|
-
// Built-in hooks
|
|
123
|
+
// Built-in hooks — success transitions
|
|
103
124
|
// ---------------------------------------------------------------------------
|
|
104
125
|
|
|
105
126
|
/** Suggest audit flow after a successful build flow. */
|
|
@@ -156,10 +177,6 @@ registerHook({
|
|
|
156
177
|
},
|
|
157
178
|
});
|
|
158
179
|
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Extended transition hooks
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
180
|
/** Suggest build or debug flow after a successful scout flow. */
|
|
164
181
|
registerHook({
|
|
165
182
|
name: "pi-agent-flow/scout-to-build",
|
|
@@ -231,3 +248,47 @@ registerHook({
|
|
|
231
248
|
};
|
|
232
249
|
},
|
|
233
250
|
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Built-in hooks — failure transitions
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/** Suggest debug flow after a failed build flow. */
|
|
257
|
+
registerHook({
|
|
258
|
+
name: "pi-agent-flow/build-to-debug-on-failure",
|
|
259
|
+
trigger: { flowTypes: ["build"], onlyOnSuccess: false },
|
|
260
|
+
action: (ctx) => {
|
|
261
|
+
const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
|
|
262
|
+
if (allSucceeded) return null;
|
|
263
|
+
const debugWasRequested = ctx.params.some(
|
|
264
|
+
(p) => p.type.toLowerCase() === "debug",
|
|
265
|
+
);
|
|
266
|
+
if (debugWasRequested) return null;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
content:
|
|
270
|
+
"Build failed. Consider running a [debug] flow to investigate the root cause.",
|
|
271
|
+
priority: 10,
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/** Suggest build flow after a failed audit flow (found issues). */
|
|
277
|
+
registerHook({
|
|
278
|
+
name: "pi-agent-flow/audit-to-build-on-failure",
|
|
279
|
+
trigger: { flowTypes: ["audit"], onlyOnSuccess: false },
|
|
280
|
+
action: (ctx) => {
|
|
281
|
+
const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
|
|
282
|
+
if (allSucceeded) return null;
|
|
283
|
+
const buildWasRequested = ctx.params.some(
|
|
284
|
+
(p) => p.type.toLowerCase() === "build",
|
|
285
|
+
);
|
|
286
|
+
if (buildWasRequested) return null;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
content:
|
|
290
|
+
"Audit found issues. Consider running a [build] flow to fix them.",
|
|
291
|
+
priority: 10,
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* Each flow receives a forked snapshot of the current session context.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
8
|
import * as os from "node:os";
|
|
10
9
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
10
|
import { Type } from "@sinclair/typebox";
|
|
@@ -20,7 +19,7 @@ import {
|
|
|
20
19
|
import { getInheritedCliArgs } from "./cli-args.js";
|
|
21
20
|
import { renderFlowCall, renderFlowResult } from "./render.js";
|
|
22
21
|
import { getFlowSummaryText } from "./runner-events.js";
|
|
23
|
-
import { runHooks, runHooksDetailed, getRegisteredHooks, registerHook } from "./hooks.js";
|
|
22
|
+
import { runHooks, runHooksDetailed, getRegisteredHooks, registerHook, unregisterHook } from "./hooks.js";
|
|
24
23
|
import { mapFlowConcurrent, runFlow } from "./flow.js";
|
|
25
24
|
import { executeFlows } from "./executor.js";
|
|
26
25
|
import {
|
|
@@ -31,6 +30,7 @@ import {
|
|
|
31
30
|
type FileEntry,
|
|
32
31
|
type CommandEntry,
|
|
33
32
|
type AutoTransition,
|
|
33
|
+
type PiAgentFlowAPI,
|
|
34
34
|
emptyFlowUsage,
|
|
35
35
|
isFlowError,
|
|
36
36
|
isFlowSuccess,
|
|
@@ -43,6 +43,17 @@ import {
|
|
|
43
43
|
looksLikeUrlPrompt,
|
|
44
44
|
looksLikeWebSearchPrompt,
|
|
45
45
|
} from "./web-tool.js";
|
|
46
|
+
import {
|
|
47
|
+
SLIDING_PROMPT,
|
|
48
|
+
SLIDING_PROMPT_OPEN_TAG,
|
|
49
|
+
stripSlidingPromptText,
|
|
50
|
+
stripSlidingPromptFromContent,
|
|
51
|
+
contentContainsSlidingTag,
|
|
52
|
+
isJsonEqual,
|
|
53
|
+
stripSlidingPromptsFromMessages,
|
|
54
|
+
makeSlidingPromptMessage,
|
|
55
|
+
} from "./sliding-prompt.js";
|
|
56
|
+
import { DEFAULT_TRANSITIONS, buildTransitionHooks } from "./transitions.js";
|
|
46
57
|
import { createTimedBashToolDefinition } from "./timed-bash.js";
|
|
47
58
|
|
|
48
59
|
// ---------------------------------------------------------------------------
|
|
@@ -346,110 +357,7 @@ async function confirmProjectFlowsIfNeeded(
|
|
|
346
357
|
// before the latest user message by the context handler.
|
|
347
358
|
// ---------------------------------------------------------------------------
|
|
348
359
|
|
|
349
|
-
|
|
350
|
-
const SLIDING_PROMPT_OPEN_TAG = `<pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
|
|
351
|
-
const SLIDING_PROMPT_CLOSE_TAG = `</pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
|
|
352
|
-
|
|
353
|
-
const SLIDING_PROMPT =
|
|
354
|
-
`${SLIDING_PROMPT_OPEN_TAG}\n` +
|
|
355
|
-
`You are operating with pi-agent-flow routing.\n` +
|
|
356
|
-
`If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
|
|
357
|
-
`For git, bash, CLI, or terminal tasks, delegate to [build].\n` +
|
|
358
|
-
`${SLIDING_PROMPT_CLOSE_TAG}`;
|
|
359
|
-
|
|
360
|
-
const SLIDING_PROMPT_RE = new RegExp(
|
|
361
|
-
SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
|
|
362
|
-
"[\\s\\S]*?" +
|
|
363
|
-
SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
364
|
-
"g",
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
/** Legacy regex to strip old bare sliding system prompt tags (no id attribute). */
|
|
368
|
-
const LEGACY_SLIDING_PROMPT_RE = /<pi-flow-sliding-system(?:\s[^>]*)?>[\s\S]*?<\/pi-flow-sliding-system(?:\s[^>]*)?>/g;
|
|
369
|
-
|
|
370
|
-
/** Strip any old sliding system prompt tags from a string. */
|
|
371
|
-
function stripSlidingPromptText(text: string): string {
|
|
372
|
-
return text.replace(SLIDING_PROMPT_RE, "").replace(LEGACY_SLIDING_PROMPT_RE, "");
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Strip sliding prompt tags from content (string or text-part array). */
|
|
376
|
-
function stripSlidingPromptFromContent(
|
|
377
|
-
content: string | { type: string; text?: string }[],
|
|
378
|
-
): string | { type: string; text?: string }[] {
|
|
379
|
-
if (typeof content === "string") {
|
|
380
|
-
return stripSlidingPromptText(content);
|
|
381
|
-
}
|
|
382
|
-
return content.map((c) => {
|
|
383
|
-
if (c.type === "text" && typeof c.text === "string") {
|
|
384
|
-
return { ...c, text: stripSlidingPromptText(c.text) };
|
|
385
|
-
}
|
|
386
|
-
return c;
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/** Check whether content (string or text-part array) contains the sliding tag. */
|
|
391
|
-
/** Input-message content types (string or multipart text-part array). */
|
|
392
|
-
type MessageContent = string | Array<{ type: string; text?: string }>;
|
|
393
|
-
|
|
394
|
-
/** Type guard: is this a text-part with a string .text? */
|
|
395
|
-
function isTextPart(part: unknown): part is { type: "text"; text: string } {
|
|
396
|
-
return (
|
|
397
|
-
part != null &&
|
|
398
|
-
typeof part === "object" &&
|
|
399
|
-
"type" in part &&
|
|
400
|
-
part.type === "text" &&
|
|
401
|
-
"text" in part &&
|
|
402
|
-
typeof (part as { text?: unknown }).text === "string"
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Check whether content (string or text-part array) contains the sliding tag. */
|
|
407
|
-
function contentContainsSlidingTag(content: MessageContent): boolean {
|
|
408
|
-
const hasCurrent = (text: string) => text.includes(SLIDING_PROMPT_OPEN_TAG);
|
|
409
|
-
const hasLegacy = (text: string) => text.includes("<pi-flow-sliding-system>");
|
|
410
|
-
const check = (text: string) => hasCurrent(text) || hasLegacy(text);
|
|
411
|
-
if (typeof content === "string") {
|
|
412
|
-
return check(content);
|
|
413
|
-
}
|
|
414
|
-
if (Array.isArray(content)) {
|
|
415
|
-
return content.some((part) => isTextPart(part) && check(part.text));
|
|
416
|
-
}
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
|
|
421
|
-
* Returns the sanitized messages and a flag indicating whether anything changed.
|
|
422
|
-
*/
|
|
423
|
-
function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
|
|
424
|
-
let changed = false;
|
|
425
|
-
const result = messages
|
|
426
|
-
.filter((msg) => {
|
|
427
|
-
// Remove dedicated sliding system prompt messages
|
|
428
|
-
if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
|
|
429
|
-
changed = true;
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
return true;
|
|
433
|
-
})
|
|
434
|
-
.map((msg) => {
|
|
435
|
-
// Also strip stray tags embedded in user/assistant messages
|
|
436
|
-
if (!("content" in msg)) return msg;
|
|
437
|
-
const stripped = stripSlidingPromptFromContent(msg.content);
|
|
438
|
-
if (isJsonEqual(stripped, msg.content)) return msg;
|
|
439
|
-
changed = true;
|
|
440
|
-
return { ...msg, content: stripped };
|
|
441
|
-
});
|
|
442
|
-
return { messages: result, changed };
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** Build a system message containing the sliding prompt. */
|
|
446
|
-
function makeSlidingPromptMessage(referenceMessage?: any): any {
|
|
447
|
-
return {
|
|
448
|
-
role: "system",
|
|
449
|
-
content: SLIDING_PROMPT,
|
|
450
|
-
timestamp: referenceMessage?.timestamp,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
360
|
+
// SLIDING_PROMPT, SLIDING_PROMPT_OPEN_TAG imported from ./sliding-prompt.js
|
|
453
361
|
|
|
454
362
|
const REASONING_PART_TYPES = new Set([
|
|
455
363
|
"thinking",
|
|
@@ -498,22 +406,6 @@ function stripReasoningFromAssistantMessage(message: any): {
|
|
|
498
406
|
return { message: next, changed };
|
|
499
407
|
}
|
|
500
408
|
|
|
501
|
-
/** Deep-equality check that handles unordered object keys (unlike JSON.stringify). */
|
|
502
|
-
function isJsonEqual(a: unknown, b: unknown): boolean {
|
|
503
|
-
if (Object.is(a, b)) return true;
|
|
504
|
-
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
505
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
506
|
-
|
|
507
|
-
const keysA = Object.keys(a as Record<string, unknown>);
|
|
508
|
-
const keysB = Object.keys(b as Record<string, unknown>);
|
|
509
|
-
if (keysA.length !== keysB.length) return false;
|
|
510
|
-
|
|
511
|
-
for (const key of keysA) {
|
|
512
|
-
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
513
|
-
if (!isJsonEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
|
|
514
|
-
}
|
|
515
|
-
return true;
|
|
516
|
-
}
|
|
517
409
|
// ---------------------------------------------------------------------------
|
|
518
410
|
// Flow result compression
|
|
519
411
|
// ---------------------------------------------------------------------------
|
|
@@ -556,11 +448,7 @@ function renderCompressedFlowResult(r: CompressedFlowResult): string {
|
|
|
556
448
|
parts.push(`Files:\n${fileLines.join("\n")}`);
|
|
557
449
|
}
|
|
558
450
|
if (r.commands?.length) {
|
|
559
|
-
const cmdLines = r.commands.map((c) => {
|
|
560
|
-
const result = c.result ? ` (${c.result})` : "";
|
|
561
|
-
const purpose = c.purpose ? ` — ${c.purpose}` : "";
|
|
562
|
-
return ` ${c.tool ?? "cmd"}: ${c.command}${result}${purpose}`;
|
|
563
|
-
});
|
|
451
|
+
const cmdLines = r.commands.map((c) => ` ${c.tool ?? "cmd"}: ${c.command}`);
|
|
564
452
|
parts.push(`Commands:\n${cmdLines.join("\n")}`);
|
|
565
453
|
}
|
|
566
454
|
if (r.error) parts.push(`Error: ${r.error}`);
|
|
@@ -800,6 +688,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
800
688
|
discoveredFlows = discovery.flows;
|
|
801
689
|
loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
|
|
802
690
|
|
|
691
|
+
// Register declarative transition hooks from the transition matrix
|
|
692
|
+
for (const hook of buildTransitionHooks(DEFAULT_TRANSITIONS)) {
|
|
693
|
+
registerHook(hook);
|
|
694
|
+
}
|
|
695
|
+
|
|
803
696
|
// Resolve toolOptimize: CLI flag > env var > settings.json > default
|
|
804
697
|
const cliFlag = pi.getFlag("tool-optimize");
|
|
805
698
|
if (typeof cliFlag === "boolean") {
|
|
@@ -1097,14 +990,31 @@ flow [type] accomplished
|
|
|
1097
990
|
// -------------------------------------------------------------------------
|
|
1098
991
|
|
|
1099
992
|
// Emit a ready event with the API surface so external plugins can extend.
|
|
993
|
+
// Emit a typed plugin API surface
|
|
994
|
+
const pluginApi: PiAgentFlowAPI = {
|
|
995
|
+
registerHook,
|
|
996
|
+
unregisterHook,
|
|
997
|
+
getRegisteredHooks,
|
|
998
|
+
discoverFlows: (cwd: string) => discoverFlows(cwd, "all"),
|
|
999
|
+
getFlowTier: (name: string) => getFlowTier(name),
|
|
1000
|
+
getSettings: () => ({ toolOptimize, structuredOutput, maxConcurrency, autoTransition }),
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1100
1003
|
if (typeof pi.emit === "function") {
|
|
1101
|
-
pi.emit("pi-agent-flow:ready",
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1004
|
+
pi.emit("pi-agent-flow:ready", pluginApi);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Register cleanup on process exit (once)
|
|
1008
|
+
if (!(globalThis as any).__pi_agent_flow_shutdown_registered) {
|
|
1009
|
+
(globalThis as any).__pi_agent_flow_shutdown_registered = true;
|
|
1010
|
+
const emitShutdown = () => {
|
|
1011
|
+
if (typeof pi.emit === "function") {
|
|
1012
|
+
pi.emit("pi-agent-flow:shutdown", { reason: "process-exit" });
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
process.on("exit", emitShutdown);
|
|
1016
|
+
process.on("SIGINT", () => { emitShutdown(); process.exit(130); });
|
|
1017
|
+
process.on("SIGTERM", () => { emitShutdown(); process.exit(143); });
|
|
1108
1018
|
}
|
|
1109
1019
|
|
|
1110
1020
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding system prompt utilities.
|
|
3
|
+
*
|
|
4
|
+
* Manages the sliding prompt tag that is injected before the latest user
|
|
5
|
+
* message each turn. Provides helpers for stripping, detecting, and building
|
|
6
|
+
* the prompt so they can be tested independently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Tag constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const SLIDING_PROMPT_UUID = randomUUID();
|
|
16
|
+
|
|
17
|
+
export const SLIDING_PROMPT_OPEN_TAG = `<pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
|
|
18
|
+
export const SLIDING_PROMPT_CLOSE_TAG = `</pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
|
|
19
|
+
|
|
20
|
+
export const SLIDING_PROMPT =
|
|
21
|
+
`${SLIDING_PROMPT_OPEN_TAG}\n` +
|
|
22
|
+
`You are operating with pi-agent-flow routing.\n` +
|
|
23
|
+
`If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
|
|
24
|
+
`For git, bash, CLI, or terminal tasks, delegate to [build].\n` +
|
|
25
|
+
`${SLIDING_PROMPT_CLOSE_TAG}`;
|
|
26
|
+
|
|
27
|
+
const SLIDING_PROMPT_RE = new RegExp(
|
|
28
|
+
SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
|
|
29
|
+
"[\\s\\S]*?" +
|
|
30
|
+
SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
31
|
+
"g",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Legacy regex to strip old bare sliding system prompt tags (no id attribute). */
|
|
35
|
+
const LEGACY_SLIDING_PROMPT_RE = /<pi-flow-sliding-system(?:\s[^>]*)?>[\s\S]*?<\/pi-flow-sliding-system(?:\s[^>]*)?>/g;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Content types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Input-message content types (string or multipart text-part array). */
|
|
42
|
+
type MessageContent = string | Array<{ type: string; text?: string }>;
|
|
43
|
+
|
|
44
|
+
/** Type guard: is this a text-part with a string .text? */
|
|
45
|
+
function isTextPart(part: unknown): part is { type: "text"; text: string } {
|
|
46
|
+
return (
|
|
47
|
+
part != null &&
|
|
48
|
+
typeof part === "object" &&
|
|
49
|
+
"type" in part &&
|
|
50
|
+
part.type === "text" &&
|
|
51
|
+
"text" in part &&
|
|
52
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Public helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Strip any old sliding system prompt tags from a string. */
|
|
61
|
+
export function stripSlidingPromptText(text: string): string {
|
|
62
|
+
return text.replace(SLIDING_PROMPT_RE, "").replace(LEGACY_SLIDING_PROMPT_RE, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strip sliding prompt tags from content (string or text-part array). */
|
|
66
|
+
export function stripSlidingPromptFromContent(
|
|
67
|
+
content: string | { type: string; text?: string }[],
|
|
68
|
+
): string | { type: string; text?: string }[] {
|
|
69
|
+
if (typeof content === "string") {
|
|
70
|
+
return stripSlidingPromptText(content);
|
|
71
|
+
}
|
|
72
|
+
return content.map((c) => {
|
|
73
|
+
if (c.type === "text" && typeof c.text === "string") {
|
|
74
|
+
return { ...c, text: stripSlidingPromptText(c.text) };
|
|
75
|
+
}
|
|
76
|
+
return c;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Check whether content (string or text-part array) contains the sliding tag. */
|
|
81
|
+
export function contentContainsSlidingTag(content: MessageContent): boolean {
|
|
82
|
+
const hasCurrent = (text: string) => text.includes(SLIDING_PROMPT_OPEN_TAG);
|
|
83
|
+
const hasLegacy = (text: string) => text.includes("<pi-flow-sliding-system>");
|
|
84
|
+
const check = (text: string) => hasCurrent(text) || hasLegacy(text);
|
|
85
|
+
if (typeof content === "string") {
|
|
86
|
+
return check(content);
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
return content.some((part) => isTextPart(part) && check(part.text));
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Deep-equality check that handles unordered object keys (unlike JSON.stringify). */
|
|
95
|
+
export function isJsonEqual(a: unknown, b: unknown): boolean {
|
|
96
|
+
if (Object.is(a, b)) return true;
|
|
97
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
98
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
99
|
+
|
|
100
|
+
const keysA = Object.keys(a as Record<string, unknown>);
|
|
101
|
+
const keysB = Object.keys(b as Record<string, unknown>);
|
|
102
|
+
if (keysA.length !== keysB.length) return false;
|
|
103
|
+
|
|
104
|
+
for (const key of keysA) {
|
|
105
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
106
|
+
if (!isJsonEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
|
|
112
|
+
* Returns the sanitized messages and a flag indicating whether anything changed.
|
|
113
|
+
*/
|
|
114
|
+
export function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
|
|
115
|
+
let changed = false;
|
|
116
|
+
const result = messages
|
|
117
|
+
.filter((msg) => {
|
|
118
|
+
// Remove dedicated sliding system prompt messages
|
|
119
|
+
if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
|
|
120
|
+
changed = true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
})
|
|
125
|
+
.map((msg) => {
|
|
126
|
+
// Also strip stray tags embedded in user/assistant messages
|
|
127
|
+
if (!("content" in msg)) return msg;
|
|
128
|
+
const stripped = stripSlidingPromptFromContent(msg.content);
|
|
129
|
+
if (isJsonEqual(stripped, msg.content)) return msg;
|
|
130
|
+
changed = true;
|
|
131
|
+
return { ...msg, content: stripped };
|
|
132
|
+
});
|
|
133
|
+
return { messages: result, changed };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build a system message containing the sliding prompt. */
|
|
137
|
+
export function makeSlidingPromptMessage(referenceMessage?: any): any {
|
|
138
|
+
return {
|
|
139
|
+
role: "system",
|
|
140
|
+
content: SLIDING_PROMPT,
|
|
141
|
+
timestamp: referenceMessage?.timestamp,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/structured-output.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and validates it against the FlowStructuredOutput schema.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
8
9
|
import type { Action, CommandEntry, FileEntry, FlowStructuredOutput, NotDoneItem } from "./types.js";
|
|
9
10
|
|
|
10
11
|
type FlowStatus = FlowStructuredOutput["status"];
|
|
@@ -95,3 +96,64 @@ export function extractStructuredOutput(text: string): FlowStructuredOutput | un
|
|
|
95
96
|
...(parsed.extensions !== undefined ? { extensions: parsed.extensions } : {}),
|
|
96
97
|
};
|
|
97
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Walk the message history and mechanically replace paraphrased bash commands
|
|
102
|
+
* in a structured-output `commands` array with the exact verbatim strings from
|
|
103
|
+
* the actual tool calls. This fixes the common LLM behaviour of summarising
|
|
104
|
+
* `curl -s -X POST …` as `"curl GAWA baseline"`.
|
|
105
|
+
*
|
|
106
|
+
* Batch operations are flattened so that bash commands nested inside a batch
|
|
107
|
+
* call are included in the same chronological order they were executed.
|
|
108
|
+
*/
|
|
109
|
+
export function enrichStructuredOutputCommands(
|
|
110
|
+
structuredOutput: FlowStructuredOutput,
|
|
111
|
+
messages: Message[],
|
|
112
|
+
): FlowStructuredOutput {
|
|
113
|
+
// Collect actual verbatim bash commands (including those inside batch ops)
|
|
114
|
+
const actualBashCommands: string[] = [];
|
|
115
|
+
for (const msg of messages) {
|
|
116
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
117
|
+
for (const part of msg.content) {
|
|
118
|
+
if (part.type !== "toolCall") continue;
|
|
119
|
+
const name =
|
|
120
|
+
part.name ||
|
|
121
|
+
(part as unknown as { toolName?: string }).toolName ||
|
|
122
|
+
"";
|
|
123
|
+
const args = part.arguments || part.input || {};
|
|
124
|
+
|
|
125
|
+
if (name === "bash") {
|
|
126
|
+
const cmd = (args.command as string) || "";
|
|
127
|
+
if (cmd) actualBashCommands.push(cmd);
|
|
128
|
+
} else if (name === "batch") {
|
|
129
|
+
const ops = Array.isArray(args.o)
|
|
130
|
+
? args.o
|
|
131
|
+
: Array.isArray(args.op)
|
|
132
|
+
? args.op
|
|
133
|
+
: Array.isArray(args.operations)
|
|
134
|
+
? args.operations
|
|
135
|
+
: [];
|
|
136
|
+
for (const op of ops) {
|
|
137
|
+
if (!op) continue;
|
|
138
|
+
const opType = (op.o ?? op.op) as string;
|
|
139
|
+
if (opType === "bash" && op.command) {
|
|
140
|
+
actualBashCommands.push(op.command as string);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (actualBashCommands.length === 0) return structuredOutput;
|
|
148
|
+
|
|
149
|
+
// Replace paraphrased bash commands with actual verbatim ones, in order.
|
|
150
|
+
let bashIdx = 0;
|
|
151
|
+
const enrichedCommands = structuredOutput.commands.map((cmd) => {
|
|
152
|
+
if (cmd.tool === "bash" && bashIdx < actualBashCommands.length) {
|
|
153
|
+
return { command: actualBashCommands[bashIdx++], tool: "bash" };
|
|
154
|
+
}
|
|
155
|
+
return { command: cmd.command, tool: cmd.tool };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return { ...structuredOutput, commands: enrichedCommands };
|
|
159
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative transition matrix for post-flow routing.
|
|
3
|
+
*
|
|
4
|
+
* Instead of imperative hooks for common flow paths, this module defines a
|
|
5
|
+
* data-driven transition map. Each entry describes a recommended follow-up
|
|
6
|
+
* flow given a source flow's outcome. The matrix is user-overridable via
|
|
7
|
+
* settings.json.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: There is no confidence threshold gating. Non-deterministic agents
|
|
10
|
+
* should not have unstable numeric params controlling execution flow.
|
|
11
|
+
* All matching transitions are recommended; the caller decides whether to
|
|
12
|
+
* auto-queue them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type PostFlowHook, type AutoTransition, isFlowSuccess } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Transition descriptor
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface FlowTransition {
|
|
22
|
+
/** Source flow name (case-insensitive). */
|
|
23
|
+
from: string;
|
|
24
|
+
/** Target flow name (case-insensitive). */
|
|
25
|
+
to: string;
|
|
26
|
+
/** Condition for this transition. */
|
|
27
|
+
on: "success" | "failure" | "always";
|
|
28
|
+
/** Advisory message shown to the user. */
|
|
29
|
+
advice: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Default transition matrix
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_TRANSITIONS: FlowTransition[] = [
|
|
37
|
+
{ from: "scout", to: "build", on: "success", advice: "Context mapped. Consider running a [build] flow to implement changes, or [debug] if investigating an issue." },
|
|
38
|
+
{ from: "scout", to: "debug", on: "success", advice: "Context mapped. Consider running a [debug] flow if investigating an issue." },
|
|
39
|
+
{ from: "debug", to: "build", on: "success", advice: "The root cause has been identified. Consider running a [build] flow to implement the fix." },
|
|
40
|
+
{ from: "debug", to: "audit", on: "success", advice: "Root cause identified. Consider running an [audit] flow to verify the fix area for related issues." },
|
|
41
|
+
{ from: "build", to: "audit", on: "success", advice: "Consider running a [audit] flow to audit the changes for security, correctness, and code quality." },
|
|
42
|
+
{ from: "build", to: "debug", on: "failure", advice: "Build failed. Consider running a [debug] flow to investigate the root cause." },
|
|
43
|
+
{ from: "audit", to: "scout", on: "success", advice: "Audit complete. Consider running a [scout] flow to trace the audit findings across the codebase." },
|
|
44
|
+
{ from: "audit", to: "build", on: "failure", advice: "Audit found issues. Consider running a [build] flow to fix them." },
|
|
45
|
+
{ from: "craft", to: "build", on: "success", advice: "Plan ready. Consider running a [build] flow to implement the design." },
|
|
46
|
+
{ from: "ideas", to: "craft", on: "success", advice: "Ideas explored. Consider running a [craft] flow to design the approach, or [build] to implement directly." },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Hook generation
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert the transition matrix into PostFlowHook instances.
|
|
55
|
+
*
|
|
56
|
+
* Each transition becomes a hook that checks:
|
|
57
|
+
* 1. The source flow type matches
|
|
58
|
+
* 2. The target flow was not already requested
|
|
59
|
+
* 3. The outcome matches the `on` condition
|
|
60
|
+
*/
|
|
61
|
+
export function buildTransitionHooks(transitions: FlowTransition[]): PostFlowHook[] {
|
|
62
|
+
return transitions.map((t) => ({
|
|
63
|
+
name: `pi-agent-flow/${t.from}-to-${t.to}-${t.on}`,
|
|
64
|
+
trigger: {
|
|
65
|
+
flowTypes: [t.from],
|
|
66
|
+
onlyOnSuccess: t.on === "success",
|
|
67
|
+
},
|
|
68
|
+
action: (ctx) => {
|
|
69
|
+
// Respect the on condition: success hooks only fire when all results
|
|
70
|
+
// succeeded; failure hooks only fire when at least one result failed.
|
|
71
|
+
const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
|
|
72
|
+
if (t.on === "success" && !allSucceeded) return null;
|
|
73
|
+
if (t.on === "failure" && allSucceeded) return null;
|
|
74
|
+
|
|
75
|
+
const alreadyRequested = ctx.params.some(
|
|
76
|
+
(p) => p.type.toLowerCase() === t.to,
|
|
77
|
+
);
|
|
78
|
+
if (alreadyRequested) return null;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
content: t.advice,
|
|
82
|
+
priority: 10,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -39,18 +39,10 @@ export interface FileEntry {
|
|
|
39
39
|
|
|
40
40
|
/** Structured command/tool invocation entry in a flow's output. */
|
|
41
41
|
export interface CommandEntry {
|
|
42
|
-
/** The command string or tool call
|
|
42
|
+
/** The exact verbatim command string or tool call that was executed. */
|
|
43
43
|
command: string;
|
|
44
44
|
/** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
|
|
45
45
|
tool?: string;
|
|
46
|
-
/** Working directory or target scope (file path, URL, etc.). */
|
|
47
|
-
target?: string;
|
|
48
|
-
/** Whether it succeeded, failed, or was aborted. */
|
|
49
|
-
result?: "success" | "failure" | "partial" | "aborted";
|
|
50
|
-
/** High-level output or error excerpt (not full stdout). */
|
|
51
|
-
output?: string;
|
|
52
|
-
/** Why this command was run (1 sentence). */
|
|
53
|
-
purpose?: string;
|
|
54
46
|
}
|
|
55
47
|
|
|
56
48
|
/** Compressed representation of a flow result for child context inheritance. */
|
|
@@ -365,6 +357,24 @@ export interface AutoTransition {
|
|
|
365
357
|
type: string;
|
|
366
358
|
/** Intent for the follow-up flow. */
|
|
367
359
|
intent: string;
|
|
368
|
-
/** Confidence score (0-1). Higher means more certain. */
|
|
369
|
-
confidence: number;
|
|
370
360
|
}
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Plugin API types
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** Public API surface exposed via the pi-agent-flow:ready event. */
|
|
366
|
+
export interface PiAgentFlowAPI {
|
|
367
|
+
/** Register or replace a post-flow hook. */
|
|
368
|
+
registerHook: (hook: PostFlowHook) => void;
|
|
369
|
+
/** Unregister a hook by name. Returns true if removed. */
|
|
370
|
+
unregisterHook: (name: string) => boolean;
|
|
371
|
+
/** Get a snapshot of all registered hooks. */
|
|
372
|
+
getRegisteredHooks: () => PostFlowHook[];
|
|
373
|
+
/** Discover all available flows for a given working directory. */
|
|
374
|
+
discoverFlows: (cwd: string) => { flows: Array<{ name: string; description: string; source: string }>; projectFlowsDir: string | null };
|
|
375
|
+
/** Determine the model tier for a given flow name. */
|
|
376
|
+
getFlowTier: (name: string) => string;
|
|
377
|
+
/** Get current flow settings. */
|
|
378
|
+
getSettings: () => { toolOptimize: boolean; structuredOutput: boolean; maxConcurrency: number; autoTransition: boolean };
|
|
379
|
+
}
|
|
380
|
+
|