pi-workflow-engine 0.6.1 → 0.8.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/.pi/extensions/pi-workflow-engine/index.ts +187 -30
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +70 -2
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +204 -22
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +150 -20
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +17 -12
- package/.pi/extensions/pi-workflow-engine/src/types.ts +25 -1
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +13 -7
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +230 -0
- package/README.md +147 -182
- package/USAGE.md +46 -9
- package/package.json +7 -7
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { SelectList, Text, truncateToWidth, type Component, type SelectItem, type SelectListTheme, type TUI } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./src/progress.ts";
|
|
6
|
-
import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
|
|
6
|
+
import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
|
|
7
7
|
import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
8
8
|
import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
|
|
9
|
+
import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./src/usage.ts";
|
|
9
10
|
import { registerDynamax } from "./src/dynamax.ts";
|
|
10
11
|
import { handleReviewViewerAction } from "./src/review/review-actions.ts";
|
|
11
12
|
import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
|
|
@@ -21,13 +22,15 @@ function summarize(result: unknown): string {
|
|
|
21
22
|
return typeof result === "string" ? result : "Workflow finished.";
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function formatMessageContent(name: string, result: unknown, perf?: WorkflowPerfDetails): string {
|
|
25
|
+
function formatMessageContent(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): string {
|
|
26
|
+
const usageLine = formatWorkflowUsageLine(usage);
|
|
25
27
|
const perfLine = formatPerfLine(perf);
|
|
26
|
-
|
|
28
|
+
const details = [usageLine, perfLine].filter((line): line is string => line !== undefined);
|
|
29
|
+
return `## Workflow: ${name}\n\n${summarize(result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
function workflowEnvelope(name: string, result: unknown, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
|
|
30
|
-
return { name, result, completedAt: Date.now(), perf };
|
|
32
|
+
function workflowEnvelope(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
|
|
33
|
+
return { name, result, completedAt: Date.now(), usage, perf };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
|
|
@@ -64,7 +67,95 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
|
|
|
64
67
|
return createPerfRecorder(true);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<WorkflowModule> {
|
|
74
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
75
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
|
|
76
|
+
const mod = workflows.get(ref);
|
|
77
|
+
if (!mod) {
|
|
78
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
79
|
+
throw new Error(`Unknown workflow "${ref}". Available: ${available}`);
|
|
80
|
+
}
|
|
81
|
+
return mod;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const AUTHOR_TEMP_WORKFLOW_VALUE = "__author-temporary-workflow__";
|
|
85
|
+
const AUTHOR_TEMP_WORKFLOW_LABEL = "Author temporary one-shot workflow";
|
|
86
|
+
const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run an inline workflow.";
|
|
87
|
+
const WORKFLOW_PICKER_MAX_VISIBLE = 8;
|
|
88
|
+
const WORKFLOW_PICKER_LAYOUT = { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 22 };
|
|
89
|
+
|
|
90
|
+
class WorkflowPickerComponent implements Component {
|
|
91
|
+
private readonly list: SelectList;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
items: SelectItem[],
|
|
95
|
+
private readonly tui: TUI,
|
|
96
|
+
private readonly theme: Theme,
|
|
97
|
+
done: (value: string | undefined) => void,
|
|
98
|
+
) {
|
|
99
|
+
this.list = new SelectList(items, Math.min(WORKFLOW_PICKER_MAX_VISIBLE, items.length), selectListTheme(theme), WORKFLOW_PICKER_LAYOUT);
|
|
100
|
+
this.list.onSelect = (item) => done(item.value);
|
|
101
|
+
this.list.onCancel = () => done(undefined);
|
|
102
|
+
this.list.onSelectionChange = () => this.tui.requestRender();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
render(width: number): string[] {
|
|
106
|
+
const safeWidth = Math.max(20, width);
|
|
107
|
+
const rule = this.theme.fg("borderMuted", "-".repeat(safeWidth));
|
|
108
|
+
const lines = [
|
|
109
|
+
rule,
|
|
110
|
+
this.theme.fg("accent", this.theme.bold("Run workflow")),
|
|
111
|
+
"",
|
|
112
|
+
...this.list.render(safeWidth),
|
|
113
|
+
"",
|
|
114
|
+
this.theme.fg("muted", "up/down navigate enter select escape cancel"),
|
|
115
|
+
rule,
|
|
116
|
+
];
|
|
117
|
+
return lines.map((line) => truncateToWidth(line, safeWidth));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
handleInput(data: string): void {
|
|
121
|
+
this.list.handleInput(data);
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidate(): void {
|
|
126
|
+
this.list.invalidate();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function selectListTheme(theme: Theme): SelectListTheme {
|
|
131
|
+
return {
|
|
132
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
133
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
134
|
+
description: (text) => theme.fg("muted", text),
|
|
135
|
+
scrollInfo: (text) => theme.fg("muted", text),
|
|
136
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function workflowPickerItems(workflows: ReadonlyMap<string, WorkflowModule>): SelectItem[] {
|
|
141
|
+
return [
|
|
142
|
+
{ value: AUTHOR_TEMP_WORKFLOW_VALUE, label: AUTHOR_TEMP_WORKFLOW_LABEL, description: AUTHOR_TEMP_WORKFLOW_DESCRIPTION },
|
|
143
|
+
...[...workflows.values()].map((mod) => ({ value: mod.meta.name, label: mod.meta.name, description: mod.meta.description })),
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
148
|
+
const items = workflowPickerItems(workflows);
|
|
149
|
+
if (typeof ctx.ui.custom === "function") {
|
|
150
|
+
return await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new WorkflowPickerComponent(items, tui, theme, done));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const choice = await ctx.ui.select(
|
|
154
|
+
"Run workflow",
|
|
155
|
+
items.map((item) => item.label),
|
|
156
|
+
);
|
|
157
|
+
return items.find((item) => item.label === choice)?.value ?? choice;
|
|
158
|
+
}
|
|
68
159
|
|
|
69
160
|
export interface LastWorkflowInspection {
|
|
70
161
|
readonly name: string;
|
|
@@ -73,19 +164,53 @@ export interface LastWorkflowInspection {
|
|
|
73
164
|
readonly snapshot: WorkflowProgressSnapshot;
|
|
74
165
|
}
|
|
75
166
|
|
|
167
|
+
export interface ActiveWorkflowInspection {
|
|
168
|
+
readonly name: string;
|
|
169
|
+
readonly args: string;
|
|
170
|
+
readonly startedAt: number;
|
|
171
|
+
readonly snapshot: () => WorkflowProgressSnapshot;
|
|
172
|
+
}
|
|
173
|
+
|
|
76
174
|
let lastWorkflowInspection: LastWorkflowInspection | undefined;
|
|
175
|
+
let activeWorkflowInspection: ActiveWorkflowInspection | undefined;
|
|
77
176
|
|
|
78
177
|
export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
|
|
79
178
|
return lastWorkflowInspection;
|
|
80
179
|
}
|
|
81
180
|
|
|
82
|
-
export
|
|
181
|
+
export function getActiveWorkflowInspection(): ActiveWorkflowInspection | undefined {
|
|
182
|
+
return activeWorkflowInspection;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function openWorkflowInspector(ctx: ExtensionContext, inspection: LastWorkflowInspection | ActiveWorkflowInspection): Promise<void> {
|
|
83
186
|
await ctx.ui.custom<void>(
|
|
84
|
-
(tui, theme, _keybindings, done) => new WorkflowInspector(()
|
|
187
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(snapshotGetter(inspection), tui, theme, () => done(undefined)),
|
|
85
188
|
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
86
189
|
);
|
|
87
190
|
}
|
|
88
191
|
|
|
192
|
+
async function openAvailableWorkflowInspector(ctx: ExtensionContext): Promise<void> {
|
|
193
|
+
if (!ctx.hasUI) {
|
|
194
|
+
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const inspection = activeWorkflowInspection ?? lastWorkflowInspection;
|
|
198
|
+
if (!inspection) {
|
|
199
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await openWorkflowInspector(ctx, inspection);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function snapshotGetter(inspection: LastWorkflowInspection | ActiveWorkflowInspection): () => WorkflowProgressSnapshot {
|
|
206
|
+
const snapshot = inspection.snapshot;
|
|
207
|
+
return typeof snapshot === "function" ? snapshot : () => snapshot;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
|
|
211
|
+
return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot() };
|
|
212
|
+
}
|
|
213
|
+
|
|
89
214
|
export interface WorkflowInvocation {
|
|
90
215
|
name: string;
|
|
91
216
|
args: string;
|
|
@@ -218,11 +343,10 @@ export async function pickWorkflow(
|
|
|
218
343
|
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
219
344
|
ctx: ExtensionCommandContext,
|
|
220
345
|
): Promise<WorkflowInvocation | undefined> {
|
|
221
|
-
const
|
|
222
|
-
const choice = await ctx.ui.select("Run workflow", choices);
|
|
346
|
+
const choice = await selectWorkflowValue(workflows, ctx);
|
|
223
347
|
if (!choice) return undefined;
|
|
224
348
|
|
|
225
|
-
if (choice ===
|
|
349
|
+
if (choice === AUTHOR_TEMP_WORKFLOW_VALUE || choice === AUTHOR_TEMP_WORKFLOW_LABEL) {
|
|
226
350
|
const brief = await ctx.ui.editor(
|
|
227
351
|
"Describe temporary workflow",
|
|
228
352
|
"Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
|
|
@@ -249,14 +373,31 @@ export async function sendWorkflowResult(
|
|
|
249
373
|
): Promise<void> {
|
|
250
374
|
const { runWorkflow } = await loadEngine();
|
|
251
375
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
376
|
+
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
377
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
252
378
|
const result = await runWorkflow(ctx, mod, args, {
|
|
253
379
|
...options,
|
|
254
380
|
perf: options.perf ?? perfRecorder !== undefined,
|
|
255
381
|
perfRecorder,
|
|
382
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
383
|
+
onProgressSource(source) {
|
|
384
|
+
if (source) {
|
|
385
|
+
liveInspection = bindActiveWorkflowInspection(name, args, source);
|
|
386
|
+
activeWorkflowInspection = liveInspection;
|
|
387
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
388
|
+
activeWorkflowInspection = undefined;
|
|
389
|
+
liveInspection = undefined;
|
|
390
|
+
}
|
|
391
|
+
options.onProgressSource?.(source);
|
|
392
|
+
},
|
|
256
393
|
onPerfSnapshot(snapshot) {
|
|
257
394
|
perfSnapshot = snapshot;
|
|
258
395
|
options.onPerfSnapshot?.(snapshot);
|
|
259
396
|
},
|
|
397
|
+
onUsageSnapshot(snapshot) {
|
|
398
|
+
usageSnapshot = snapshot;
|
|
399
|
+
options.onUsageSnapshot?.(snapshot);
|
|
400
|
+
},
|
|
260
401
|
onProgressSnapshot(snapshot) {
|
|
261
402
|
lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
262
403
|
options.onProgressSnapshot?.(snapshot);
|
|
@@ -276,38 +417,29 @@ export async function sendWorkflowResult(
|
|
|
276
417
|
await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
|
|
277
418
|
}
|
|
278
419
|
pi.sendMessage(
|
|
279
|
-
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
420
|
+
{ customType: "workflow-result", content: formatMessageContent(name, result, usageSnapshot, perf), display: true, details: workflowEnvelope(name, result, usageSnapshot, perf) },
|
|
280
421
|
{ triggerTurn: false },
|
|
281
422
|
);
|
|
282
423
|
}
|
|
283
424
|
|
|
284
425
|
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
285
|
-
registerDynamax(pi);
|
|
426
|
+
registerDynamax(pi, undefined, { openInspector: openAvailableWorkflowInspector });
|
|
286
427
|
|
|
287
428
|
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
288
429
|
const details = message.details;
|
|
289
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
430
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
|
|
290
431
|
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
291
432
|
});
|
|
292
433
|
|
|
293
434
|
pi.registerCommand("workflow:inspector", {
|
|
294
|
-
description: "Open the last
|
|
435
|
+
description: "Open the current or last workflow inspector",
|
|
295
436
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
437
|
const trimmed = args.trim();
|
|
297
438
|
if (trimmed && trimmed !== "last") {
|
|
298
439
|
ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
|
|
299
440
|
return;
|
|
300
441
|
}
|
|
301
|
-
|
|
302
|
-
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
const inspection = lastWorkflowInspection;
|
|
306
|
-
if (!inspection) {
|
|
307
|
-
ctx.ui.notify("No completed workflow inspector is available yet", "warning");
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
await openWorkflowInspector(ctx, inspection);
|
|
442
|
+
await openAvailableWorkflowInspector(ctx);
|
|
311
443
|
},
|
|
312
444
|
});
|
|
313
445
|
|
|
@@ -354,6 +486,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
354
486
|
"Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
|
|
355
487
|
"Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
|
|
356
488
|
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
489
|
+
"Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
|
|
357
490
|
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
358
491
|
],
|
|
359
492
|
parameters: Type.Object({
|
|
@@ -376,7 +509,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
376
509
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
377
510
|
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
378
511
|
const details = result.details;
|
|
379
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
512
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
|
|
380
513
|
const first = result.content[0];
|
|
381
514
|
const text = first?.type === "text" ? first.text : "Workflow finished.";
|
|
382
515
|
return new Text(theme.fg("muted", text), 0, 0);
|
|
@@ -386,6 +519,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
386
519
|
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
387
520
|
|
|
388
521
|
const runOptions: WorkflowRunOptions = {
|
|
522
|
+
inspect: ctx.hasUI,
|
|
389
523
|
concurrency: params.concurrency,
|
|
390
524
|
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
391
525
|
perf: params.perf,
|
|
@@ -421,17 +555,40 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
421
555
|
|
|
422
556
|
const { runWorkflow } = await loadEngine();
|
|
423
557
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
424
|
-
|
|
558
|
+
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
559
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
560
|
+
const resultArgs = params.args ?? "";
|
|
561
|
+
const result = await runWorkflow(ctx, mod, resultArgs, {
|
|
425
562
|
...runOptions,
|
|
426
563
|
perf: runOptions.perf ?? perfRecorder !== undefined,
|
|
427
564
|
perfRecorder,
|
|
565
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
566
|
+
onProgressSource(source) {
|
|
567
|
+
if (source) {
|
|
568
|
+
liveInspection = bindActiveWorkflowInspection(resultName, resultArgs, source);
|
|
569
|
+
activeWorkflowInspection = liveInspection;
|
|
570
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
571
|
+
activeWorkflowInspection = undefined;
|
|
572
|
+
liveInspection = undefined;
|
|
573
|
+
}
|
|
574
|
+
},
|
|
428
575
|
onPerfSnapshot: (snapshot) => {
|
|
429
576
|
perfSnapshot = snapshot;
|
|
430
577
|
},
|
|
578
|
+
onUsageSnapshot: (snapshot) => {
|
|
579
|
+
usageSnapshot = snapshot;
|
|
580
|
+
},
|
|
581
|
+
onProgressSnapshot: (snapshot) => {
|
|
582
|
+
// Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
|
|
583
|
+
// previously uninspectable, unlike the /workflow command path.
|
|
584
|
+
lastWorkflowInspection = { name: resultName, args: resultArgs, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
585
|
+
},
|
|
431
586
|
});
|
|
432
587
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
588
|
+
const usageLine = formatWorkflowUsageLine(usageSnapshot);
|
|
433
589
|
const perfLine = formatPerfLine(perf);
|
|
434
|
-
|
|
590
|
+
const detailLines = [usageLine, perfLine].filter((line): line is string => line !== undefined);
|
|
591
|
+
return { content: [{ type: "text", text: `${summarize(result)}${detailLines.length > 0 ? `\n\n${detailLines.join("\n")}` : ""}` }], details: workflowEnvelope(resultName, result, usageSnapshot, perf) };
|
|
435
592
|
},
|
|
436
593
|
});
|
|
437
594
|
}
|
|
@@ -4,6 +4,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
5
|
import type { Semaphore } from "./concurrency.ts";
|
|
6
6
|
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import type { WorkflowUsageSink } from "./usage.ts";
|
|
7
8
|
import { throwIfAborted } from "./cancellation.ts";
|
|
8
9
|
|
|
9
10
|
/** Name of the synthetic terminating tool that carries structured output. */
|
|
@@ -42,9 +43,69 @@ export interface RunContext {
|
|
|
42
43
|
progress: AgentProgress;
|
|
43
44
|
signal: AbortSignal | undefined;
|
|
44
45
|
perf: PerfSink;
|
|
46
|
+
usage: WorkflowUsageSink;
|
|
45
47
|
createSession?: CreateAgentSession;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
export interface ResolvedAgentModelRequest {
|
|
51
|
+
readonly ref: string;
|
|
52
|
+
readonly provider: string;
|
|
53
|
+
readonly id: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolvedAgentModel {
|
|
57
|
+
readonly model: Model<Api> | undefined;
|
|
58
|
+
readonly requested: ResolvedAgentModelRequest | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseAgentModelRef(modelRef: string): ResolvedAgentModelRequest {
|
|
62
|
+
const normalized = modelRef.trim();
|
|
63
|
+
if (normalized.length === 0) {
|
|
64
|
+
throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
|
|
65
|
+
}
|
|
66
|
+
if (normalized !== modelRef) {
|
|
67
|
+
throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const slash = modelRef.indexOf("/");
|
|
71
|
+
if (slash === -1) {
|
|
72
|
+
return { ref: modelRef, provider: "anthropic", id: modelRef };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const provider = modelRef.slice(0, slash);
|
|
76
|
+
const id = modelRef.slice(slash + 1);
|
|
77
|
+
if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
|
|
78
|
+
throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
|
|
79
|
+
}
|
|
80
|
+
return { ref: modelRef, provider, id };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a workflow agent model reference.
|
|
85
|
+
*
|
|
86
|
+
* Omitted refs inherit the host/session default model. Explicit refs are strict:
|
|
87
|
+
* bare model ids keep the original Anthropic shorthand for compatibility, and
|
|
88
|
+
* provider-qualified refs split only on the first slash so provider routers such
|
|
89
|
+
* as OpenRouter can use ids that contain additional slashes.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveAgentModel(
|
|
92
|
+
modelRef: string | undefined,
|
|
93
|
+
modelRegistry: Pick<ModelRegistry, "find">,
|
|
94
|
+
hostModel: Model<Api> | undefined,
|
|
95
|
+
): ResolvedAgentModel {
|
|
96
|
+
if (modelRef === undefined) {
|
|
97
|
+
return { model: hostModel, requested: undefined };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parsed = parseAgentModelRef(modelRef);
|
|
101
|
+
const found = modelRegistry.find(parsed.provider, parsed.id);
|
|
102
|
+
if (!found) {
|
|
103
|
+
throw new Error(`Agent model "${modelRef}" not found (resolved as ${parsed.provider}/${parsed.id}).`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { model: found, requested: parsed };
|
|
107
|
+
}
|
|
108
|
+
|
|
48
109
|
/** Pull the last assistant message's plain text out of a finished session. */
|
|
49
110
|
function lastAssistantText(state: { readonly messages: readonly unknown[] }): string {
|
|
50
111
|
const messages = state.messages;
|
|
@@ -124,7 +185,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
124
185
|
let captured: unknown = null;
|
|
125
186
|
let failed = false;
|
|
126
187
|
let session: AgentRunnerSession | undefined;
|
|
188
|
+
let usageRecorded = false;
|
|
127
189
|
let unsubscribe: (() => void) | undefined;
|
|
190
|
+
const recordUsage = (activeSession: AgentRunnerSession): void => {
|
|
191
|
+
if (usageRecorded) return;
|
|
192
|
+
usageRecorded = true;
|
|
193
|
+
rc.usage.recordAgentSession({ label, phase, messages: activeSession.state.messages });
|
|
194
|
+
};
|
|
128
195
|
const customTools: ToolDefinition[] = opts.schema
|
|
129
196
|
? [
|
|
130
197
|
defineTool({
|
|
@@ -149,8 +216,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
149
216
|
: opts.tools
|
|
150
217
|
: undefined;
|
|
151
218
|
|
|
152
|
-
|
|
153
|
-
const model = opts.model ? rc.modelRegistry.find("anthropic", opts.model) ?? rc.hostModel : rc.hostModel;
|
|
219
|
+
const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
|
|
154
220
|
const createSessionForRun = rc.createSession ?? defaultCreateSession;
|
|
155
221
|
|
|
156
222
|
throwIfAborted(rc.signal);
|
|
@@ -188,6 +254,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
188
254
|
} finally {
|
|
189
255
|
unlinkPromptAbort();
|
|
190
256
|
}
|
|
257
|
+
recordUsage(activeSession);
|
|
191
258
|
throwIfAborted(rc.signal);
|
|
192
259
|
|
|
193
260
|
return rc.perf.timeSync(
|
|
@@ -213,6 +280,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
213
280
|
} finally {
|
|
214
281
|
const disposable = session;
|
|
215
282
|
if (disposable) {
|
|
283
|
+
recordUsage(disposable);
|
|
216
284
|
rc.perf.timeSync(
|
|
217
285
|
"agent.dispose_ms",
|
|
218
286
|
() => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
|
|
7
|
+
|
|
8
|
+
const CONFIG_FILE_NAME = "pi-workflow-engine.json";
|
|
9
|
+
|
|
10
|
+
export interface DynamaxShortcuts {
|
|
11
|
+
inspector: KeyId | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DynamaxShortcutConfig {
|
|
15
|
+
inspector?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function dynamaxShortcutsConfigPath(agentDir: string = getAgentDir()): string {
|
|
19
|
+
return join(agentDir, "extensions", CONFIG_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveDynamaxShortcuts(configPath: string = dynamaxShortcutsConfigPath()): DynamaxShortcuts {
|
|
23
|
+
if (!existsSync(configPath)) return defaultDynamaxShortcuts();
|
|
24
|
+
|
|
25
|
+
const config = readShortcutConfig(configPath);
|
|
26
|
+
if (!config) return defaultDynamaxShortcuts();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
inspector: shortcutFromConfig(config.inspector, DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readShortcutConfig(configPath: string): DynamaxShortcutConfig | null {
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
warnUsingDefaults("Could not read", configPath);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shortcuts = isRecord(parsed) ? parsed.shortcuts : undefined;
|
|
43
|
+
if (shortcuts === undefined) return {};
|
|
44
|
+
if (!isRecord(shortcuts) || !hasValidShortcutValues(shortcuts)) {
|
|
45
|
+
warnUsingDefaults("Invalid", configPath);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return shortcuts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasValidShortcutValues(shortcuts: Record<string, unknown>): boolean {
|
|
53
|
+
return isValidShortcutConfigValue(shortcuts.inspector);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isValidShortcutConfigValue(value: unknown): value is string | null | undefined {
|
|
57
|
+
return value === undefined || value === null || (typeof value === "string" && value.trim().length > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function shortcutFromConfig(configured: unknown, fallback: KeyId): KeyId | null {
|
|
61
|
+
if (configured === null) return null;
|
|
62
|
+
return typeof configured === "string" ? (configured.trim() as KeyId) : fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultDynamaxShortcuts(): DynamaxShortcuts {
|
|
66
|
+
return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
|
|
74
|
+
console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default Dynamax shortcuts.`);
|
|
75
|
+
}
|