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