pi-workflow-engine 1.1.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 +199 -78
- 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 +114 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -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 +44 -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 +17 -6
- 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 +11 -3
- package/USAGE.md +121 -22
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ToolDefinition,
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { prepareAgentSkillResources } from "./agent-skills.ts";
|
|
15
|
+
import { createCodemodeSlotExtension, runToolCallsSequentially } from "./codemode-slots.ts";
|
|
15
16
|
import type {
|
|
16
17
|
AgentExecutionOptions,
|
|
17
18
|
AgentProgress,
|
|
@@ -35,6 +36,16 @@ export const FINAL_TOOL = "final_answer";
|
|
|
35
36
|
/** pi's codemode tool; SDK sessions only have it when their resource loader registers the extension. */
|
|
36
37
|
export const CODEMODE_TOOL = "codemode";
|
|
37
38
|
|
|
39
|
+
/** pi's stock prompt frames a chat with a person; a subagent's last message is program data instead. */
|
|
40
|
+
const RETURN_VALUE_FRAMING =
|
|
41
|
+
"Your final message is returned verbatim to the workflow program that started you, as data. " +
|
|
42
|
+
"It is not shown to a person as a chat reply. Make it exactly the output your task asks for, in the format it asks for, " +
|
|
43
|
+
"with no greeting, preamble, narration of your process, or offer of further help.";
|
|
44
|
+
/** A schema agent returns its final_answer arguments, not its last message. */
|
|
45
|
+
const STRUCTURED_RETURN_VALUE_FRAMING =
|
|
46
|
+
`Your ${FINAL_TOOL} arguments are returned to the workflow program that started you, as data. ` +
|
|
47
|
+
"They are not shown to a person as a chat reply. Fill each field with exactly the content it asks for, " +
|
|
48
|
+
"with no greeting, preamble, narration of your process, or offer of further help.";
|
|
38
49
|
const SCHEMA_REPROMPT =
|
|
39
50
|
`You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
|
|
40
51
|
`Call ${FINAL_TOOL} now with your final answer as its arguments. Do not reply with plain text.`;
|
|
@@ -234,6 +245,7 @@ async function prepareAgentSessionResources(input: {
|
|
|
234
245
|
sessionManager: SessionManager.inMemory(cwd),
|
|
235
246
|
});
|
|
236
247
|
const createSession = rc.createSession;
|
|
248
|
+
// Injected test sessions build no resource loader, so they get neither skills nor the return-value framing.
|
|
237
249
|
if (createSession) {
|
|
238
250
|
return {
|
|
239
251
|
selectedSkills: [],
|
|
@@ -251,9 +263,15 @@ async function prepareAgentSessionResources(input: {
|
|
|
251
263
|
log: (message: string) => rc.progress.log(`${label}: ${message}`),
|
|
252
264
|
};
|
|
253
265
|
const preparedSkills = prepareAgentSkillResources(skillOptions);
|
|
266
|
+
const codemode = opts.tools?.includes(CODEMODE_TOOL) ?? false;
|
|
254
267
|
const services = await createAgentSessionServices({
|
|
255
268
|
cwd,
|
|
256
|
-
resourceLoaderOptions: {
|
|
269
|
+
resourceLoaderOptions: {
|
|
270
|
+
...preparedSkills.resourceLoaderOptions,
|
|
271
|
+
...(codemode ? codemodeExtensions() : {}),
|
|
272
|
+
// An override, not `appendSystemPrompt`: passing that replaces pi's discovery of the user's APPEND_SYSTEM.md.
|
|
273
|
+
appendSystemPromptOverride: (base) => [...base, opts.schema ? STRUCTURED_RETURN_VALUE_FRAMING : RETURN_VALUE_FRAMING],
|
|
274
|
+
},
|
|
257
275
|
});
|
|
258
276
|
await synchronizeWorkflowModelRuntime({
|
|
259
277
|
host: rc.modelRegistry,
|
|
@@ -272,11 +290,14 @@ async function prepareAgentSessionResources(input: {
|
|
|
272
290
|
const selectedSkills = preparedSkills.resolve(services.resourceLoader);
|
|
273
291
|
return {
|
|
274
292
|
selectedSkills,
|
|
275
|
-
createSession: (sessionOptions) =>
|
|
276
|
-
createAgentSessionFromServices({
|
|
293
|
+
createSession: async (sessionOptions) => {
|
|
294
|
+
const created = await createAgentSessionFromServices({
|
|
277
295
|
services,
|
|
278
296
|
...commonSessionOptions(sessionOptions),
|
|
279
|
-
})
|
|
297
|
+
});
|
|
298
|
+
if (codemode) runToolCallsSequentially(created.session);
|
|
299
|
+
return created;
|
|
300
|
+
},
|
|
280
301
|
};
|
|
281
302
|
}
|
|
282
303
|
|
|
@@ -284,10 +305,16 @@ async function prepareAgentSessionResources(input: {
|
|
|
284
305
|
* SDK sessions load none of pi's built-in extensions, so codemode is registered only for agents whose
|
|
285
306
|
* allowlist names it. Scripts can call just the agent's other allowed tools. Mode "on" keeps those
|
|
286
307
|
* tools declared whatever the user's `codemode.mode`, so the author's allowlist stays what the model sees.
|
|
308
|
+
* Its calls queue for the process-wide codemode slots instead of failing at pi's worker limit, so the
|
|
309
|
+
* session runs its tool calls one at a time.
|
|
287
310
|
*/
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
|
|
311
|
+
function codemodeExtensions(): Pick<SessionResourceLoaderOptions, "extensionFactories"> {
|
|
312
|
+
return {
|
|
313
|
+
extensionFactories: [
|
|
314
|
+
{ name: CODEMODE_TOOL, factory: createCodemodeExtension({ mode: "on" }), replaceable: true },
|
|
315
|
+
{ name: "workflow-codemode-slots", factory: createCodemodeSlotExtension(CODEMODE_TOOL), hidden: true },
|
|
316
|
+
],
|
|
317
|
+
};
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
type SessionResourceLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { BackgroundWorkflowCoordinator, backgroundOrigin } from "./background-workflows.ts";
|
|
3
|
-
import { createWorkflowRunId } from "./journal.ts";
|
|
4
|
+
import { createWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
5
|
import type { ResolvedWorkflowRunOptions } from "./options.ts";
|
|
5
6
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
import { workflowRunScriptFile } from "./workflow-run-record.ts";
|
|
6
8
|
|
|
7
9
|
export interface BackgroundWorkflowToolResult {
|
|
8
10
|
readonly content: Array<{ readonly type: "text"; readonly text: string }>;
|
|
@@ -15,6 +17,8 @@ export interface BackgroundWorkflowToolStartInput {
|
|
|
15
17
|
readonly ctx: ExtensionContext;
|
|
16
18
|
readonly name: string;
|
|
17
19
|
readonly options: ResolvedWorkflowRunOptions;
|
|
20
|
+
/** Deliver the completion with a new agent turn instead of silently. */
|
|
21
|
+
readonly wakeOnComplete?: boolean;
|
|
18
22
|
readonly execute: (ctx: ExtensionContext, options: ResolvedWorkflowRunOptions) => Promise<void>;
|
|
19
23
|
}
|
|
20
24
|
|
|
@@ -39,8 +43,9 @@ export async function startBackgroundWorkflowTool(
|
|
|
39
43
|
inspect: false,
|
|
40
44
|
resultViewer: "skip",
|
|
41
45
|
runId,
|
|
42
|
-
background: backgroundOrigin(input.ctx),
|
|
46
|
+
background: backgroundOrigin(input.ctx, Date.now(), input.wakeOnComplete),
|
|
43
47
|
};
|
|
48
|
+
let scriptPersisted = false;
|
|
44
49
|
try {
|
|
45
50
|
await input.coordinator.start({
|
|
46
51
|
ctx: input.ctx,
|
|
@@ -53,6 +58,7 @@ export async function startBackgroundWorkflowTool(
|
|
|
53
58
|
...backgroundOptions,
|
|
54
59
|
signal,
|
|
55
60
|
onRunMetadata(metadata) {
|
|
61
|
+
scriptPersisted = metadata.scriptPath !== undefined;
|
|
56
62
|
onStarted();
|
|
57
63
|
return backgroundOptions.onRunMetadata?.(metadata);
|
|
58
64
|
},
|
|
@@ -71,7 +77,7 @@ export async function startBackgroundWorkflowTool(
|
|
|
71
77
|
return {
|
|
72
78
|
content: [{
|
|
73
79
|
type: "text",
|
|
74
|
-
text: `Background workflow "${input.name}" started.\nRun ID: ${runId}\nDurable record: .pi/.workflow-runs/${runId}.run.json`,
|
|
80
|
+
text: `Background workflow "${input.name}" started.\nRun ID: ${runId}\nDurable record: .pi/.workflow-runs/${runId}.run.json${scriptPersisted ? `\nScript: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}` : ""}`,
|
|
75
81
|
}],
|
|
76
82
|
details: { background: true, state: "running", name: input.name, runId },
|
|
77
83
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
|
|
3
4
|
import { isRecord } from "./guards.ts";
|
|
5
|
+
import { WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
6
|
import type { WorkflowBackgroundOrigin } from "./types.ts";
|
|
5
7
|
import {
|
|
6
8
|
stopWorkflowRunRecord,
|
|
@@ -9,9 +11,11 @@ import {
|
|
|
9
11
|
type WorkflowRunState,
|
|
10
12
|
} from "./workflow-run-record.ts";
|
|
11
13
|
import { updateWorkflowRunDelivery, type WorkflowRunDelivery } from "./workflow-run-background.ts";
|
|
12
|
-
import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
14
|
+
import { ProjectWorkflowRunStore, WORKFLOW_RUN_RECORD_SUFFIX, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
13
15
|
import { truncateText } from "./text.ts";
|
|
16
|
+
import { formatWorkflowUsageLine } from "./usage.ts";
|
|
14
17
|
import { workflowResultSummary } from "./workflow-execution.ts";
|
|
18
|
+
import { formatResultForContext, formatWorkflowRunFileLines } from "./workflow-result-context.ts";
|
|
15
19
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
16
20
|
|
|
17
21
|
const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
|
|
@@ -294,11 +298,12 @@ export class BackgroundWorkflowCoordinator {
|
|
|
294
298
|
this.pi.sendMessage(
|
|
295
299
|
{
|
|
296
300
|
customType: BACKGROUND_DELIVERY_CUSTOM_TYPE,
|
|
297
|
-
content: formatBackgroundDelivery(details),
|
|
301
|
+
content: formatBackgroundDelivery(record, details),
|
|
298
302
|
display: true,
|
|
299
303
|
details,
|
|
300
304
|
},
|
|
301
|
-
|
|
305
|
+
// Delivery waits for an idle session, and pi steers a message sent while a run starts into that run.
|
|
306
|
+
{ triggerTurn: wakesAgent(record) },
|
|
302
307
|
);
|
|
303
308
|
}
|
|
304
309
|
await markDelivery(store, runId, { state: "delivered", deliveredAt: Date.now() });
|
|
@@ -306,8 +311,13 @@ export class BackgroundWorkflowCoordinator {
|
|
|
306
311
|
}
|
|
307
312
|
}
|
|
308
313
|
|
|
309
|
-
export function backgroundOrigin(
|
|
310
|
-
|
|
314
|
+
export function backgroundOrigin(
|
|
315
|
+
ctx: Pick<ExtensionContext, "sessionManager">,
|
|
316
|
+
requestedAt = Date.now(),
|
|
317
|
+
wakeOnComplete = false,
|
|
318
|
+
): WorkflowBackgroundOrigin {
|
|
319
|
+
const origin = { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
|
|
320
|
+
return wakeOnComplete ? { ...origin, wakeOnComplete } : origin;
|
|
311
321
|
}
|
|
312
322
|
|
|
313
323
|
export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
|
|
@@ -336,17 +346,41 @@ function backgroundSummary(record: WorkflowRunRecord): string {
|
|
|
336
346
|
return summary === undefined ? "Workflow completed. Open run history for the retained result." : truncateText(summary, SUMMARY_LIMIT);
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
|
|
349
|
+
/**
|
|
350
|
+
* The host model only sees this text, never `details`. A woken agent acts on it at once, so it gets the
|
|
351
|
+
* result as the synchronous tool path renders it; a silent delivery keeps the summary. Both name the run's
|
|
352
|
+
* files so a later turn can read the retained result.
|
|
353
|
+
*/
|
|
354
|
+
function formatBackgroundDelivery(record: WorkflowRunRecord, details: BackgroundWorkflowResultDetails): string {
|
|
355
|
+
const woken = record.background?.origin.wakeOnComplete === true;
|
|
356
|
+
const usage = woken && record.state === "completed" ? formatWorkflowUsageLine(record.usage) : undefined;
|
|
340
357
|
return [
|
|
341
358
|
`## Background workflow: ${details.name}`,
|
|
342
359
|
"",
|
|
343
|
-
|
|
360
|
+
// A resume, including an automatic one, has a new run id; name the run the agent started.
|
|
361
|
+
details.resumedFromRunId ? `Run ID: ${details.runId} (resumed from ${details.resumedFromRunId})` : `Run ID: ${details.runId}`,
|
|
344
362
|
`State: ${details.status}`,
|
|
345
363
|
"",
|
|
346
|
-
details.summary,
|
|
364
|
+
woken ? wokenDeliveryBody(record, details.summary) : details.summary,
|
|
365
|
+
"",
|
|
366
|
+
...backgroundRunFileLines(record),
|
|
367
|
+
...(usage === undefined ? [] : [usage]),
|
|
347
368
|
].join("\n");
|
|
348
369
|
}
|
|
349
370
|
|
|
371
|
+
function wokenDeliveryBody(record: WorkflowRunRecord, summary: string): string {
|
|
372
|
+
if (record.state !== "completed") return `Workflow ${record.state}: ${record.message}`;
|
|
373
|
+
return record.result.kind === "value" ? formatResultForContext(record.result.value) : summary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The retained record, script and journal, relative to the workflow cwd like the synchronous result's lines. */
|
|
377
|
+
function backgroundRunFileLines(record: WorkflowRunRecord): string[] {
|
|
378
|
+
return [
|
|
379
|
+
`Record: ${join(WORKFLOW_RUNS_DIR, `${record.runId}${WORKFLOW_RUN_RECORD_SUFFIX}`)}`,
|
|
380
|
+
...formatWorkflowRunFileLines(record.runId, record.scriptFile !== undefined),
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
|
|
350
384
|
function sessionHasBackgroundDelivery(ctx: ExtensionContext, runId: string): boolean {
|
|
351
385
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
352
386
|
if (entry.type !== "message" || entry.message.role !== "custom") continue;
|
|
@@ -361,6 +395,15 @@ function isPendingBackgroundOutcome(record: WorkflowRunRecord): record is Workfl
|
|
|
361
395
|
return record.background?.delivery.state === "pending" && isDeliverableState(record.state);
|
|
362
396
|
}
|
|
363
397
|
|
|
398
|
+
/**
|
|
399
|
+
* A run started under Dynamax wakes the agent to act on its result; others arrive silently. So does a run
|
|
400
|
+
* the user stopped, or one paused with a resume already scheduled, whose successor wakes it instead.
|
|
401
|
+
*/
|
|
402
|
+
function wakesAgent(record: WorkflowRunRecord): boolean {
|
|
403
|
+
if (record.background?.origin.wakeOnComplete !== true || record.state === "stopped") return false;
|
|
404
|
+
return !(record.state === "paused" && record.pause?.autoResume === true);
|
|
405
|
+
}
|
|
406
|
+
|
|
364
407
|
function isDeliverableState(state: WorkflowRunState): state is "completed" | "failed" | "stopped" | "paused" {
|
|
365
408
|
return state === "completed" || state === "failed" || state === "stopped" || state === "paused";
|
|
366
409
|
}
|
|
@@ -13,6 +13,20 @@ export class WorkflowPauseError extends WorkflowAbortError {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Abort reason for one agent the user skipped from the inspector. It deliberately does not extend
|
|
18
|
+
* WorkflowAbortError: the run carries on, so `parallel()`/`pipeline()` turn the skipped branch into a
|
|
19
|
+
* `null` slot while a direct `await agent()` sees this error.
|
|
20
|
+
*/
|
|
21
|
+
export class WorkflowAgentSkippedError extends Error {
|
|
22
|
+
override readonly name = "WorkflowAgentSkippedError";
|
|
23
|
+
readonly code = "WORKFLOW_AGENT_SKIPPED";
|
|
24
|
+
|
|
25
|
+
constructor(readonly label: string) {
|
|
26
|
+
super(`Workflow agent "${label}" was skipped by the user.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function abortReason(signal: AbortSignal | undefined): Error {
|
|
17
31
|
const reason = signal?.reason;
|
|
18
32
|
if (safeInstanceOf(reason, Error)) return reason;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentSession, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Semaphore } from "./concurrency.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* pi runs at most this many codemode scripts per process, and rejects one more with "Codemode worker
|
|
6
|
+
* limit reached" instead of waiting (pi 1.3 `extensions/codemode/execute.js`). Every subagent shares
|
|
7
|
+
* that limit with the others and with the host, so a fan-out of codemode agents would fail calls.
|
|
8
|
+
*/
|
|
9
|
+
export const CODEMODE_WORKER_SLOTS = 2;
|
|
10
|
+
|
|
11
|
+
const processSlots = new Semaphore(CODEMODE_WORKER_SLOTS);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Queue a subagent's top-level calls to `toolName` for one of the process-wide codemode slots, so a
|
|
15
|
+
* call waits for a running script instead of failing. pi frees its own slot only once the worker has
|
|
16
|
+
* exited, which can lag the result on Bun, so a call can still meet pi's limit now and then. The host's
|
|
17
|
+
* own codemode calls are not queued. A session using it must run its tool calls one at a time
|
|
18
|
+
* ({@link runToolCallsSequentially}).
|
|
19
|
+
*/
|
|
20
|
+
export function createCodemodeSlotExtension(toolName: string, slots: Semaphore = processSlots): ExtensionFactory {
|
|
21
|
+
return (pi) => {
|
|
22
|
+
const held = new Map<string, () => void>();
|
|
23
|
+
const release = (toolCallId: string): void => {
|
|
24
|
+
held.get(toolCallId)?.();
|
|
25
|
+
held.delete(toolCallId);
|
|
26
|
+
};
|
|
27
|
+
// A call another handler blocks after this one took a slot never runs, so the turn's end frees it.
|
|
28
|
+
const releaseAll = (): void => {
|
|
29
|
+
for (const toolCallId of [...held.keys()]) release(toolCallId);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
33
|
+
// A script's nested calls run inside the slot its own call holds.
|
|
34
|
+
if (event.toolName !== toolName || event.parentToolCallId !== undefined) return undefined;
|
|
35
|
+
// Rejects while queued once the agent is aborted; pi then blocks the call.
|
|
36
|
+
await new Promise<void>((acquired, failed) => {
|
|
37
|
+
slots
|
|
38
|
+
.run(
|
|
39
|
+
() =>
|
|
40
|
+
new Promise<void>((done) => {
|
|
41
|
+
held.set(event.toolCallId, done);
|
|
42
|
+
acquired();
|
|
43
|
+
}),
|
|
44
|
+
{ signal: ctx.signal },
|
|
45
|
+
)
|
|
46
|
+
.catch(failed);
|
|
47
|
+
});
|
|
48
|
+
return undefined;
|
|
49
|
+
});
|
|
50
|
+
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
51
|
+
pi.on("tool_result", (event) => {
|
|
52
|
+
release(event.toolCallId);
|
|
53
|
+
return undefined;
|
|
54
|
+
});
|
|
55
|
+
pi.on("turn_end", () => {
|
|
56
|
+
releaseAll();
|
|
57
|
+
return undefined;
|
|
58
|
+
});
|
|
59
|
+
pi.on("session_shutdown", releaseAll);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run a codemode session's tool calls one at a time. pi's default "parallel" execution runs every call's
|
|
65
|
+
* `tool_call` hook before it executes any (pi-agent-core `executeToolCallsParallel`), so a message with
|
|
66
|
+
* more codemode calls than free slots would leave the calls holding slots waiting on the one queued
|
|
67
|
+
* behind them until the agent timed out, and starve every other codemode agent. In order, each call
|
|
68
|
+
* has freed its slot before the next call asks for one. pi then also runs the calls a script makes one
|
|
69
|
+
* at a time (`NestedToolCallRunner`).
|
|
70
|
+
*/
|
|
71
|
+
export function runToolCallsSequentially(session: Pick<AgentSession, "agent">): void {
|
|
72
|
+
session.agent.toolExecution = "sequential";
|
|
73
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
|
|
8
|
+
/** A JSON config that lives once per user and once per project, the project file overriding the user one. */
|
|
9
|
+
export interface ConfigFilePaths {
|
|
10
|
+
readonly user: string;
|
|
11
|
+
readonly project: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configFilePaths(fileName: string, cwd: string, agentDir: string = getAgentDir()): ConfigFilePaths {
|
|
15
|
+
return {
|
|
16
|
+
user: join(agentDir, fileName),
|
|
17
|
+
project: join(cwd, ".pi", fileName),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The parsed JSON at `configPath`, or undefined when there is no file. `label` names the config in errors. */
|
|
22
|
+
export function readJsonConfigFile(configPath: string, label: string): unknown {
|
|
23
|
+
let contents: string;
|
|
24
|
+
try {
|
|
25
|
+
contents = readFileSync(configPath, "utf8");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (isMissingPathError(error)) return undefined;
|
|
28
|
+
throw new Error(`Could not read ${label} at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(contents);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`Invalid JSON in ${label} at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Replace `configPath` atomically, so a concurrent reader sees the old file or the new one. */
|
|
38
|
+
export function writeJsonConfigFile(configPath: string, value: unknown, label: string): void {
|
|
39
|
+
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
42
|
+
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
43
|
+
renameSync(temporaryPath, configPath);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temporaryPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// Best effort: preserve the original write error.
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Could not write ${label} at ${configPath}: ${unknownErrorMessage(error)}.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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> {
|