pi-workflow-engine 1.0.0 → 1.2.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 +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -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/classify.ts +107 -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 +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -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/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- 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 +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -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/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- 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 +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -7,6 +7,7 @@ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from
|
|
|
7
7
|
import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
|
|
8
8
|
import { createBudget } from "./budget.ts";
|
|
9
9
|
import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
|
|
10
|
+
import { runClassifier } from "./classify.ts";
|
|
10
11
|
import { ProgressTracker } from "./progress.ts";
|
|
11
12
|
import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts";
|
|
12
13
|
import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
|
|
@@ -14,11 +15,12 @@ import {
|
|
|
14
15
|
resolveWorkflowRunOptions,
|
|
15
16
|
type ResolvedWorkflowRunOptions,
|
|
16
17
|
} from "./options.ts";
|
|
17
|
-
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
18
|
+
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowMeta, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
18
19
|
import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
19
20
|
import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
|
|
20
21
|
import { WorktreeRegistry } from "./worktree.ts";
|
|
21
22
|
import { runFinalizers } from "./finalizers.ts";
|
|
23
|
+
import { workflowArgsText, workflowInputPresent } from "./workflow-input.ts";
|
|
22
24
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
23
25
|
import {
|
|
24
26
|
captureWorkflowResumeContext,
|
|
@@ -26,11 +28,14 @@ import {
|
|
|
26
28
|
} from "./resume-context.ts";
|
|
27
29
|
import {
|
|
28
30
|
DurableWorkflowRun,
|
|
31
|
+
persistWorkflowRunScript,
|
|
29
32
|
ProjectWorkflowRunStore,
|
|
30
33
|
workflowRunRecordPath,
|
|
31
34
|
type WorkflowRunStore,
|
|
32
35
|
} from "./workflow-run-store.ts";
|
|
33
36
|
import { createWorkflowRunRecord } from "./workflow-run-record.ts";
|
|
37
|
+
import { canRelaunchWorkflowRun } from "./workflow-run-history.ts";
|
|
38
|
+
import { sealWorkflowRunScript } from "./workflow-run-seal.ts";
|
|
34
39
|
import {
|
|
35
40
|
createProviderUsageLimitPauseRecord,
|
|
36
41
|
WorkflowProviderUsageLimitError,
|
|
@@ -75,22 +80,23 @@ export interface WorkflowEngineDependencies {
|
|
|
75
80
|
/**
|
|
76
81
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
77
82
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
83
|
+
* `input` is a slash command's argument string or a tool call's JSON value.
|
|
78
84
|
*/
|
|
79
85
|
export async function runWorkflow(
|
|
80
86
|
ctx: ExtensionContext,
|
|
81
87
|
mod: LoadedWorkflow,
|
|
82
|
-
|
|
88
|
+
input: unknown,
|
|
83
89
|
options: WorkflowRunOptions = {},
|
|
84
90
|
dependencies: WorkflowEngineDependencies = {},
|
|
85
91
|
): Promise<unknown> {
|
|
86
|
-
return await runResolvedWorkflow(ctx, mod,
|
|
92
|
+
return await runResolvedWorkflow(ctx, mod, input, resolveWorkflowRunOptions(options), dependencies);
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
/** Execute an options-normalized workflow without re-reading environment defaults. */
|
|
90
96
|
export async function runResolvedWorkflow(
|
|
91
97
|
ctx: ExtensionContext,
|
|
92
98
|
mod: LoadedWorkflow,
|
|
93
|
-
|
|
99
|
+
input: unknown,
|
|
94
100
|
resolvedOptions: ResolvedWorkflowRunOptions,
|
|
95
101
|
dependencies: WorkflowEngineDependencies = {},
|
|
96
102
|
): Promise<unknown> {
|
|
@@ -106,16 +112,20 @@ export async function runResolvedWorkflow(
|
|
|
106
112
|
const journalPath = workflowJournalPath(ctx.cwd, runId);
|
|
107
113
|
const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
|
|
108
114
|
const runStore = new ProjectWorkflowRunStore(ctx.cwd);
|
|
115
|
+
const script = await sealRunScript(ctx.cwd, runId, mod);
|
|
109
116
|
let persistenceFailureReported = false;
|
|
117
|
+
const initialRecord = createWorkflowRunRecord({
|
|
118
|
+
runId,
|
|
119
|
+
workflow: mod,
|
|
120
|
+
options: resolvedOptions,
|
|
121
|
+
progress: progress.snapshot(),
|
|
122
|
+
argumentsPresent: workflowInputPresent(input),
|
|
123
|
+
scriptPersisted: script.path !== undefined,
|
|
124
|
+
scriptSeal: script.seal,
|
|
125
|
+
});
|
|
110
126
|
const durableRun = new DurableWorkflowRun(
|
|
111
127
|
runStore,
|
|
112
|
-
|
|
113
|
-
runId,
|
|
114
|
-
workflow: mod,
|
|
115
|
-
options: resolvedOptions,
|
|
116
|
-
progress: progress.snapshot(),
|
|
117
|
-
argumentsPresent: args.length > 0,
|
|
118
|
-
}),
|
|
128
|
+
initialRecord,
|
|
119
129
|
(error) => {
|
|
120
130
|
if (persistenceFailureReported) return;
|
|
121
131
|
persistenceFailureReported = true;
|
|
@@ -123,7 +133,11 @@ export async function runResolvedWorkflow(
|
|
|
123
133
|
},
|
|
124
134
|
);
|
|
125
135
|
durableProgress = durableRun;
|
|
126
|
-
const progressSource = {
|
|
136
|
+
const progressSource = {
|
|
137
|
+
snapshot: () => progress.snapshot(),
|
|
138
|
+
state: () => durableRun.state,
|
|
139
|
+
skipAgent: (agentId: number) => progress.skipAgent(agentId),
|
|
140
|
+
};
|
|
127
141
|
const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
|
|
128
142
|
const runAbortController = new AbortController();
|
|
129
143
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
@@ -131,7 +145,7 @@ export async function runResolvedWorkflow(
|
|
|
131
145
|
const workflowOutcome = await captureOutcome(async () => {
|
|
132
146
|
await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
|
|
133
147
|
if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
|
|
134
|
-
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
|
|
148
|
+
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }), progressSource)
|
|
135
149
|
.catch((error: unknown) => {
|
|
136
150
|
try {
|
|
137
151
|
progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
|
|
@@ -150,6 +164,7 @@ export async function runResolvedWorkflow(
|
|
|
150
164
|
resumedFromRunId: resolvedOptions.resumeFromRunId,
|
|
151
165
|
journalPath,
|
|
152
166
|
recordPath: workflowRunRecordPath(ctx.cwd, runId),
|
|
167
|
+
...(script.path === undefined ? {} : { scriptPath: script.path }),
|
|
153
168
|
}),
|
|
154
169
|
);
|
|
155
170
|
progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
|
|
@@ -177,11 +192,13 @@ export async function runResolvedWorkflow(
|
|
|
177
192
|
budget,
|
|
178
193
|
journal,
|
|
179
194
|
worktrees,
|
|
195
|
+
hostTools: resolvedOptions.hostTools,
|
|
196
|
+
classifierRoutes: new Map(),
|
|
180
197
|
};
|
|
181
198
|
|
|
182
199
|
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
183
200
|
return perf.time("workflow.total_ms", () =>
|
|
184
|
-
runWorkflowWithContext(rc, progress, mod,
|
|
201
|
+
runWorkflowWithContext(rc, progress, mod, input, {
|
|
185
202
|
abortController: runAbortController,
|
|
186
203
|
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
187
204
|
resolveWorkflow: resolvedOptions.resolveWorkflow,
|
|
@@ -222,7 +239,7 @@ export async function runResolvedWorkflow(
|
|
|
222
239
|
durableRun.transition({
|
|
223
240
|
state: "paused",
|
|
224
241
|
progress: progress.snapshot(),
|
|
225
|
-
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions,
|
|
242
|
+
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, canRelaunchWorkflowRun(initialRecord)),
|
|
226
243
|
});
|
|
227
244
|
} else if (pauseError) {
|
|
228
245
|
durableRun.transition({
|
|
@@ -244,6 +261,33 @@ export async function runResolvedWorkflow(
|
|
|
244
261
|
throw outcome.error;
|
|
245
262
|
}
|
|
246
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Seal a run whose code came from a script for this install, so a repository cannot ship a run record
|
|
266
|
+
* whose relaunch runs its own code. An inline run's script is also written beside its record; a saved
|
|
267
|
+
* script resolves again by name, and the seal pins the content that ran. A failed write or seal only
|
|
268
|
+
* costs the run its relaunch from `/workflow:runs`.
|
|
269
|
+
*/
|
|
270
|
+
async function sealRunScript(cwd: string, runId: string, mod: LoadedWorkflow): Promise<{ readonly path?: string; readonly seal?: string }> {
|
|
271
|
+
const { source } = mod;
|
|
272
|
+
const savedScript = source.kind === "file" && source.selfContained === true;
|
|
273
|
+
if (!savedScript && (source.kind !== "fingerprint" || mod.inlineScript === undefined)) return {};
|
|
274
|
+
let path: string | undefined;
|
|
275
|
+
if (mod.inlineScript !== undefined && source.kind === "fingerprint") {
|
|
276
|
+
try {
|
|
277
|
+
path = await persistWorkflowRunScript(cwd, runId, mod.inlineScript);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
process.stderr.write(`[${mod.meta.name}] script persistence failed: ${unknownErrorMessage(error)}\n`);
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return { path, seal: await sealWorkflowRunScript(runId, source.fingerprint) };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
process.stderr.write(`[${mod.meta.name}] script seal failed, so /workflow:runs cannot relaunch this run: ${unknownErrorMessage(error)}\n`);
|
|
287
|
+
return { path };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
247
291
|
function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
|
|
248
292
|
if (isWorkflowPauseError(error)) return error;
|
|
249
293
|
for (const signal of signals) {
|
|
@@ -342,7 +386,7 @@ function createWorkflowAgent(
|
|
|
342
386
|
function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
343
387
|
function agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
344
388
|
function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
|
|
345
|
-
const scopedOptions = scope.agentOptions(agentOpts);
|
|
389
|
+
const scopedOptions = scope.agentOptions(withPhaseProfile(mod.meta, agentOpts));
|
|
346
390
|
const executionOptions: AgentExecutionOptions =
|
|
347
391
|
scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
|
|
348
392
|
? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
|
|
@@ -352,6 +396,17 @@ function createWorkflowAgent(
|
|
|
352
396
|
return agent;
|
|
353
397
|
}
|
|
354
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Routing order is the agent's `model`, then its `profile`, then its declared phase's `profile`, then
|
|
401
|
+
* the host. Only an explicit `opts.phase` selects a phase profile: the global `phase()` is shared
|
|
402
|
+
* state that concurrent `parallel`/`pipeline` branches race on.
|
|
403
|
+
*/
|
|
404
|
+
function withPhaseProfile(meta: WorkflowMeta, opts: AgentOptions | undefined): AgentOptions | undefined {
|
|
405
|
+
if (opts?.phase === undefined || opts.model !== undefined || opts.profile !== undefined) return opts;
|
|
406
|
+
const profile = meta.phases?.find((phase) => phase.title === opts.phase)?.profile;
|
|
407
|
+
return profile === undefined ? opts : { ...opts, profile };
|
|
408
|
+
}
|
|
409
|
+
|
|
355
410
|
/**
|
|
356
411
|
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
357
412
|
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
@@ -361,10 +416,12 @@ export async function runWorkflowWithContext(
|
|
|
361
416
|
rc: WorkflowRunContext,
|
|
362
417
|
progress: WorkflowProgress,
|
|
363
418
|
mod: LoadedWorkflow,
|
|
364
|
-
|
|
419
|
+
input: unknown,
|
|
365
420
|
opts: WorkflowContextOptions,
|
|
366
421
|
): Promise<unknown> {
|
|
367
422
|
const depth = opts.depth ?? 0;
|
|
423
|
+
// Converted before any agent runs, so an argument with no JSON form rejects the call up front.
|
|
424
|
+
const args = workflowArgsText(input);
|
|
368
425
|
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
369
426
|
const resumeContext: AgentResumeBaseContext = {
|
|
370
427
|
workflow: await captureWorkflowResumeContext(mod, rc.signal),
|
|
@@ -385,7 +442,8 @@ export async function runWorkflowWithContext(
|
|
|
385
442
|
const childController = new AbortController();
|
|
386
443
|
const unlink = linkAbortSignal(rc.signal, childController);
|
|
387
444
|
try {
|
|
388
|
-
|
|
445
|
+
const childInput = childArgs === undefined ? "" : childArgs;
|
|
446
|
+
return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childInput, {
|
|
389
447
|
abortController: childController,
|
|
390
448
|
submissionLimit: opts.submissionLimit,
|
|
391
449
|
resolveWorkflow: opts.resolveWorkflow,
|
|
@@ -401,6 +459,7 @@ export async function runWorkflowWithContext(
|
|
|
401
459
|
|
|
402
460
|
const api: WorkflowApi = {
|
|
403
461
|
agent,
|
|
462
|
+
classify: (context, classifyOpts) => runClassifier(rc, context, { ...classifyOpts, phase: scope.agentOptions({ phase: classifyOpts?.phase }).phase }),
|
|
404
463
|
workflow,
|
|
405
464
|
parallel: bindParallel({
|
|
406
465
|
signal: rc.signal,
|
|
@@ -412,6 +471,7 @@ export async function runWorkflowWithContext(
|
|
|
412
471
|
log: scope.log,
|
|
413
472
|
progress: scope.event,
|
|
414
473
|
args,
|
|
474
|
+
input,
|
|
415
475
|
cwd: rc.cwd,
|
|
416
476
|
budget: rc.budget,
|
|
417
477
|
signal: rc.signal,
|
|
@@ -2,3 +2,8 @@
|
|
|
2
2
|
export function isMissingPathError(error: unknown): boolean {
|
|
3
3
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
/** Identify a path that already exists, as reported for an exclusive (`wx`) create. */
|
|
7
|
+
export function isExistingPathError(error: unknown): boolean {
|
|
8
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
9
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** pi names MCP tools `mcp__<server>__<tool>`; its MCP extension also registers these resource tools. */
|
|
5
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
6
|
+
const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
|
|
7
|
+
|
|
8
|
+
/** Mirrors pi's own `isMcpToolName`, which the package does not export. */
|
|
9
|
+
export function isMcpToolName(name: string): boolean {
|
|
10
|
+
return name.startsWith(MCP_TOOL_PREFIX) || MCP_RESOURCE_TOOLS.has(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An agent named MCP tools it cannot reach. Raised before prompting so it never runs without them. */
|
|
14
|
+
export class WorkflowHostToolUnavailableError extends Error {
|
|
15
|
+
readonly code = "WORKFLOW_HOST_TOOL_UNAVAILABLE";
|
|
16
|
+
readonly tools: readonly string[];
|
|
17
|
+
|
|
18
|
+
constructor(tools: readonly string[], bridged: boolean) {
|
|
19
|
+
super(
|
|
20
|
+
bridged
|
|
21
|
+
? `Host MCP tools not available: ${tools.join(", ")}`
|
|
22
|
+
: `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${tools.join(", ")}`,
|
|
23
|
+
);
|
|
24
|
+
this.name = "WorkflowHostToolUnavailableError";
|
|
25
|
+
this.tools = [...tools];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which
|
|
31
|
+
* load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the
|
|
32
|
+
* host's tools over its existing connections and through its `tool_call` permission handlers. They act
|
|
33
|
+
* in the host's context, so a local MCP tool sees the main checkout even for an isolated worktree agent.
|
|
34
|
+
*/
|
|
35
|
+
export interface HostToolBridge {
|
|
36
|
+
/** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */
|
|
37
|
+
tools(): readonly ToolInfo[];
|
|
38
|
+
execute(name: string, args: unknown, signal: AbortSignal | undefined): Promise<AgentToolCallOutcome>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `executeTool` exists only while the host's tool call runs, so only a synchronous `workflow` tool run
|
|
43
|
+
* gets a bridge; `/workflow` commands and background runs that outlive the call do not.
|
|
44
|
+
*/
|
|
45
|
+
export function createHostToolBridge(
|
|
46
|
+
registeredTools: () => readonly ToolInfo[],
|
|
47
|
+
ctx: Pick<ExtensionToolContext, "tools" | "executeTool">,
|
|
48
|
+
): HostToolBridge {
|
|
49
|
+
return {
|
|
50
|
+
tools() {
|
|
51
|
+
const callable = new Set(ctx.tools.map((tool) => tool.name));
|
|
52
|
+
return registeredTools().filter((tool) => isMcpToolName(tool.name) && callable.has(tool.name));
|
|
53
|
+
},
|
|
54
|
+
execute: (name, args, signal) => ctx.executeTool(name, args, { signal }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A subagent tool that runs the host tool. It reports no usage: pi already adds nested calls' usage to
|
|
60
|
+
* the host's `workflow` tool result.
|
|
61
|
+
*/
|
|
62
|
+
export function hostToolProxy(tool: ToolInfo, bridge: HostToolBridge): ToolDefinition {
|
|
63
|
+
return defineTool({
|
|
64
|
+
name: tool.name,
|
|
65
|
+
label: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
parameters: tool.parameters,
|
|
68
|
+
promptGuidelines: tool.promptGuidelines,
|
|
69
|
+
namespace: tool.namespace,
|
|
70
|
+
annotations: tool.annotations,
|
|
71
|
+
async execute(_toolCallId, params, signal) {
|
|
72
|
+
const outcome = await bridge.execute(tool.name, params, signal);
|
|
73
|
+
return { content: outcome.result.content, details: undefined, ...(outcome.isError ? { isError: true } : {}) };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -36,10 +36,16 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
|
36
36
|
return {
|
|
37
37
|
meta: parsedMeta.meta,
|
|
38
38
|
default: (api) => executor(api, Type),
|
|
39
|
-
source: { kind: "fingerprint", fingerprint:
|
|
39
|
+
source: { kind: "fingerprint", fingerprint: inlineScriptFingerprint(source) },
|
|
40
|
+
inlineScript: source,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** A compiled script can import nothing, so the hash of its source is its whole identity. */
|
|
45
|
+
export function inlineScriptFingerprint(source: string): string {
|
|
46
|
+
return createHash("sha256").update(source).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
|
|
44
50
|
export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
|
|
45
51
|
try {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, appendFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
5
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
6
6
|
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
7
|
+
import { isExistingPathError } from "./filesystem-error.ts";
|
|
7
8
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
9
|
import {
|
|
9
10
|
isAgentResumeContext,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
|
|
16
17
|
export const WORKFLOW_JOURNAL_KEEP = 50;
|
|
17
18
|
|
|
19
|
+
/** Ignores everything in the runs directory, including this file, so user projects never commit run data. */
|
|
20
|
+
const WORKFLOW_RUNS_GITIGNORE = "*\n";
|
|
21
|
+
|
|
18
22
|
export interface JournalEntryV2 {
|
|
19
23
|
readonly version: 2;
|
|
20
24
|
readonly key: string;
|
|
@@ -56,6 +60,20 @@ export class WorkflowJournalLoadError extends Error {
|
|
|
56
60
|
override readonly name = "WorkflowJournalLoadError";
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Create the directory that holds run files, with a `.gitignore` that ignores all of them: records,
|
|
65
|
+
* journals, and scripts can carry prompts and results, and user projects do not list this directory in
|
|
66
|
+
* their own ignore files. An existing `.gitignore` is never overwritten.
|
|
67
|
+
*/
|
|
68
|
+
export async function ensureWorkflowRunsDir(dir: string): Promise<void> {
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(join(dir, ".gitignore"), WORKFLOW_RUNS_GITIGNORE, { encoding: "utf8", flag: "wx" });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!isExistingPathError(error)) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
export function createWorkflowRunId(): string {
|
|
60
78
|
return randomUUID();
|
|
61
79
|
}
|
|
@@ -161,7 +179,7 @@ export async function createWorkflowJournal(options: {
|
|
|
161
179
|
readonly writePath: string;
|
|
162
180
|
}): Promise<WorkflowJournal> {
|
|
163
181
|
const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
|
|
164
|
-
await
|
|
182
|
+
await ensureWorkflowRunsDir(dirname(options.writePath));
|
|
165
183
|
await appendFile(options.writePath, "", "utf8");
|
|
166
184
|
return createMemoryBackedJournal(priorEntries, options.writePath);
|
|
167
185
|
}
|
|
@@ -201,10 +219,10 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
|
|
|
201
219
|
const entry: JournalEntryV2 = { version: 2, key, result, identity };
|
|
202
220
|
if (writePath) {
|
|
203
221
|
try {
|
|
204
|
-
await
|
|
222
|
+
await ensureWorkflowRunsDir(dirname(writePath));
|
|
205
223
|
await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
206
224
|
} catch (error) {
|
|
207
|
-
|
|
225
|
+
return { ok: false, error: unknownErrorMessage(error) };
|
|
208
226
|
}
|
|
209
227
|
}
|
|
210
228
|
return { ok: true };
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
|
|
8
5
|
import { isRecord } from "./guards.ts";
|
|
9
|
-
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
10
6
|
|
|
11
7
|
export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
|
|
12
8
|
export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
|
|
@@ -20,10 +16,7 @@ export interface WorkflowModelProfileFile {
|
|
|
20
16
|
readonly profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>>;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
export
|
|
24
|
-
readonly user: string;
|
|
25
|
-
readonly project: string;
|
|
26
|
-
}
|
|
19
|
+
export type WorkflowModelProfilePaths = ConfigFilePaths;
|
|
27
20
|
|
|
28
21
|
interface ResolvedWorkflowModelProfileBase {
|
|
29
22
|
readonly name: WorkflowModelProfileName;
|
|
@@ -52,15 +45,10 @@ const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> =
|
|
|
52
45
|
big: "high",
|
|
53
46
|
};
|
|
54
47
|
const CONFIG_FILE_NAME = "workflow-models.json";
|
|
48
|
+
const CONFIG_LABEL = "workflow model profiles";
|
|
55
49
|
|
|
56
|
-
export function workflowModelProfilePaths(
|
|
57
|
-
cwd
|
|
58
|
-
agentDir: string = getAgentDir(),
|
|
59
|
-
): WorkflowModelProfilePaths {
|
|
60
|
-
return {
|
|
61
|
-
user: join(agentDir, CONFIG_FILE_NAME),
|
|
62
|
-
project: join(cwd, ".pi", CONFIG_FILE_NAME),
|
|
63
|
-
};
|
|
50
|
+
export function workflowModelProfilePaths(cwd: string, agentDir?: string): WorkflowModelProfilePaths {
|
|
51
|
+
return configFilePaths(CONFIG_FILE_NAME, cwd, agentDir);
|
|
64
52
|
}
|
|
65
53
|
|
|
66
54
|
export function resolveWorkflowModelProfiles(input: {
|
|
@@ -123,22 +111,8 @@ export function resolveAgentModelProfile(
|
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
export function readWorkflowModelProfileFile(configPath: string): WorkflowModelProfileFile {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
contents = readFileSync(configPath, "utf8");
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (isMissingPathError(error)) return { profiles: {} };
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
let parsed: unknown;
|
|
136
|
-
try {
|
|
137
|
-
parsed = JSON.parse(contents);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
|
|
140
|
-
}
|
|
141
|
-
return parseWorkflowModelProfileFile(parsed, configPath);
|
|
114
|
+
const parsed = readJsonConfigFile(configPath, CONFIG_LABEL);
|
|
115
|
+
return parsed === undefined ? { profiles: {} } : parseWorkflowModelProfileFile(parsed, configPath);
|
|
142
116
|
}
|
|
143
117
|
|
|
144
118
|
export function setWorkflowModelProfile(input: {
|
|
@@ -239,19 +213,7 @@ function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string
|
|
|
239
213
|
}
|
|
240
214
|
|
|
241
215
|
function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
245
|
-
writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
246
|
-
renameSync(temporaryPath, configPath);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
try {
|
|
249
|
-
unlinkSync(temporaryPath);
|
|
250
|
-
} catch {
|
|
251
|
-
// Best effort: preserve the original write error.
|
|
252
|
-
}
|
|
253
|
-
throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
|
|
254
|
-
}
|
|
216
|
+
writeJsonConfigFile(configPath, config, CONFIG_LABEL);
|
|
255
217
|
}
|
|
256
218
|
|
|
257
219
|
function invalidConfig(configPath: string, reason: string): Error {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Parse a model ref: a bare id is Anthropic shorthand, otherwise "provider/id". Malformed refs throw. */
|
|
2
|
+
export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } {
|
|
3
|
+
const normalized = modelRef.trim();
|
|
4
|
+
if (normalized.length === 0) {
|
|
5
|
+
throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
|
|
6
|
+
}
|
|
7
|
+
if (normalized !== modelRef) {
|
|
8
|
+
throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const slash = modelRef.indexOf("/");
|
|
12
|
+
if (slash === -1) return { provider: "anthropic", id: modelRef };
|
|
13
|
+
const provider = modelRef.slice(0, slash);
|
|
14
|
+
const id = modelRef.slice(slash + 1);
|
|
15
|
+
if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
|
|
16
|
+
throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
|
|
17
|
+
}
|
|
18
|
+
return { provider, id };
|
|
19
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const MINIMUM_PI_VERSION = "0.
|
|
1
|
+
const MINIMUM_PI_VERSION = "1.0.0";
|
|
2
2
|
|
|
3
|
-
const MINIMUM_PI_CORE = [
|
|
3
|
+
const MINIMUM_PI_CORE = [1, 0, 0] as const;
|
|
4
4
|
|
|
5
5
|
/** Fail during extension registration instead of failing later in a workflow. */
|
|
6
6
|
export function assertSupportedPiVersion(version: string): void {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
2
2
|
|
|
3
|
-
export type AgentRowStatus = "queued" | "running" | "done" | "failed";
|
|
3
|
+
export type AgentRowStatus = "queued" | "running" | "done" | "failed" | "skipped";
|
|
4
4
|
export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
|
|
5
5
|
|
|
6
6
|
export interface AgentRowSnapshot {
|
|
@@ -69,7 +69,9 @@ export class ProgressTracker {
|
|
|
69
69
|
private readonly lanes = new Map<string, WorkflowLaneItem[]>();
|
|
70
70
|
private readonly laneOverflow = new Map<string, number>();
|
|
71
71
|
private readonly rowsById = new Map<number, AgentRow>();
|
|
72
|
-
|
|
72
|
+
/** How to abort each running row's agent; never part of a snapshot, so it is never persisted. */
|
|
73
|
+
private readonly skipHandlers = new Map<number, () => void>();
|
|
74
|
+
private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
|
73
75
|
private readonly startedAt = Date.now();
|
|
74
76
|
private readonly laneItemLimit = laneItemLimitFromEnv();
|
|
75
77
|
private doneAt: number | undefined;
|
|
@@ -168,12 +170,13 @@ export class ProgressTracker {
|
|
|
168
170
|
return id;
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
agentStart(id: number): void {
|
|
173
|
+
agentStart(id: number, skip?: () => void): void {
|
|
172
174
|
const row = this.rowsById.get(id);
|
|
173
175
|
if (row) {
|
|
174
176
|
this.transitionAgentStatus(row, "running");
|
|
175
177
|
row.startedAt = Date.now();
|
|
176
178
|
row.error = undefined;
|
|
179
|
+
if (skip) this.skipHandlers.set(id, skip);
|
|
177
180
|
}
|
|
178
181
|
this.publish();
|
|
179
182
|
}
|
|
@@ -188,8 +191,9 @@ export class ProgressTracker {
|
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
agentDone(id: number): void {
|
|
194
|
+
this.skipHandlers.delete(id);
|
|
191
195
|
const row = this.rowsById.get(id);
|
|
192
|
-
if (row && row.status !== "failed") {
|
|
196
|
+
if (row && row.status !== "failed" && row.status !== "skipped") {
|
|
193
197
|
this.transitionAgentStatus(row, "done");
|
|
194
198
|
row.doneAt = Date.now();
|
|
195
199
|
}
|
|
@@ -197,6 +201,7 @@ export class ProgressTracker {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
agentFailed(id: number, error: unknown): void {
|
|
204
|
+
this.skipHandlers.delete(id);
|
|
200
205
|
const row = this.rowsById.get(id);
|
|
201
206
|
if (row) {
|
|
202
207
|
this.transitionAgentStatus(row, "failed");
|
|
@@ -206,6 +211,28 @@ export class ProgressTracker {
|
|
|
206
211
|
this.publish();
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
agentSkipped(id: number): void {
|
|
215
|
+
this.skipHandlers.delete(id);
|
|
216
|
+
const row = this.rowsById.get(id);
|
|
217
|
+
if (row) {
|
|
218
|
+
this.transitionAgentStatus(row, "skipped");
|
|
219
|
+
row.doneAt = Date.now();
|
|
220
|
+
}
|
|
221
|
+
this.publish();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Abort one running agent at the user's request (the inspector's skip key); false when the row is
|
|
226
|
+
* no longer running. The row turns `skipped` once the agent's session has wound down.
|
|
227
|
+
*/
|
|
228
|
+
skipAgent(id: number): boolean {
|
|
229
|
+
const skip = this.skipHandlers.get(id);
|
|
230
|
+
if (!skip || this.rowsById.get(id)?.status !== "running") return false;
|
|
231
|
+
this.skipHandlers.delete(id);
|
|
232
|
+
skip();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
updateUsage(snapshot: WorkflowUsageSnapshot): void {
|
|
210
237
|
this.usageSnapshot = snapshot;
|
|
211
238
|
this.publish();
|
|
@@ -238,7 +265,8 @@ export class ProgressTracker {
|
|
|
238
265
|
running: this.agentCounts.running,
|
|
239
266
|
done: this.agentCounts.done,
|
|
240
267
|
failed: this.agentCounts.failed,
|
|
241
|
-
|
|
268
|
+
skipped: this.agentCounts.skipped,
|
|
269
|
+
total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed + this.agentCounts.skipped,
|
|
242
270
|
};
|
|
243
271
|
}
|
|
244
272
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BeforeAgentStartEventResult, NormalizedBuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type PromptSectionOptions = Pick<NormalizedBuildSystemPromptOptions, "sections" | "forceSystemPrompt">;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finish adding the `name` section from a `before_agent_start` handler. Normally the section in
|
|
7
|
+
* `options.sections` is enough and pi appends it as a patch. An earlier handler that forced the whole
|
|
8
|
+
* prompt makes pi render no sections, so the section is appended to that text instead; pi hands the
|
|
9
|
+
* returned prompt to later handlers as their `forceSystemPrompt`, so several sections chain.
|
|
10
|
+
*/
|
|
11
|
+
export function appendSectionToForcedPrompt(options: PromptSectionOptions, name: string): BeforeAgentStartEventResult | undefined {
|
|
12
|
+
const text = options.sections[name];
|
|
13
|
+
if (options.forceSystemPrompt === undefined || text === undefined) return undefined;
|
|
14
|
+
return { systemPrompt: `${options.forceSystemPrompt}\n\n<${name}>\n${text}\n</${name}>` };
|
|
15
|
+
}
|