pi-workflow-engine 0.6.1 → 0.7.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 +87 -17
- 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 +146 -20
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +17 -12
- package/.pi/extensions/pi-workflow-engine/src/types.ts +18 -0
- package/README.md +5 -5
- package/USAGE.md +37 -5
- package/package.json +6 -6
- 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,9 +1,9 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Text } 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
9
|
import { registerDynamax } from "./src/dynamax.ts";
|
|
@@ -64,6 +64,20 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
|
|
|
64
64
|
return createPerfRecorder(true);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<WorkflowModule> {
|
|
71
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
72
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
|
|
73
|
+
const mod = workflows.get(ref);
|
|
74
|
+
if (!mod) {
|
|
75
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
76
|
+
throw new Error(`Unknown workflow "${ref}". Available: ${available}`);
|
|
77
|
+
}
|
|
78
|
+
return mod;
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
|
|
68
82
|
|
|
69
83
|
export interface LastWorkflowInspection {
|
|
@@ -73,19 +87,53 @@ export interface LastWorkflowInspection {
|
|
|
73
87
|
readonly snapshot: WorkflowProgressSnapshot;
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
export interface ActiveWorkflowInspection {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly args: string;
|
|
93
|
+
readonly startedAt: number;
|
|
94
|
+
readonly snapshot: () => WorkflowProgressSnapshot;
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
let lastWorkflowInspection: LastWorkflowInspection | undefined;
|
|
98
|
+
let activeWorkflowInspection: ActiveWorkflowInspection | undefined;
|
|
77
99
|
|
|
78
100
|
export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
|
|
79
101
|
return lastWorkflowInspection;
|
|
80
102
|
}
|
|
81
103
|
|
|
82
|
-
export
|
|
104
|
+
export function getActiveWorkflowInspection(): ActiveWorkflowInspection | undefined {
|
|
105
|
+
return activeWorkflowInspection;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function openWorkflowInspector(ctx: ExtensionContext, inspection: LastWorkflowInspection | ActiveWorkflowInspection): Promise<void> {
|
|
83
109
|
await ctx.ui.custom<void>(
|
|
84
|
-
(tui, theme, _keybindings, done) => new WorkflowInspector(()
|
|
110
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(snapshotGetter(inspection), tui, theme, () => done(undefined)),
|
|
85
111
|
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
86
112
|
);
|
|
87
113
|
}
|
|
88
114
|
|
|
115
|
+
async function openAvailableWorkflowInspector(ctx: ExtensionContext): Promise<void> {
|
|
116
|
+
if (!ctx.hasUI) {
|
|
117
|
+
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const inspection = activeWorkflowInspection ?? lastWorkflowInspection;
|
|
121
|
+
if (!inspection) {
|
|
122
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await openWorkflowInspector(ctx, inspection);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snapshotGetter(inspection: LastWorkflowInspection | ActiveWorkflowInspection): () => WorkflowProgressSnapshot {
|
|
129
|
+
const snapshot = inspection.snapshot;
|
|
130
|
+
return typeof snapshot === "function" ? snapshot : () => snapshot;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
|
|
134
|
+
return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot() };
|
|
135
|
+
}
|
|
136
|
+
|
|
89
137
|
export interface WorkflowInvocation {
|
|
90
138
|
name: string;
|
|
91
139
|
args: string;
|
|
@@ -249,10 +297,22 @@ export async function sendWorkflowResult(
|
|
|
249
297
|
): Promise<void> {
|
|
250
298
|
const { runWorkflow } = await loadEngine();
|
|
251
299
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
300
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
252
301
|
const result = await runWorkflow(ctx, mod, args, {
|
|
253
302
|
...options,
|
|
254
303
|
perf: options.perf ?? perfRecorder !== undefined,
|
|
255
304
|
perfRecorder,
|
|
305
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
306
|
+
onProgressSource(source) {
|
|
307
|
+
if (source) {
|
|
308
|
+
liveInspection = bindActiveWorkflowInspection(name, args, source);
|
|
309
|
+
activeWorkflowInspection = liveInspection;
|
|
310
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
311
|
+
activeWorkflowInspection = undefined;
|
|
312
|
+
liveInspection = undefined;
|
|
313
|
+
}
|
|
314
|
+
options.onProgressSource?.(source);
|
|
315
|
+
},
|
|
256
316
|
onPerfSnapshot(snapshot) {
|
|
257
317
|
perfSnapshot = snapshot;
|
|
258
318
|
options.onPerfSnapshot?.(snapshot);
|
|
@@ -282,7 +342,7 @@ export async function sendWorkflowResult(
|
|
|
282
342
|
}
|
|
283
343
|
|
|
284
344
|
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
285
|
-
registerDynamax(pi);
|
|
345
|
+
registerDynamax(pi, undefined, { openInspector: openAvailableWorkflowInspector });
|
|
286
346
|
|
|
287
347
|
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
288
348
|
const details = message.details;
|
|
@@ -291,23 +351,14 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
291
351
|
});
|
|
292
352
|
|
|
293
353
|
pi.registerCommand("workflow:inspector", {
|
|
294
|
-
description: "Open the last
|
|
354
|
+
description: "Open the current or last workflow inspector",
|
|
295
355
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
356
|
const trimmed = args.trim();
|
|
297
357
|
if (trimmed && trimmed !== "last") {
|
|
298
358
|
ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
|
|
299
359
|
return;
|
|
300
360
|
}
|
|
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);
|
|
361
|
+
await openAvailableWorkflowInspector(ctx);
|
|
311
362
|
},
|
|
312
363
|
});
|
|
313
364
|
|
|
@@ -354,6 +405,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
354
405
|
"Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
|
|
355
406
|
"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
407
|
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
408
|
+
"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
409
|
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
358
410
|
],
|
|
359
411
|
parameters: Type.Object({
|
|
@@ -386,6 +438,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
386
438
|
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
387
439
|
|
|
388
440
|
const runOptions: WorkflowRunOptions = {
|
|
441
|
+
inspect: ctx.hasUI,
|
|
389
442
|
concurrency: params.concurrency,
|
|
390
443
|
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
391
444
|
perf: params.perf,
|
|
@@ -421,13 +474,30 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
421
474
|
|
|
422
475
|
const { runWorkflow } = await loadEngine();
|
|
423
476
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
424
|
-
|
|
477
|
+
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
478
|
+
const resultArgs = params.args ?? "";
|
|
479
|
+
const result = await runWorkflow(ctx, mod, resultArgs, {
|
|
425
480
|
...runOptions,
|
|
426
481
|
perf: runOptions.perf ?? perfRecorder !== undefined,
|
|
427
482
|
perfRecorder,
|
|
483
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
484
|
+
onProgressSource(source) {
|
|
485
|
+
if (source) {
|
|
486
|
+
liveInspection = bindActiveWorkflowInspection(resultName, resultArgs, source);
|
|
487
|
+
activeWorkflowInspection = liveInspection;
|
|
488
|
+
} else if (activeWorkflowInspection === liveInspection) {
|
|
489
|
+
activeWorkflowInspection = undefined;
|
|
490
|
+
liveInspection = undefined;
|
|
491
|
+
}
|
|
492
|
+
},
|
|
428
493
|
onPerfSnapshot: (snapshot) => {
|
|
429
494
|
perfSnapshot = snapshot;
|
|
430
495
|
},
|
|
496
|
+
onProgressSnapshot: (snapshot) => {
|
|
497
|
+
// Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
|
|
498
|
+
// previously uninspectable, unlike the /workflow command path.
|
|
499
|
+
lastWorkflowInspection = { name: resultName, args: resultArgs, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
500
|
+
},
|
|
431
501
|
});
|
|
432
502
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
433
503
|
const perfLine = formatPerfLine(perf);
|
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
4
|
+
import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
|
|
3
5
|
|
|
4
6
|
export interface DynamaxState {
|
|
5
7
|
sticky: boolean;
|
|
6
8
|
oneShotPending: boolean;
|
|
9
|
+
turnActive: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DynamaxRuntime {
|
|
13
|
+
state: DynamaxState;
|
|
14
|
+
runningWorkflow?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
|
|
18
|
+
export interface DynamaxRegistrationOptions {
|
|
19
|
+
openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
|
|
7
20
|
}
|
|
8
21
|
|
|
9
22
|
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
23
|
+
export const DYNAMAX_STATUS_KEY = "dynamax";
|
|
24
|
+
export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
|
|
10
25
|
|
|
11
26
|
export const DYNAMAX_REMINDER = `
|
|
12
27
|
## dynamax workflow opt-in
|
|
@@ -23,7 +38,24 @@ Inline workflow rules:
|
|
|
23
38
|
const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
|
|
24
39
|
|
|
25
40
|
export function createDynamaxState(): DynamaxState {
|
|
26
|
-
return { sticky: false, oneShotPending: false };
|
|
41
|
+
return { sticky: false, oneShotPending: false, turnActive: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDynamaxRuntime(): DynamaxRuntime {
|
|
45
|
+
return { state: createDynamaxState() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
49
|
+
return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
|
|
53
|
+
const key = dynamaxSessionKey(ctx);
|
|
54
|
+
const existing = store.get(key);
|
|
55
|
+
if (existing) return existing;
|
|
56
|
+
const runtime = createDynamaxRuntime();
|
|
57
|
+
store.set(key, runtime);
|
|
58
|
+
return runtime;
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
export function hasDynamaxToken(text: string): boolean {
|
|
@@ -36,12 +68,16 @@ export function markDynamaxOneShot(state: DynamaxState): void {
|
|
|
36
68
|
|
|
37
69
|
export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
|
|
38
70
|
state.sticky = sticky;
|
|
39
|
-
if (!sticky)
|
|
71
|
+
if (!sticky) {
|
|
72
|
+
state.oneShotPending = false;
|
|
73
|
+
state.turnActive = false;
|
|
74
|
+
}
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
export function clearDynamax(state: DynamaxState): void {
|
|
43
78
|
state.sticky = false;
|
|
44
79
|
state.oneShotPending = false;
|
|
80
|
+
state.turnActive = false;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
83
|
export function consumeDynamaxOneShot(state: DynamaxState): boolean {
|
|
@@ -51,12 +87,32 @@ export function consumeDynamaxOneShot(state: DynamaxState): boolean {
|
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
export function isDynamaxActive(state: DynamaxState): boolean {
|
|
54
|
-
return state.sticky || state.oneShotPending;
|
|
90
|
+
return state.sticky || state.oneShotPending || state.turnActive;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function describeDynamaxState(state: DynamaxState): string {
|
|
94
|
+
const sticky = state.sticky ? "on" : "off";
|
|
95
|
+
const oneShot = state.oneShotPending ? "pending" : "clear";
|
|
96
|
+
const turn = state.turnActive ? "; active for current turn" : "";
|
|
97
|
+
return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
|
|
101
|
+
const modes = [
|
|
102
|
+
runningWorkflow ? `running ${runningWorkflow}` : undefined,
|
|
103
|
+
state.sticky ? "sticky on" : undefined,
|
|
104
|
+
!state.sticky && state.turnActive ? "active this turn" : undefined,
|
|
105
|
+
state.oneShotPending ? "one-shot pending" : undefined,
|
|
106
|
+
].filter((value): value is string => value !== undefined);
|
|
107
|
+
const mode = modes.join(" + ") || "off";
|
|
108
|
+
const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
|
|
109
|
+
return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
|
|
55
110
|
}
|
|
56
111
|
|
|
57
112
|
export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
|
|
58
|
-
if (!
|
|
113
|
+
if (!state.sticky && !state.oneShotPending) return systemPrompt;
|
|
59
114
|
state.oneShotPending = false;
|
|
115
|
+
state.turnActive = true;
|
|
60
116
|
return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
|
|
61
117
|
}
|
|
62
118
|
|
|
@@ -65,52 +121,174 @@ export function appendDynamaxContextReminder(messages: AgentMessage[], state: Dy
|
|
|
65
121
|
return [...messages, createDynamaxContextMessage()];
|
|
66
122
|
}
|
|
67
123
|
|
|
68
|
-
export function registerDynamax(
|
|
69
|
-
|
|
124
|
+
export function registerDynamax(
|
|
125
|
+
pi: ExtensionAPI,
|
|
126
|
+
shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
|
|
127
|
+
options: DynamaxRegistrationOptions = {},
|
|
128
|
+
): void {
|
|
129
|
+
const runtimes: DynamaxRuntimeStore = new Map();
|
|
130
|
+
const openInspector =
|
|
131
|
+
options.openInspector ??
|
|
132
|
+
((ctx: ExtensionContext): void => {
|
|
133
|
+
ctx.ui.notify("No workflow inspector is available yet", "warning");
|
|
134
|
+
});
|
|
135
|
+
const refreshIfVisible = (ctx: ExtensionContext): void => {
|
|
136
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
137
|
+
if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
|
|
138
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
pi.on("session_start", (_event, ctx) => {
|
|
143
|
+
updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
147
|
+
clearDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
|
|
148
|
+
});
|
|
70
149
|
|
|
71
|
-
pi.on("input", (event) => {
|
|
150
|
+
pi.on("input", (event, ctx) => {
|
|
72
151
|
if (event.source === "extension") return { action: "continue" };
|
|
73
|
-
|
|
152
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
153
|
+
if (hasDynamaxToken(event.text)) {
|
|
154
|
+
markDynamaxOneShot(runtime.state);
|
|
155
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
156
|
+
}
|
|
74
157
|
return { action: "continue" };
|
|
75
158
|
});
|
|
76
159
|
|
|
77
|
-
pi.on("before_agent_start", (event) => {
|
|
78
|
-
const
|
|
160
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
161
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
162
|
+
const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
|
|
79
163
|
if (systemPrompt === event.systemPrompt) return undefined;
|
|
164
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
80
165
|
return { systemPrompt };
|
|
81
166
|
});
|
|
82
167
|
|
|
83
|
-
pi.on("
|
|
84
|
-
|
|
168
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
169
|
+
refreshIfVisible(ctx);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
pi.on("message_start", (_event, ctx) => {
|
|
173
|
+
refreshIfVisible(ctx);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("message_update", (_event, ctx) => {
|
|
177
|
+
refreshIfVisible(ctx);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("message_end", (_event, ctx) => {
|
|
181
|
+
refreshIfVisible(ctx);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
185
|
+
refreshIfVisible(ctx);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
189
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
190
|
+
runtime.runningWorkflow = undefined;
|
|
191
|
+
if (!runtime.state.sticky) runtime.state.turnActive = false;
|
|
192
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
196
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
197
|
+
if (event.toolName === "workflow") {
|
|
198
|
+
runtime.runningWorkflow = workflowLabel(event.args);
|
|
199
|
+
}
|
|
200
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
201
|
+
return undefined;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.on("tool_execution_update", (_event, ctx) => {
|
|
205
|
+
refreshIfVisible(ctx);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
209
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
210
|
+
if (event.toolName === "workflow") {
|
|
211
|
+
runtime.runningWorkflow = undefined;
|
|
212
|
+
}
|
|
213
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
214
|
+
return undefined;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("context", (event, ctx) => {
|
|
218
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
219
|
+
const messages = appendDynamaxContextReminder(event.messages, runtime.state);
|
|
85
220
|
if (messages === event.messages) return undefined;
|
|
86
221
|
return { messages };
|
|
87
222
|
});
|
|
88
223
|
|
|
224
|
+
if (shortcuts.inspector) {
|
|
225
|
+
pi.registerShortcut(shortcuts.inspector, {
|
|
226
|
+
description: "Open workflow inspector",
|
|
227
|
+
handler: async (ctx) => {
|
|
228
|
+
await openInspector(ctx);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
89
233
|
pi.registerCommand("workflow:dynamax", {
|
|
90
|
-
description: "Toggle
|
|
234
|
+
description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
|
|
91
235
|
handler: async (args, ctx) => {
|
|
236
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
92
237
|
const action = args.trim().toLowerCase();
|
|
238
|
+
if (action === "") {
|
|
239
|
+
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
93
242
|
if (action === "on") {
|
|
94
|
-
setDynamaxSticky(state, true);
|
|
95
|
-
ctx
|
|
243
|
+
setDynamaxSticky(runtime.state, true);
|
|
244
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
245
|
+
ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
|
|
96
246
|
return;
|
|
97
247
|
}
|
|
98
248
|
if (action === "off") {
|
|
99
|
-
clearDynamax(state);
|
|
100
|
-
ctx
|
|
249
|
+
clearDynamax(runtime.state);
|
|
250
|
+
updateDynamaxSurfaces(ctx, runtime, shortcuts);
|
|
251
|
+
ctx.ui.notify("Dynamax workflow orchestration is off", "info");
|
|
101
252
|
return;
|
|
102
253
|
}
|
|
103
254
|
if (action === "status") {
|
|
104
|
-
|
|
105
|
-
const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
|
|
106
|
-
ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
|
|
255
|
+
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
|
|
107
256
|
return;
|
|
108
257
|
}
|
|
109
|
-
ctx.ui.notify("Usage: /workflow:dynamax on|off|status", "warning");
|
|
258
|
+
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
|
|
110
259
|
},
|
|
111
260
|
});
|
|
112
261
|
}
|
|
113
262
|
|
|
263
|
+
export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
|
|
264
|
+
if (!ctx.hasUI) return;
|
|
265
|
+
ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
|
|
266
|
+
ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
|
|
267
|
+
if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
|
|
268
|
+
clearDynamaxSurfaces(ctx, runtime);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
|
|
273
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
|
|
277
|
+
if (!ctx.hasUI) return;
|
|
278
|
+
ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
|
|
279
|
+
ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
|
|
280
|
+
ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function workflowLabel(args: unknown): string {
|
|
284
|
+
if (!isRecord(args)) return "workflow";
|
|
285
|
+
const name = args.name;
|
|
286
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
287
|
+
const script = args.script;
|
|
288
|
+
if (typeof script === "string" && script.trim()) return "inline workflow";
|
|
289
|
+
return "workflow";
|
|
290
|
+
}
|
|
291
|
+
|
|
114
292
|
function createDynamaxContextMessage(): AgentMessage {
|
|
115
293
|
return {
|
|
116
294
|
role: "custom",
|
|
@@ -121,3 +299,7 @@ function createDynamaxContextMessage(): AgentMessage {
|
|
|
121
299
|
timestamp: Date.now(),
|
|
122
300
|
};
|
|
123
301
|
}
|
|
302
|
+
|
|
303
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
304
|
+
return typeof value === "object" && value !== null;
|
|
305
|
+
}
|
|
@@ -5,12 +5,35 @@ import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
|
5
5
|
import { ProgressTracker } from "./progress.ts";
|
|
6
6
|
import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
|
|
7
7
|
import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
|
|
8
|
-
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
8
|
+
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
9
9
|
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
10
10
|
|
|
11
11
|
/** Default global cap on concurrent agents per run. */
|
|
12
12
|
const DEFAULT_CONCURRENCY = defaultConcurrency();
|
|
13
13
|
|
|
14
|
+
/** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
|
|
15
|
+
export interface WorkflowProgress {
|
|
16
|
+
phase(title: string): void;
|
|
17
|
+
log(message: string): void;
|
|
18
|
+
event(event: WorkflowProgressEvent): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Per-context knobs threaded through `runWorkflowWithContext` (and re-derived for each sub-workflow). */
|
|
22
|
+
export interface WorkflowContextOptions {
|
|
23
|
+
/** Controller whose `.signal` equals `rc.signal`; aborts in-flight `parallel` siblings on failure. */
|
|
24
|
+
abortController: AbortController;
|
|
25
|
+
/** Eager-submission cap for `parallel`. */
|
|
26
|
+
submissionLimit: number;
|
|
27
|
+
/** Resolve a sub-workflow reference for `api.workflow()`. Omit to disable composition. */
|
|
28
|
+
resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
|
|
29
|
+
/** Nesting depth. Sub-workflows run at depth >= 1, where `api.workflow()` rejects. */
|
|
30
|
+
depth?: number;
|
|
31
|
+
/** Prefix applied to phase titles so sub-workflow phases nest as "<name> ▸ <title>". */
|
|
32
|
+
progressPrefix?: string;
|
|
33
|
+
/** Namespace applied to child structured progress keys/lanes/logs. */
|
|
34
|
+
progressNamespace?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
/**
|
|
15
38
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
16
39
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
@@ -23,6 +46,8 @@ export async function runWorkflow(
|
|
|
23
46
|
): Promise<unknown> {
|
|
24
47
|
const resolvedOptions = resolveWorkflowRunOptions(options);
|
|
25
48
|
const progress = new ProgressTracker(ctx, mod.meta.name);
|
|
49
|
+
const progressSource = { snapshot: () => progress.snapshot() };
|
|
50
|
+
resolvedOptions.onProgressSource?.(progressSource);
|
|
26
51
|
if (resolvedOptions.inspect && ctx.hasUI) {
|
|
27
52
|
void ctx.ui
|
|
28
53
|
.custom<void>(
|
|
@@ -46,27 +71,17 @@ export async function runWorkflow(
|
|
|
46
71
|
perf,
|
|
47
72
|
};
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
parallel: (thunks) =>
|
|
54
|
-
parallel(thunks, {
|
|
55
|
-
signal: runAbortController.signal,
|
|
74
|
+
try {
|
|
75
|
+
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
76
|
+
return await perf.time("workflow.total_ms", () =>
|
|
77
|
+
runWorkflowWithContext(rc, progress, mod, args, {
|
|
56
78
|
abortController: runAbortController,
|
|
57
|
-
|
|
79
|
+
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
80
|
+
resolveWorkflow: resolvedOptions.resolveWorkflow,
|
|
81
|
+
depth: 0,
|
|
82
|
+
progressPrefix: "",
|
|
58
83
|
}),
|
|
59
|
-
|
|
60
|
-
phase: (title) => progress.phase(title),
|
|
61
|
-
log: (message) => progress.log(message),
|
|
62
|
-
progress: (event) => progress.event(event),
|
|
63
|
-
args,
|
|
64
|
-
cwd: ctx.cwd,
|
|
65
|
-
signal: runAbortController.signal,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
return await perf.time("workflow.total_ms", () => mod.default(api));
|
|
84
|
+
);
|
|
70
85
|
} finally {
|
|
71
86
|
try {
|
|
72
87
|
const snapshot = perf.snapshot();
|
|
@@ -77,12 +92,123 @@ export async function runWorkflow(
|
|
|
77
92
|
progress.done();
|
|
78
93
|
resolvedOptions.onProgressSnapshot?.(progress.snapshot());
|
|
79
94
|
} finally {
|
|
95
|
+
resolvedOptions.onProgressSource?.(undefined);
|
|
80
96
|
unlinkContextAbortSignal();
|
|
81
97
|
unlinkOptionAbortSignal();
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
104
|
+
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
105
|
+
* abort signal, and perf sink. No setup/teardown of its own — that belongs to `runWorkflow`.
|
|
106
|
+
*/
|
|
107
|
+
export async function runWorkflowWithContext(
|
|
108
|
+
rc: RunContext,
|
|
109
|
+
progress: WorkflowProgress,
|
|
110
|
+
mod: WorkflowModule,
|
|
111
|
+
args: string,
|
|
112
|
+
opts: WorkflowContextOptions,
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const depth = opts.depth ?? 0;
|
|
115
|
+
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
116
|
+
|
|
117
|
+
const agent = ((prompt: string, agentOpts?: AgentOptions) => runAgent(rc, prompt, scope.agentOptions(agentOpts))) as WorkflowApi["agent"];
|
|
118
|
+
|
|
119
|
+
const workflow: WorkflowApi["workflow"] =
|
|
120
|
+
depth >= 1
|
|
121
|
+
? async () => {
|
|
122
|
+
throw new Error("workflow() can only nest one level deep");
|
|
123
|
+
}
|
|
124
|
+
: async (ref, childArgs) => {
|
|
125
|
+
if (!opts.resolveWorkflow) throw new Error("sub-workflows are not enabled in this context");
|
|
126
|
+
const childMod = await opts.resolveWorkflow(ref);
|
|
127
|
+
// A child controller isolates the child's failures from the parent (so the parent can
|
|
128
|
+
// try/catch a failed sub-workflow), while still cancelling the child when the run aborts.
|
|
129
|
+
const childController = new AbortController();
|
|
130
|
+
const unlink = linkAbortSignal(rc.signal, childController);
|
|
131
|
+
try {
|
|
132
|
+
return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childArgs ?? "", {
|
|
133
|
+
abortController: childController,
|
|
134
|
+
submissionLimit: opts.submissionLimit,
|
|
135
|
+
resolveWorkflow: opts.resolveWorkflow,
|
|
136
|
+
depth: depth + 1,
|
|
137
|
+
progressPrefix: `${childMod.meta.name} ▸ `,
|
|
138
|
+
progressNamespace: childMod.meta.name,
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
unlink();
|
|
142
|
+
scope.restorePhase();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const api: WorkflowApi = {
|
|
147
|
+
agent,
|
|
148
|
+
workflow,
|
|
149
|
+
parallel: (thunks) =>
|
|
150
|
+
parallel(thunks, {
|
|
151
|
+
signal: rc.signal,
|
|
152
|
+
abortController: opts.abortController,
|
|
153
|
+
limit: opts.submissionLimit,
|
|
154
|
+
}),
|
|
155
|
+
pipeline,
|
|
156
|
+
phase: scope.phase,
|
|
157
|
+
log: scope.log,
|
|
158
|
+
progress: scope.event,
|
|
159
|
+
args,
|
|
160
|
+
cwd: rc.cwd,
|
|
161
|
+
signal: rc.signal,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return await mod.default(api);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface WorkflowScope {
|
|
168
|
+
agentOptions(opts: AgentOptions | undefined): AgentOptions;
|
|
169
|
+
phase(title: string): void;
|
|
170
|
+
log(message: string): void;
|
|
171
|
+
event(event: WorkflowProgressEvent): void;
|
|
172
|
+
restorePhase(): void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespace: string | undefined): WorkflowScope {
|
|
176
|
+
let currentPhase = `${prefix}Workflow`;
|
|
177
|
+
const display = (title: string) => `${prefix}${title}`;
|
|
178
|
+
return {
|
|
179
|
+
agentOptions(opts) {
|
|
180
|
+
const phase = opts?.phase ? display(opts.phase) : currentPhase;
|
|
181
|
+
return opts ? { ...opts, phase } : { phase };
|
|
182
|
+
},
|
|
183
|
+
phase(title) {
|
|
184
|
+
currentPhase = display(title);
|
|
185
|
+
progress.phase(currentPhase);
|
|
186
|
+
},
|
|
187
|
+
log(message) {
|
|
188
|
+
progress.log(namespace ? `${namespace}: ${message}` : message);
|
|
189
|
+
},
|
|
190
|
+
event(event) {
|
|
191
|
+
progress.event(namespace ? namespaceProgressEvent(namespace, event) : event);
|
|
192
|
+
},
|
|
193
|
+
restorePhase() {
|
|
194
|
+
progress.phase(currentPhase);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function namespaceProgressEvent(namespace: string, event: WorkflowProgressEvent): WorkflowProgressEvent {
|
|
200
|
+
switch (event.type) {
|
|
201
|
+
case "counter":
|
|
202
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
203
|
+
case "counter_delta":
|
|
204
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
205
|
+
case "lane_item":
|
|
206
|
+
return { ...event, lane: `${namespace} ▸ ${event.lane}` };
|
|
207
|
+
case "summary":
|
|
208
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
86
212
|
function formatPerfSummary(snapshot: PerfSnapshot): string {
|
|
87
213
|
const parts = snapshot.aggregates
|
|
88
214
|
.filter((aggregate) => aggregate.count > 0)
|
|
@@ -28,7 +28,7 @@ export type InlineCommentStatus =
|
|
|
28
28
|
| { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
|
|
29
29
|
| { readonly issueId: string; readonly status: "failed"; readonly reason: string };
|
|
30
30
|
|
|
31
|
-
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url
|
|
31
|
+
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
|
|
32
32
|
|
|
33
33
|
export async function resolveGitHubPrContext(
|
|
34
34
|
exec: ExecLike,
|
|
@@ -47,10 +47,10 @@ export async function resolveGitHubPrContext(
|
|
|
47
47
|
if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
|
|
48
48
|
if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
let owner =
|
|
53
|
-
let repo =
|
|
50
|
+
const url = stringField(prView.value, "url");
|
|
51
|
+
const baseFromUrl = parsePullRequestUrl(url);
|
|
52
|
+
let owner = baseFromUrl?.owner;
|
|
53
|
+
let repo = baseFromUrl?.repo;
|
|
54
54
|
if (!owner || !repo) {
|
|
55
55
|
const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"]);
|
|
56
56
|
if (!repoView.ok) return { ok: false, reason: repoView.reason };
|
|
@@ -61,7 +61,6 @@ export async function resolveGitHubPrContext(
|
|
|
61
61
|
}
|
|
62
62
|
if (!owner || !repo) return { ok: false, reason: "GitHub repository owner/name is missing." };
|
|
63
63
|
|
|
64
|
-
const url = stringField(prView.value, "url");
|
|
65
64
|
return { ok: true, context: { owner, repo, number, headSha, url } };
|
|
66
65
|
}
|
|
67
66
|
|
|
@@ -159,12 +158,6 @@ function stringField(value: unknown, key: string): string | undefined {
|
|
|
159
158
|
return typeof field === "string" && field.trim().length > 0 ? field : undefined;
|
|
160
159
|
}
|
|
161
160
|
|
|
162
|
-
function nestedStringField(value: unknown, objectKey: string, key: string): string | undefined {
|
|
163
|
-
if (!isRecord(value)) return undefined;
|
|
164
|
-
const nested = value[objectKey];
|
|
165
|
-
return stringField(nested, key);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
161
|
function parseNameWithOwner(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
169
162
|
if (!value) return undefined;
|
|
170
163
|
const [owner, repo, extra] = value.split("/");
|
|
@@ -172,6 +165,18 @@ function parseNameWithOwner(value: string | undefined): { readonly owner: string
|
|
|
172
165
|
return { owner, repo };
|
|
173
166
|
}
|
|
174
167
|
|
|
168
|
+
function parsePullRequestUrl(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
169
|
+
if (!value) return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const parsed = new URL(value);
|
|
172
|
+
const [owner, repo, type, number, extra] = parsed.pathname.split("/").filter(Boolean);
|
|
173
|
+
if (!owner || !repo || type !== "pull" || !number || extra !== undefined) return undefined;
|
|
174
|
+
return { owner, repo };
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
175
180
|
function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
176
181
|
try {
|
|
177
182
|
return stringField(JSON.parse(stdout), "html_url");
|
|
@@ -4,6 +4,9 @@ import type { Pipeline } from "./concurrency.ts";
|
|
|
4
4
|
import type { PerfSink, PerfSnapshot } from "./perf.ts";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./progress.ts";
|
|
6
6
|
|
|
7
|
+
/** A reference to a registered workflow by name. */
|
|
8
|
+
export type WorkflowRef = string;
|
|
9
|
+
|
|
7
10
|
/** Metadata every workflow module must export. */
|
|
8
11
|
export interface WorkflowMeta {
|
|
9
12
|
name: string;
|
|
@@ -29,12 +32,20 @@ export interface WorkflowRunOptions {
|
|
|
29
32
|
signal?: AbortSignal;
|
|
30
33
|
/** Internal recorder override for command/tool invocation timing. */
|
|
31
34
|
perfRecorder?: PerfSink;
|
|
35
|
+
/** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
|
|
36
|
+
resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
|
|
32
37
|
/** Called with the final performance snapshot when perf is enabled. */
|
|
33
38
|
onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
|
|
39
|
+
/** Called with the live progress source while a workflow is running, then undefined when it ends. */
|
|
40
|
+
onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
|
|
34
41
|
/** Called with the final completed progress snapshot after live workflow UI teardown. */
|
|
35
42
|
onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
export interface WorkflowProgressSource {
|
|
46
|
+
snapshot(): WorkflowProgressSnapshot;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Options for a single `agent()` call. */
|
|
39
50
|
export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
|
|
40
51
|
|
|
@@ -75,6 +86,13 @@ export interface WorkflowApi {
|
|
|
75
86
|
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
|
|
76
87
|
/** Run a subagent and return its final assistant text. */
|
|
77
88
|
agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Run another registered workflow inline as a sub-step and return its result. The child shares
|
|
91
|
+
* this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
|
|
92
|
+
* `workflow()` from within a sub-workflow rejects. Rejects on unknown name — catch it to handle
|
|
93
|
+
* a missing sub-workflow gracefully.
|
|
94
|
+
*/
|
|
95
|
+
workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
|
|
78
96
|
/** Run every thunk concurrently and wait for all (a barrier). */
|
|
79
97
|
parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
|
|
80
98
|
/** Run each item through all stages independently — no barrier between stages. */
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ In a pi session, from inside a git repo with changes:
|
|
|
66
66
|
/workflow diagnose "typecheck fails after the schema change"
|
|
67
67
|
/workflow perf-review "workflow startup latency"
|
|
68
68
|
/workflow code-review --inspect # open the live workflow inspector while the run is active
|
|
69
|
-
/workflow:inspector #
|
|
69
|
+
/workflow:inspector # open the current or last workflow inspector
|
|
70
70
|
/workflow code-review --result-viewer # explicitly open the post-review results viewer
|
|
71
71
|
/workflow code-review --review-viewer # alias for --result-viewer
|
|
72
72
|
/workflow code-review --no-result-viewer # explicitly keep the post-review viewer closed
|
|
@@ -87,7 +87,7 @@ dynamax inspect this bug with multiple focused agents
|
|
|
87
87
|
/workflow:dynamax off
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
`dynamax` is a permission signal for the host agent: once opted in, it may run an existing named workflow or author an inline workflow script through the `workflow` tool. In interactive mode, `/workflow` with no arguments also offers `✍ Author temporary one-shot workflow…`; choose it, type a brief, and pi will ask the host agent to author/run a temporary inline workflow.
|
|
90
|
+
`dynamax` is a one-shot permission signal for the host agent: once opted in, it may run an existing named workflow or author an inline workflow script through the `workflow` tool. Dynamax workflow-tool runs open the live workflow inspector in the TUI while they run. `/workflow:dynamax on|off|status` controls sticky mode, and `/hotkeys` shows the registered inspector shortcut (`ctrl+shift+m` by default). Sticky mode, one-shot pending mode, and active Dynamax workflow runs show compact status with the relevant commands while active and leave no persistent UI when cleared. In interactive mode, `/workflow` with no arguments also offers `✍ Author temporary one-shot workflow…`; choose it, type a brief, and pi will ask the host agent to author/run a temporary inline workflow.
|
|
91
91
|
|
|
92
92
|
The advisory workflows inspect and report only; they do not edit files. They return the same shape: summary, ranked findings, evidence, impact, recommendations, and next steps.
|
|
93
93
|
|
|
@@ -110,7 +110,7 @@ The bundled review workflow is deliberately shaped like a serious review process
|
|
|
110
110
|
4. **Verify**: send each survivor to an independent verifier that must confirm, mark plausible, or refute with evidence.
|
|
111
111
|
5. **Synthesize**: produce one ranked report with stats, verdicts, and concrete locations.
|
|
112
112
|
|
|
113
|
-
After a direct TUI `/workflow code-review` run produces findings, pi records a readable table-formatted result message without asking whether to open another surface. Use `--result-viewer`/`--review-viewer` when you want the interactive findings viewer to open immediately, or `--no-result-viewer`/`--no-review-viewer` when a script or alias should explicitly keep it closed. Use `/workflow:inspector` after a run to
|
|
113
|
+
After a direct TUI `/workflow code-review` run produces findings, pi records a readable table-formatted result message without asking whether to open another surface. Use `--result-viewer`/`--review-viewer` when you want the interactive findings viewer to open immediately, or `--no-result-viewer`/`--no-review-viewer` when a script or alias should explicitly keep it closed. Use `/workflow:inspector` during or after a run to open the workflow inspector.
|
|
114
114
|
|
|
115
115
|
Viewer controls:
|
|
116
116
|
|
|
@@ -194,7 +194,7 @@ Under the hood:
|
|
|
194
194
|
- **Each `agent()` is an in-process pi `AgentSession`** using `createAgentSession` and `SessionManager.inMemory()`.
|
|
195
195
|
- **Structured output is a terminating tool**. The engine registers one tool whose `parameters` is your schema; pi validates the call and the engine captures the args. There is no JSON scraping.
|
|
196
196
|
- **A single global semaphore caps concurrent agents**, so `parallel` and `pipeline` can nest freely while every `agent()` call still respects the run cap.
|
|
197
|
-
- **Three surfaces are registered**: `/workflow <name> [args]` for direct use, grouped workflow commands such as `/workflow:dynamax on|off|status` and `/workflow:inspector`, and a `workflow` tool for host-agent delegation by `name` or inline `script`.
|
|
197
|
+
- **Three surfaces are registered**: `/workflow <name> [args]` for direct use, grouped workflow commands such as `/workflow:dynamax [on|off|status]` and `/workflow:inspector`, and a `workflow` tool for host-agent delegation by `name` or inline `script`.
|
|
198
198
|
|
|
199
199
|
## Local development
|
|
200
200
|
|
|
@@ -208,7 +208,7 @@ bun run typecheck
|
|
|
208
208
|
bun run test
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
The test suite is no-LLM and uses Bun's built-in `bun test` runner, not a third-party test framework.
|
|
211
|
+
Typechecking uses TypeScript Native Preview (`tsgo`) through `bun run typecheck`. The test suite is no-LLM and uses Bun's built-in `bun test` runner, not a third-party test framework.
|
|
212
212
|
|
|
213
213
|
### Performance controls and benchmarks
|
|
214
214
|
|
package/USAGE.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Usage
|
|
2
2
|
|
|
3
|
-
`pi-workflow-engine` adds multi-agent workflows to pi. Most users only need
|
|
3
|
+
`pi-workflow-engine` adds multi-agent workflows to pi. Most users only need these surfaces:
|
|
4
4
|
|
|
5
5
|
- `/workflow <name> [args]` to run a saved workflow directly.
|
|
6
|
-
- `/workflow:*` commands for related actions such as inspector reopening and
|
|
6
|
+
- `/workflow:*` commands for related actions such as inspector reopening and Dynamax control.
|
|
7
7
|
- `dynamax` as a literal one-shot opt-in token when you want the host agent to author a one-off inline workflow for investigation/review.
|
|
8
8
|
|
|
9
9
|
## Run a workflow
|
|
@@ -24,7 +24,7 @@ Useful flags:
|
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
26
|
/workflow code-review --inspect # show the live inspector while the run is active
|
|
27
|
-
/workflow:inspector #
|
|
27
|
+
/workflow:inspector # open the current or last workflow inspector
|
|
28
28
|
/workflow code-review --result-viewer # explicitly open the findings viewer
|
|
29
29
|
/workflow code-review --review-viewer # alias for --result-viewer
|
|
30
30
|
/workflow code-review --concurrency=4 # cap concurrent subagents
|
|
@@ -58,7 +58,7 @@ dynamax create a one-off workflow to compare the parser and tests before we chan
|
|
|
58
58
|
dynamax use parallel agents to review this design and report the risks
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Sticky
|
|
61
|
+
Sticky mode and inspector shortcut:
|
|
62
62
|
|
|
63
63
|
```text
|
|
64
64
|
/workflow:dynamax on
|
|
@@ -66,7 +66,25 @@ Sticky for the session:
|
|
|
66
66
|
/workflow:dynamax status
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
The workflow inspector is also registered as a first-class shortcut shown by `/hotkeys`; the default is `ctrl+shift+m`. `ctrl+o` is intentionally not used because pi already uses it for tool-output expansion and tree filtering.
|
|
70
|
+
|
|
71
|
+
When only the literal `dynamax` token is used, the opt-in is one-shot: the next agent run receives the workflow permission reminder, stays visibly active for that run, then clears after the run ends. When `/workflow:dynamax on` is used, the opt-in is sticky for the current pi session until `/workflow:dynamax off`. Sticky mode, one-shot pending mode, and active Dynamax workflow runs show compact TUI status with `/workflow:dynamax on|off` and the inspector shortcut; off mode clears that status line.
|
|
72
|
+
|
|
73
|
+
When the host agent calls the `workflow` tool from a TUI session, pi opens the live workflow inspector for that run. The compact workflow widget still shows the latest moving status above the editor, but the inspector is the richer view for phases, agents, findings, and logs.
|
|
74
|
+
|
|
75
|
+
Configure the inspector shortcut by creating `~/.pi/agent/extensions/pi-workflow-engine.json`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"shortcuts": {
|
|
80
|
+
"inspector": "ctrl+shift+x"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Set `"inspector": null` to disable the shortcut while keeping `/workflow:dynamax` and `/workflow:inspector` available.
|
|
86
|
+
|
|
87
|
+
With Dynamax enabled, the host agent usually calls the `workflow` tool with `script`: a one-off inline workflow. It can still call a saved workflow by `name` when one already fits.
|
|
70
88
|
|
|
71
89
|
## Workflow results
|
|
72
90
|
|
|
@@ -121,9 +139,23 @@ Core primitives:
|
|
|
121
139
|
| `pipeline(items, ...stages)` | Runs each item through stages independently. |
|
|
122
140
|
| `phase(title)` / `log(message)` | Updates workflow progress UI. |
|
|
123
141
|
| `progress(event)` | Emits counters, summaries, and lane items. |
|
|
142
|
+
| `workflow(ref, args?)` | Runs another workflow inline as a sub-step and returns its result. |
|
|
124
143
|
|
|
125
144
|
Set `thinkingLevel` on fan-out agents. Otherwise many subagents can inherit an expensive global reasoning level.
|
|
126
145
|
|
|
146
|
+
### Compose workflows
|
|
147
|
+
|
|
148
|
+
`workflow(ref, args?)` runs a registered workflow by name as a sub-step, returning its result. The child shares the parent run's concurrency cap, abort signal, and perf timing, and its phases nest under `<name> ▸ <phase>` in the live UI.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
export default async function run({ workflow }: WorkflowApi) {
|
|
152
|
+
const review = await workflow("code-review", "HEAD~3");
|
|
153
|
+
return { review };
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Nesting is one level only: calling `workflow()` from inside a sub-workflow rejects. Resolution throws on an unknown name — wrap the call in `try/catch` if a missing sub-workflow should be non-fatal (note `parallel()` rejects the whole batch on the first error, so catch inside each thunk for per-branch resilience).
|
|
158
|
+
|
|
127
159
|
### Where workflows live
|
|
128
160
|
|
|
129
161
|
Guaranteed built-ins are statically registered:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-workflow-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
".pi/extensions/pi-workflow-engine",
|
|
25
25
|
".pi/settings.json",
|
|
26
|
-
"assets",
|
|
26
|
+
"assets/preview.png",
|
|
27
27
|
"skills",
|
|
28
28
|
"README.md",
|
|
29
29
|
"USAGE.md",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"image": "https://raw.githubusercontent.com/timbrinded/pi-workflow-engine/master/assets/preview.png"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"typecheck": "
|
|
39
|
-
"test": "bun test",
|
|
38
|
+
"typecheck": "tsgo --noEmit",
|
|
39
|
+
"test": "bun test --timeout=30000",
|
|
40
40
|
"test:changedlines": "bun test tests/changedlines.test.ts",
|
|
41
41
|
"test:concurrency": "bun test tests/concurrency.test.ts",
|
|
42
42
|
"test:smoke": "bun test tests/discovery.test.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"bench:discovery": "bun run scripts/bench/discovery.ts",
|
|
47
47
|
"bench:startup": "bun run scripts/bench/startup.ts",
|
|
48
48
|
"bench:ui": "bun run scripts/bench/ui.ts",
|
|
49
|
-
"prepublishOnly": "
|
|
49
|
+
"prepublishOnly": "tsgo --noEmit && bun run test"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@earendil-works/pi-agent-core": "*",
|
|
@@ -57,6 +57,6 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/node": "^22",
|
|
60
|
-
"typescript": "^
|
|
60
|
+
"@typescript/native-preview": "^7.0.0-dev.20260615.1"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|