pi-workflow-engine 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/pi-workflow-engine/index.ts +199 -85
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +11 -3
- package/USAGE.md +124 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +162 -0
|
@@ -4,18 +4,75 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
|
|
6
6
|
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
|
|
7
8
|
import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
|
|
8
9
|
import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
|
|
9
10
|
import { captureSourceTreeFingerprint, type FingerprintCapture } from "./tree-fingerprint.ts";
|
|
10
11
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
12
|
+
import { readBoundedScriptFile } from "./workflow-script-file.ts";
|
|
11
13
|
|
|
12
14
|
export interface DiscoverWorkflowsOptions {
|
|
13
15
|
readonly refresh?: boolean;
|
|
14
16
|
readonly perf?: PerfSink;
|
|
15
17
|
readonly userWorkflowDir?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Project root whose `.pi/workflows/*.js` saved scripts load right after the built-ins. Callers omit
|
|
20
|
+
* it for an untrusted project, which then contributes no workflows.
|
|
21
|
+
*/
|
|
22
|
+
readonly projectDir?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Built-ins and `.ts` drop-ins hash source trees and import modules, so they load once per directory pair
|
|
27
|
+
* until a refresh. Saved scripts are not cached: see `discoverWorkflows`.
|
|
28
|
+
*/
|
|
29
|
+
interface CachedModules {
|
|
30
|
+
readonly builtins: readonly LoadedWorkflow[];
|
|
31
|
+
readonly repoDynamic: readonly LoadedWorkflow[];
|
|
32
|
+
readonly userDynamic: readonly LoadedWorkflow[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const moduleCache = new Map<string, CachedModules>();
|
|
36
|
+
|
|
37
|
+
/** Saved scripts are re-read on every discovery, so each distinct skip is reported once, not on every lookup. */
|
|
38
|
+
const reportedScriptSkips = new Set<string>();
|
|
39
|
+
|
|
40
|
+
function isWorkflowFileCandidate(name: string, extension: string): boolean {
|
|
41
|
+
return name.endsWith(extension) && !name.startsWith("_") && !name.startsWith(".");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load every `*.js` saved workflow in the inline script format from a directory. Unlike `.ts` drop-ins
|
|
46
|
+
* they are compiled with the injected `Type`, so they share pi's typebox, and a content fingerprint is
|
|
47
|
+
* their whole identity, so they replay on resume.
|
|
48
|
+
*/
|
|
49
|
+
async function loadScriptDir(dir: string): Promise<LoadedWorkflow[]> {
|
|
50
|
+
let entries: string[];
|
|
51
|
+
try {
|
|
52
|
+
entries = await readdir(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const workflows: LoadedWorkflow[] = [];
|
|
57
|
+
for (const name of entries.filter((entry) => isWorkflowFileCandidate(entry, ".js")).sort()) {
|
|
58
|
+
const path = join(dir, name);
|
|
59
|
+
try {
|
|
60
|
+
// Discovery runs on every lookup, so a linked FIFO or huge file must not stall it.
|
|
61
|
+
const source = await readBoundedScriptFile(path, path);
|
|
62
|
+
const compiled = compileInlineWorkflow(source);
|
|
63
|
+
workflows.push(loadWorkflow(compiled, { kind: "file", path, root: dir, fingerprint: inlineScriptFingerprint(source), selfContained: true }));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const note = `[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`;
|
|
66
|
+
const key = `${path}\0${note}`;
|
|
67
|
+
if (!reportedScriptSkips.has(key)) {
|
|
68
|
+
reportedScriptSkips.add(key);
|
|
69
|
+
console.error(note);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return workflows;
|
|
16
74
|
}
|
|
17
75
|
|
|
18
|
-
const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
|
|
19
76
|
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
20
77
|
async function loadDir(
|
|
21
78
|
dir: string,
|
|
@@ -29,9 +86,7 @@ async function loadDir(
|
|
|
29
86
|
} catch {
|
|
30
87
|
return [];
|
|
31
88
|
}
|
|
32
|
-
const candidates = entries.filter(
|
|
33
|
-
(name) => !excludeFiles.has(name) && name.endsWith(".ts") && !name.startsWith("_") && !name.startsWith("."),
|
|
34
|
-
);
|
|
89
|
+
const candidates = entries.filter((name) => !excludeFiles.has(name) && isWorkflowFileCandidate(name, ".ts"));
|
|
35
90
|
// The shipped workflows/ dir holds only excluded built-ins; skip the tree hash when nothing will be imported.
|
|
36
91
|
if (candidates.length === 0) return [];
|
|
37
92
|
|
|
@@ -56,72 +111,86 @@ async function loadDir(
|
|
|
56
111
|
}
|
|
57
112
|
|
|
58
113
|
/**
|
|
59
|
-
* All available workflows by name.
|
|
60
|
-
* bundled example is always the verified one even if a same-named file is dropped in
|
|
114
|
+
* All available workflows by name. The first source to define a name wins: the static registry, so
|
|
115
|
+
* the bundled example is always the verified one even if a same-named file is dropped in, then the
|
|
116
|
+
* project's saved scripts, the extension's `.ts` drop-ins, the user's saved scripts, and the user's
|
|
117
|
+
* `.ts` drop-ins.
|
|
118
|
+
*
|
|
119
|
+
* Saved scripts are read and compiled on every call (small files, no import), so one written or edited
|
|
120
|
+
* mid-session resolves by name with its current content; built-ins and `.ts` drop-ins come from the cache
|
|
121
|
+
* unless `refresh` is set.
|
|
61
122
|
*/
|
|
62
123
|
export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
|
|
63
124
|
const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
|
|
125
|
+
const projectWorkflowDir = options.projectDir === undefined ? undefined : join(options.projectDir, ".pi", "workflows");
|
|
126
|
+
return await timed(options.perf, "discovery.total_ms", async () => {
|
|
127
|
+
const [modules, projectScripts, userScripts] = await Promise.all([
|
|
128
|
+
cachedModules(repoDir, userWorkflowDir, options),
|
|
129
|
+
projectWorkflowDir === undefined
|
|
130
|
+
? []
|
|
131
|
+
: timed(options.perf, "discovery.project_scripts_ms", () => loadScriptDir(projectWorkflowDir)),
|
|
132
|
+
timed(options.perf, "discovery.user_scripts_ms", () => loadScriptDir(userWorkflowDir)),
|
|
133
|
+
]);
|
|
134
|
+
const byName = new Map<string, LoadedWorkflow>();
|
|
135
|
+
for (const mod of [...modules.builtins, ...projectScripts, ...modules.repoDynamic, ...userScripts, ...modules.userDynamic]) {
|
|
136
|
+
if (!byName.has(mod.meta.name)) byName.set(mod.meta.name, mod);
|
|
137
|
+
}
|
|
138
|
+
return byName;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function cachedModules(repoDir: string, userWorkflowDir: string, options: DiscoverWorkflowsOptions): Promise<CachedModules> {
|
|
64
143
|
const cacheKey = `${repoDir}\0${userWorkflowDir}`;
|
|
65
|
-
const cached =
|
|
144
|
+
const cached = moduleCache.get(cacheKey);
|
|
66
145
|
if (cached && !options.refresh) {
|
|
67
146
|
options.perf?.counter("discovery.cache_hit");
|
|
68
|
-
return
|
|
147
|
+
return cached;
|
|
69
148
|
}
|
|
70
149
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
: {
|
|
84
|
-
kind: "unverifiable",
|
|
85
|
-
reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
|
|
86
|
-
};
|
|
87
|
-
next.set(definition.module.meta.name, loadWorkflow(definition.module, source));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const repoWorkflowDir = join(repoDir, "workflows");
|
|
91
|
-
// The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
|
|
92
|
-
const repoProvenance =
|
|
93
|
-
resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
|
|
94
|
-
const [repoDynamic, userDynamic] = await Promise.all([
|
|
95
|
-
timed(options.perf, "discovery.repo_dir_ms", () =>
|
|
96
|
-
loadDir(
|
|
97
|
-
repoWorkflowDir,
|
|
98
|
-
() => ({
|
|
150
|
+
const builtinSource = await captureSourceTreeFingerprint(BUILTIN_SOURCE_ROOT);
|
|
151
|
+
const builtins = BUILTIN_WORKFLOW_DEFINITIONS.map((definition) => {
|
|
152
|
+
const source: WorkflowSourceIdentity =
|
|
153
|
+
builtinSource.kind === "verified"
|
|
154
|
+
? {
|
|
155
|
+
kind: "file",
|
|
156
|
+
path: definition.path,
|
|
157
|
+
root: BUILTIN_SOURCE_ROOT,
|
|
158
|
+
fingerprint: builtinSource.fingerprint,
|
|
159
|
+
}
|
|
160
|
+
: {
|
|
99
161
|
kind: "unverifiable",
|
|
100
|
-
reason:
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
162
|
+
reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
|
|
163
|
+
};
|
|
164
|
+
return loadWorkflow(definition.module, source);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const repoWorkflowDir = join(repoDir, "workflows");
|
|
168
|
+
// The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
|
|
169
|
+
const repoProvenance =
|
|
170
|
+
resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
|
|
171
|
+
const [repoDynamic, userDynamic] = await Promise.all([
|
|
172
|
+
timed(options.perf, "discovery.repo_dir_ms", () =>
|
|
173
|
+
loadDir(
|
|
174
|
+
repoWorkflowDir,
|
|
175
|
+
() => ({
|
|
108
176
|
kind: "unverifiable",
|
|
109
|
-
reason: "dynamic
|
|
110
|
-
})
|
|
177
|
+
reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
|
|
178
|
+
}),
|
|
179
|
+
BUILTIN_WORKFLOW_FILES,
|
|
180
|
+
repoProvenance,
|
|
111
181
|
),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return next;
|
|
121
|
-
});
|
|
182
|
+
),
|
|
183
|
+
timed(options.perf, "discovery.user_dir_ms", () =>
|
|
184
|
+
loadDir(userWorkflowDir, () => ({
|
|
185
|
+
kind: "unverifiable",
|
|
186
|
+
reason: "dynamic user workflow dependencies do not have a declared trusted source root",
|
|
187
|
+
})),
|
|
188
|
+
),
|
|
189
|
+
]);
|
|
122
190
|
|
|
123
|
-
|
|
124
|
-
|
|
191
|
+
const modules: CachedModules = { builtins, repoDynamic, userDynamic };
|
|
192
|
+
moduleCache.set(cacheKey, modules);
|
|
193
|
+
return modules;
|
|
125
194
|
}
|
|
126
195
|
|
|
127
196
|
async function timed<T>(perf: PerfSink | undefined, name: string, fn: () => Promise<T>): Promise<T> {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { CustomEditor, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { EditorComponent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { completeCurrentArgument } from "./command-completions.ts";
|
|
4
4
|
import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
|
|
5
|
+
import { appendSectionToForcedPrompt } from "./prompt-sections.ts";
|
|
5
6
|
import { sessionKey } from "./session-identity.ts";
|
|
7
|
+
import { WORKFLOW_TOOL_NAME } from "./types.ts";
|
|
6
8
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
9
|
import { dot, GLYPH } from "./ui/kit.ts";
|
|
8
10
|
import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
|
|
@@ -20,8 +22,19 @@ export interface DynamaxState {
|
|
|
20
22
|
turnActive: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** The output-token pool a `+500k`-style directive in the user's prompt sets for that turn's workflow runs. */
|
|
26
|
+
export interface DynamaxTurnBudget {
|
|
27
|
+
readonly total: number;
|
|
28
|
+
/** Tokens not yet spent by this turn's synchronous runs; never negative. */
|
|
29
|
+
remaining(): number;
|
|
30
|
+
/** Count a finished synchronous run's output tokens against the pool. */
|
|
31
|
+
spend(outputTokens: number): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
export interface DynamaxRuntime {
|
|
24
35
|
state: DynamaxState;
|
|
36
|
+
/** Set by a budget directive in a user prompt, cleared when that run settles. */
|
|
37
|
+
turnBudget?: DynamaxTurnBudget;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
|
|
@@ -35,9 +48,18 @@ export interface DynamaxRegistrationOptions {
|
|
|
35
48
|
export interface DynamaxHandle {
|
|
36
49
|
/** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
|
|
37
50
|
markOneShot(ctx: ExtensionContext): void;
|
|
51
|
+
/** The current turn's output-token pool, when the user's prompt set one. */
|
|
52
|
+
turnBudget(ctx: ExtensionContext): DynamaxTurnBudget | undefined;
|
|
53
|
+
/** Whether Dynamax covers this session now: sticky, a pending one-shot, or a one-shot turn in progress. */
|
|
54
|
+
isActive(ctx: ExtensionContext): boolean;
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
58
|
+
/** `+500k`, `+1.5m`, `+2M`, `+250000`: after the start or whitespace, before the end, whitespace, or punctuation. */
|
|
59
|
+
export const BUDGET_DIRECTIVE_PATTERN = /(?:^|\s)\+(\d+(?:\.\d+)?)([km]?)(?=$|\s|[.,;:!?)\]}"'])/i;
|
|
60
|
+
/** Smaller values are ignored, so a `+1` or `+2` in ordinary prose never caps a run at a few tokens. */
|
|
61
|
+
export const BUDGET_DIRECTIVE_MIN_TOKENS = 1_000;
|
|
62
|
+
const BUDGET_DIRECTIVE_MULTIPLIERS: Readonly<Record<string, number>> = { "": 1, k: 1_000, m: 1_000_000 };
|
|
41
63
|
export const DYNAMAX_STATUS_KEY = "dynamax";
|
|
42
64
|
|
|
43
65
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
@@ -53,29 +75,31 @@ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): v
|
|
|
53
75
|
decorations.clear();
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
/** With no argument the command toggles sticky mode, so these name only the explicit forms. */
|
|
56
79
|
const DYNAMAX_ACTION_COMPLETIONS = [
|
|
57
|
-
{ value: "on", description: "
|
|
80
|
+
{ value: "on", description: "Make workflow orchestration the default for this session" },
|
|
58
81
|
{ value: "off", description: "Disable sticky and pending Dynamax modes" },
|
|
59
82
|
{ value: "status", description: "Show the current Dynamax state" },
|
|
60
83
|
] as const;
|
|
61
84
|
|
|
62
|
-
export const ADAPTIVE_WORKFLOW_GUIDANCE = `
|
|
63
|
-
Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
|
|
64
|
-
- run the bounded first-pass agents;
|
|
65
|
-
- give their surviving results to a structured gap-analysis agent so the LLM decides what needs follow-up;
|
|
66
|
-
- put a hard maxItems bound on LLM-authored task arrays and defensively slice them before fan-out;
|
|
67
|
-
- use ordinary TypeScript conditionals or bounded loops to launch follow-up agents only when gaps exist;
|
|
68
|
-
- synthesize the first-pass and follow-up results together.
|
|
69
|
-
Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
|
|
70
|
-
|
|
71
85
|
/** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
|
|
72
86
|
export const DYNAMAX_SECTION = "dynamax";
|
|
73
87
|
|
|
74
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Only the opt-in itself: the workflow tool's guidelines send the model to the workflow-authoring
|
|
90
|
+
* skill, which carries the authoring rules and patterns.
|
|
91
|
+
*/
|
|
75
92
|
export const DYNAMAX_REMINDER =
|
|
76
93
|
"The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
|
|
77
94
|
"Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
|
|
78
95
|
|
|
96
|
+
/** Sticky mode is a standing default for the session, not a one-request permission. */
|
|
97
|
+
export const DYNAMAX_STICKY_REMINDER =
|
|
98
|
+
"Dynamax is on for this session. Orchestrate every substantive task with the workflow tool by default: " +
|
|
99
|
+
"scout inline first to find the work list, then run one single-phase workflow per phase (understand, design, implement, review) " +
|
|
100
|
+
"and read each result before choosing the next. Lean toward adversarial verification of findings. " +
|
|
101
|
+
"Work without a workflow only for conversational turns and trivial or already-verified edits.";
|
|
102
|
+
|
|
79
103
|
export function createDynamaxState(): DynamaxState {
|
|
80
104
|
return { sticky: false, oneShotPending: false, turnActive: false };
|
|
81
105
|
}
|
|
@@ -97,6 +121,46 @@ export function hasDynamaxToken(text: string): boolean {
|
|
|
97
121
|
return DYNAMAX_TOKEN_PATTERN.test(text);
|
|
98
122
|
}
|
|
99
123
|
|
|
124
|
+
/** The output tokens the first budget directive in `text` asks for, if any. */
|
|
125
|
+
export function parseBudgetDirective(text: string): number | undefined {
|
|
126
|
+
const match = BUDGET_DIRECTIVE_PATTERN.exec(text);
|
|
127
|
+
if (!match) return undefined;
|
|
128
|
+
const multiplier = BUDGET_DIRECTIVE_MULTIPLIERS[(match[2] ?? "").toLowerCase()] ?? 1;
|
|
129
|
+
// Round, not floor: 2.3 * 1000 is 2299.999... in floating point.
|
|
130
|
+
const tokens = Math.round(Number(match[1]) * multiplier);
|
|
131
|
+
return Number.isSafeInteger(tokens) && tokens >= BUDGET_DIRECTIVE_MIN_TOKENS ? tokens : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createDynamaxTurnBudget(total: number): DynamaxTurnBudget {
|
|
135
|
+
let spent = 0;
|
|
136
|
+
return {
|
|
137
|
+
total,
|
|
138
|
+
remaining: () => Math.max(0, total - spent),
|
|
139
|
+
spend(outputTokens) {
|
|
140
|
+
if (Number.isFinite(outputTokens) && outputTokens > 0) spent += outputTokens;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type DynamaxTurnBudgetResolution =
|
|
146
|
+
| { readonly kind: "budget"; readonly budget: number | undefined }
|
|
147
|
+
| { readonly kind: "spent"; readonly total: number };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A tool run's budget. Only the user sets one: without a turn pool this adds none, and the pool caps
|
|
151
|
+
* every run in its turn at the remainder, clamped to the run budget range. A pool with less than `min`
|
|
152
|
+
* left is spent.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveTurnBudget(
|
|
155
|
+
pool: DynamaxTurnBudget | undefined,
|
|
156
|
+
range: { readonly min: number; readonly max: number },
|
|
157
|
+
): DynamaxTurnBudgetResolution {
|
|
158
|
+
if (pool === undefined) return { kind: "budget", budget: undefined };
|
|
159
|
+
const remaining = pool.remaining();
|
|
160
|
+
if (remaining < range.min) return { kind: "spent", total: pool.total };
|
|
161
|
+
return { kind: "budget", budget: Math.min(range.max, Math.floor(remaining)) };
|
|
162
|
+
}
|
|
163
|
+
|
|
100
164
|
export function markDynamaxOneShot(state: DynamaxState): void {
|
|
101
165
|
state.oneShotPending = true;
|
|
102
166
|
}
|
|
@@ -136,12 +200,21 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
|
|
|
136
200
|
/**
|
|
137
201
|
* Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
|
|
138
202
|
* run, so an inactive run simply leaves it out and pi records the removal as a patch.
|
|
203
|
+
*
|
|
204
|
+
* Sticky mode covers every run, including one a custom message started (`sendMessage` with
|
|
205
|
+
* `triggerTurn`, such as a background completion). Only a user prompt consumes the one-shot: the
|
|
206
|
+
* opt-in belongs to the request the user typed it in.
|
|
139
207
|
*/
|
|
140
|
-
export function applyDynamaxPromptSection(
|
|
141
|
-
|
|
142
|
-
state
|
|
208
|
+
export function applyDynamaxPromptSection(
|
|
209
|
+
sections: Record<string, string>,
|
|
210
|
+
state: DynamaxState,
|
|
211
|
+
messageType: BeforeAgentStartEvent["messageType"] = "user",
|
|
212
|
+
): boolean {
|
|
213
|
+
const oneShot = messageType === "user" && state.oneShotPending;
|
|
214
|
+
if (!state.sticky && !oneShot) return false;
|
|
215
|
+
if (oneShot) state.oneShotPending = false;
|
|
143
216
|
state.turnActive = true;
|
|
144
|
-
sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
|
|
217
|
+
sections[DYNAMAX_SECTION] = state.sticky ? DYNAMAX_STICKY_REMINDER : DYNAMAX_REMINDER;
|
|
145
218
|
return true;
|
|
146
219
|
}
|
|
147
220
|
|
|
@@ -230,23 +303,30 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
230
303
|
});
|
|
231
304
|
|
|
232
305
|
pi.on("input", (event, ctx) => {
|
|
233
|
-
if (event.source
|
|
306
|
+
if (event.source === "extension") return { action: "continue" };
|
|
307
|
+
if (hasDynamaxToken(event.text)) markOneShot(ctx);
|
|
308
|
+
const budget = parseBudgetDirective(event.text);
|
|
309
|
+
if (budget !== undefined) getDynamaxRuntime(runtimes, ctx).turnBudget = createDynamaxTurnBudget(budget);
|
|
234
310
|
return { action: "continue" };
|
|
235
311
|
});
|
|
236
312
|
|
|
237
313
|
pi.on("before_agent_start", (event, ctx) => {
|
|
238
|
-
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
239
314
|
const options = event.systemPromptOptions;
|
|
240
|
-
|
|
315
|
+
// Subagent sessions load this extension but withhold the workflow tool, so a `dynamax` in a
|
|
316
|
+
// subagent prompt must neither permit that tool nor consume the one-shot.
|
|
317
|
+
if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
|
|
318
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
319
|
+
if (!applyDynamaxPromptSection(options.sections, runtime.state, event.messageType)) return undefined;
|
|
241
320
|
updateDynamaxSurfaces(ctx, runtime);
|
|
242
|
-
|
|
243
|
-
if (options.forceSystemPrompt === undefined) return undefined;
|
|
244
|
-
return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
|
|
321
|
+
return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
|
|
245
322
|
});
|
|
246
323
|
|
|
247
|
-
|
|
324
|
+
// Not agent_end: pi emits that per agent loop, and one run continues in fresh loops after a
|
|
325
|
+
// provider retry, overflow compaction, or queued follow-up. agent_settled fires once per run.
|
|
326
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
248
327
|
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
249
328
|
if (!runtime.state.sticky) runtime.state.turnActive = false;
|
|
329
|
+
runtime.turnBudget = undefined;
|
|
250
330
|
updateDynamaxSurfaces(ctx, runtime);
|
|
251
331
|
});
|
|
252
332
|
|
|
@@ -260,15 +340,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
260
340
|
}
|
|
261
341
|
|
|
262
342
|
pi.registerCommand("workflow:dynamax", {
|
|
263
|
-
description: "Toggle Dynamax
|
|
343
|
+
description: "Toggle sticky Dynamax orchestration for this session: /workflow:dynamax [on|off|status]",
|
|
264
344
|
getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
|
|
265
345
|
handler: async (args, ctx) => {
|
|
266
346
|
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
347
|
+
const typed = args.trim().toLowerCase();
|
|
348
|
+
const action = typed === "" ? (runtime.state.sticky ? "off" : "on") : typed;
|
|
272
349
|
if (action === "on") {
|
|
273
350
|
enableDynamaxSticky(runtime.state);
|
|
274
351
|
updateDynamaxSurfaces(ctx, runtime);
|
|
@@ -285,11 +362,15 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
285
362
|
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
|
|
286
363
|
return;
|
|
287
364
|
}
|
|
288
|
-
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
|
|
365
|
+
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status] (no argument toggles sticky mode)", "warning");
|
|
289
366
|
},
|
|
290
367
|
});
|
|
291
368
|
|
|
292
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
markOneShot,
|
|
371
|
+
turnBudget: (ctx) => getDynamaxRuntime(runtimes, ctx).turnBudget,
|
|
372
|
+
isActive: (ctx) => isDynamaxActive(getDynamaxRuntime(runtimes, ctx).state),
|
|
373
|
+
};
|
|
293
374
|
}
|
|
294
375
|
|
|
295
376
|
export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
|