pi-dynamic-workflow 0.1.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/LICENSE +21 -0
- package/README.md +346 -0
- package/package.json +55 -0
- package/src/agents.ts +97 -0
- package/src/guide.ts +115 -0
- package/src/index.ts +122 -0
- package/src/journal.ts +182 -0
- package/src/registry.ts +101 -0
- package/src/render.ts +183 -0
- package/src/sandbox.ts +128 -0
- package/src/scheduler.ts +80 -0
- package/src/structured.ts +73 -0
- package/src/subagent.ts +304 -0
- package/src/tool.ts +558 -0
- package/src/types.ts +120 -0
package/src/tool.ts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/** The `workflow` custom tool: executes an LLM-authored orchestration script. */
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_BYTES,
|
|
8
|
+
DEFAULT_MAX_LINES,
|
|
9
|
+
defineTool,
|
|
10
|
+
type ExtensionAPI,
|
|
11
|
+
formatSize,
|
|
12
|
+
truncateHead,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { type AgentTypeConfig, discoverAgentTypes } from "./agents.ts";
|
|
16
|
+
import { WORKFLOW_TOOL_DESCRIPTION } from "./guide.ts";
|
|
17
|
+
import { agentCallHash, buildResumeCache, type JournalEntry, JournalWriter, loadJournal, newRunId } from "./journal.ts";
|
|
18
|
+
import { loadWorkflowSource } from "./registry.ts";
|
|
19
|
+
import { renderWorkflowCall, renderWorkflowResult } from "./render.ts";
|
|
20
|
+
import { runWorkflowScript, WorkflowAbortError } from "./sandbox.ts";
|
|
21
|
+
import { defaultConcurrency, parallel, pipeline, Semaphore } from "./scheduler.ts";
|
|
22
|
+
import { runSubagent } from "./subagent.ts";
|
|
23
|
+
import type { AgentOptions, AgentRecord, ScriptHooks, WorkflowBudget, WorkflowDetails } from "./types.ts";
|
|
24
|
+
import { addUsage, emptyUsage } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** Runaway backstop: max agent() calls per run, regardless of tool params. */
|
|
27
|
+
const MAX_AGENTS_PER_RUN = 200;
|
|
28
|
+
|
|
29
|
+
const WorkflowParams = Type.Object({
|
|
30
|
+
name: Type.String({ description: "Short kebab-case workflow name" }),
|
|
31
|
+
description: Type.String({ description: "One-sentence description of what the workflow does" }),
|
|
32
|
+
phases: Type.Optional(Type.Array(Type.String(), { description: "Ordered phase titles documenting the plan" })),
|
|
33
|
+
script: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
description:
|
|
36
|
+
"Plain async JavaScript body executed in the workflow sandbox (top-level await/return allowed). " +
|
|
37
|
+
"In scope: agent(), parallel(), pipeline(), phase(), log(), args, budget, workflow(). " +
|
|
38
|
+
"Provide exactly one of script, workflowName, or scriptPath.",
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
workflowName: Type.Optional(
|
|
42
|
+
Type.String({
|
|
43
|
+
description: "Run a saved workflow by name (from ~/.pi/agent/workflows/ or <project>/.pi/workflows/)",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
scriptPath: Type.Optional(
|
|
47
|
+
Type.String({ description: "Run a workflow script from a .js file path (relative to the session cwd)" }),
|
|
48
|
+
),
|
|
49
|
+
args: Type.Optional(Type.Any({ description: "JSON value exposed to the script as `args`" })),
|
|
50
|
+
maxConcurrency: Type.Optional(
|
|
51
|
+
Type.Number({ description: "Cap on concurrent subagents (default max(2, min(8, cpus-2)))" }),
|
|
52
|
+
),
|
|
53
|
+
maxAgents: Type.Optional(
|
|
54
|
+
Type.Number({ description: `Cap on total agent() calls this run (default and hard max ${MAX_AGENTS_PER_RUN})` }),
|
|
55
|
+
),
|
|
56
|
+
maxCost: Type.Optional(
|
|
57
|
+
Type.Number({ description: "Budget cap in USD; agent() throws once total subagent cost reaches it" }),
|
|
58
|
+
),
|
|
59
|
+
maxTokens: Type.Optional(
|
|
60
|
+
Type.Number({ description: "Budget cap in tokens (input+output); agent() throws once total reaches it" }),
|
|
61
|
+
),
|
|
62
|
+
resumeFromRunId: Type.Optional(
|
|
63
|
+
Type.String({
|
|
64
|
+
description:
|
|
65
|
+
"Resume from a prior run's journal: agent() calls whose prompt+options match a recorded call " +
|
|
66
|
+
"return the cached result instantly; new or changed calls run live",
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
background: Type.Optional(
|
|
70
|
+
Type.Boolean({
|
|
71
|
+
description:
|
|
72
|
+
"Return immediately and run in the background; a workflow-complete message arrives when the run " +
|
|
73
|
+
"finishes. The run dies with the pi process but is resumable via its journal (resumeFromRunId)",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Message type injected into the session when a background run finishes. */
|
|
79
|
+
export const WORKFLOW_COMPLETE_TYPE = "workflow-complete";
|
|
80
|
+
|
|
81
|
+
/** Host capabilities the tool needs beyond the per-call ExtensionContext. */
|
|
82
|
+
export type WorkflowToolHost = Partial<Pick<ExtensionAPI, "sendMessage" | "appendEntry">>;
|
|
83
|
+
|
|
84
|
+
/** Thrown by agent() once the run's maxCost/maxTokens budget is spent. Scripts
|
|
85
|
+
* can catch it by name (`e.name === "BudgetExceededError"`) to return partial results. */
|
|
86
|
+
export class BudgetExceededError extends Error {
|
|
87
|
+
constructor(message: string) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "BudgetExceededError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Background runs in flight, keyed by runId, so /workflow-stop and
|
|
94
|
+
// session_shutdown can abort them. Module-level (not per createWorkflowTool
|
|
95
|
+
// instance) so the /workflow-stop command reaches runs regardless of which
|
|
96
|
+
// tool instance started them; one extension instance per process in practice.
|
|
97
|
+
const activeBackgroundRuns = new Map<string, AbortController>();
|
|
98
|
+
|
|
99
|
+
/** Abort a background run. Returns false when no such run is active. */
|
|
100
|
+
export function stopWorkflowRun(runId: string): boolean {
|
|
101
|
+
const controller = activeBackgroundRuns.get(runId);
|
|
102
|
+
if (!controller) return false;
|
|
103
|
+
// Deregister immediately so a stopping run is no longer listed as active.
|
|
104
|
+
activeBackgroundRuns.delete(runId);
|
|
105
|
+
controller.abort();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listActiveWorkflowRuns(): string[] {
|
|
110
|
+
return [...activeBackgroundRuns.keys()];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const createWorkflowTool = (host: WorkflowToolHost = {}) =>
|
|
114
|
+
defineTool<typeof WorkflowParams, WorkflowDetails>({
|
|
115
|
+
name: "workflow",
|
|
116
|
+
label: "Workflow",
|
|
117
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
118
|
+
parameters: WorkflowParams,
|
|
119
|
+
|
|
120
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
121
|
+
const maxCost = params.maxCost ?? null;
|
|
122
|
+
const maxTokens = params.maxTokens ?? null;
|
|
123
|
+
const runId = newRunId(params.name);
|
|
124
|
+
const details: WorkflowDetails = {
|
|
125
|
+
name: params.name,
|
|
126
|
+
description: params.description,
|
|
127
|
+
...(params.phases ? { phases: params.phases } : {}),
|
|
128
|
+
agents: [],
|
|
129
|
+
logs: [],
|
|
130
|
+
startedAt: Date.now(),
|
|
131
|
+
runId,
|
|
132
|
+
...(params.resumeFromRunId ? { resumedFrom: params.resumeFromRunId } : {}),
|
|
133
|
+
...(maxCost !== null || maxTokens !== null
|
|
134
|
+
? {
|
|
135
|
+
budget: {
|
|
136
|
+
...(maxCost !== null ? { maxCost } : {}),
|
|
137
|
+
...(maxTokens !== null ? { maxTokens } : {}),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const snapshot = (): WorkflowDetails => ({
|
|
144
|
+
...details,
|
|
145
|
+
agents: details.agents.map((a) => ({ ...a })),
|
|
146
|
+
logs: [...details.logs],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Set once execute() delivers its result; dangling fire-and-forget agent()
|
|
150
|
+
// continuations settle after that and must not call onUpdate for a
|
|
151
|
+
// completed tool call.
|
|
152
|
+
let finished = false;
|
|
153
|
+
|
|
154
|
+
const emit = () => {
|
|
155
|
+
if (finished) return;
|
|
156
|
+
onUpdate?.({
|
|
157
|
+
content: [{ type: "text", text: progressLine() }],
|
|
158
|
+
details: snapshot(),
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const progressLine = () => {
|
|
163
|
+
const done = details.agents.filter((a) => a.status === "done" || a.status === "cached").length;
|
|
164
|
+
const running = details.agents.filter((a) => a.status === "running").length;
|
|
165
|
+
const queued = details.agents.filter((a) => a.status === "queued").length;
|
|
166
|
+
let line = `Workflow "${details.name}": ${done}/${details.agents.length} agents done, ${running} running`;
|
|
167
|
+
if (queued > 0) line += `, ${queued} queued`;
|
|
168
|
+
return line;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Internal controller so a script error or parent abort kills in-flight subprocesses.
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const onParentAbort = () => controller.abort();
|
|
174
|
+
// Background runs outlive the tool call on purpose: they ignore the
|
|
175
|
+
// turn's abort signal and are stopped via stopWorkflowRun instead.
|
|
176
|
+
const parentSignal = params.background ? undefined : signal;
|
|
177
|
+
if (parentSignal) {
|
|
178
|
+
if (parentSignal.aborted) controller.abort();
|
|
179
|
+
else parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const semaphore = new Semaphore(Math.max(1, params.maxConcurrency ?? defaultConcurrency()));
|
|
183
|
+
let currentPhase: string | undefined;
|
|
184
|
+
let agentCounter = 0;
|
|
185
|
+
const maxAgents = Math.max(1, Math.min(params.maxAgents ?? MAX_AGENTS_PER_RUN, MAX_AGENTS_PER_RUN));
|
|
186
|
+
|
|
187
|
+
// Budget counts only live spend this run; cached (replayed) results are free.
|
|
188
|
+
const spentUsage = () => {
|
|
189
|
+
const total = emptyUsage();
|
|
190
|
+
for (const a of details.agents) {
|
|
191
|
+
if (a.status !== "cached") addUsage(total, a.usage);
|
|
192
|
+
}
|
|
193
|
+
return total;
|
|
194
|
+
};
|
|
195
|
+
const budget: WorkflowBudget = {
|
|
196
|
+
maxCost,
|
|
197
|
+
maxTokens,
|
|
198
|
+
spentCost: () => spentUsage().cost,
|
|
199
|
+
spentTokens: () => {
|
|
200
|
+
const u = spentUsage();
|
|
201
|
+
return u.input + u.output;
|
|
202
|
+
},
|
|
203
|
+
remainingCost: () => (maxCost === null ? Infinity : Math.max(0, maxCost - budget.spentCost())),
|
|
204
|
+
remainingTokens: () => (maxTokens === null ? Infinity : Math.max(0, maxTokens - budget.spentTokens())),
|
|
205
|
+
exceeded: () =>
|
|
206
|
+
(maxCost !== null && budget.spentCost() >= maxCost) ||
|
|
207
|
+
(maxTokens !== null && budget.spentTokens() >= maxTokens),
|
|
208
|
+
};
|
|
209
|
+
let budgetLogged = false;
|
|
210
|
+
|
|
211
|
+
// Journal this run for later resume; replay from a prior run's journal.
|
|
212
|
+
let journal: JournalWriter | null = null;
|
|
213
|
+
let resumeCache: Map<string, JournalEntry[]> | null = null;
|
|
214
|
+
if (params.resumeFromRunId) {
|
|
215
|
+
const prior = loadJournal(params.resumeFromRunId);
|
|
216
|
+
if (prior) {
|
|
217
|
+
resumeCache = buildResumeCache(prior);
|
|
218
|
+
} else {
|
|
219
|
+
details.logs.push(`resume: no journal found for run "${params.resumeFromRunId}"; running everything live`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Agent-type definitions are discovered once per run, on first use.
|
|
224
|
+
let agentTypes: Map<string, AgentTypeConfig> | null = null;
|
|
225
|
+
const resolveAgentType = (name: string): AgentTypeConfig => {
|
|
226
|
+
agentTypes ??= discoverAgentTypes(ctx.cwd);
|
|
227
|
+
const def = agentTypes.get(name);
|
|
228
|
+
if (!def) {
|
|
229
|
+
const available = [...agentTypes.keys()].join(", ") || "(none found)";
|
|
230
|
+
throw new Error(`Unknown agentType "${name}". Available agent types: ${available}`);
|
|
231
|
+
}
|
|
232
|
+
return def;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const runAgent = async (prompt: string, options?: AgentOptions): Promise<unknown> => {
|
|
236
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
237
|
+
throw new Error("agent() requires a non-empty prompt string");
|
|
238
|
+
}
|
|
239
|
+
if (controller.signal.aborted) throw new WorkflowAbortError();
|
|
240
|
+
if (agentCounter >= maxAgents) {
|
|
241
|
+
throw new Error(`Workflow agent cap reached (${maxAgents}); refusing to spawn another agent`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
agentCounter++;
|
|
245
|
+
const opts = options ?? {};
|
|
246
|
+
const agentDef = opts.agentType ? resolveAgentType(opts.agentType) : undefined;
|
|
247
|
+
const phase = opts.phase ?? currentPhase;
|
|
248
|
+
const callHash = agentCallHash(prompt, opts, agentDef);
|
|
249
|
+
|
|
250
|
+
// Resume: replay a recorded result for a matching call without spawning.
|
|
251
|
+
const cachedEntry = resumeCache?.get(callHash)?.shift();
|
|
252
|
+
if (cachedEntry) {
|
|
253
|
+
const cachedRecord: AgentRecord = {
|
|
254
|
+
label: opts.label ?? cachedEntry.label,
|
|
255
|
+
...(phase !== undefined ? { phase } : {}),
|
|
256
|
+
status: "cached",
|
|
257
|
+
promptPreview: prompt.slice(0, 200),
|
|
258
|
+
output: cachedEntry.outputText,
|
|
259
|
+
...(cachedEntry.structured !== undefined ? { structured: cachedEntry.structured } : {}),
|
|
260
|
+
usage: cachedEntry.usage,
|
|
261
|
+
...(cachedEntry.model !== undefined ? { model: cachedEntry.model } : {}),
|
|
262
|
+
};
|
|
263
|
+
details.agents.push(cachedRecord);
|
|
264
|
+
emit();
|
|
265
|
+
// Re-record so the resumed run's own journal is complete and resumable.
|
|
266
|
+
journal?.record({ ...cachedEntry, hash: callHash });
|
|
267
|
+
return opts.schema ? cachedEntry.structured : cachedEntry.outputText;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Budget gates live spawns only; cached replays above are free.
|
|
271
|
+
if (budget.exceeded()) {
|
|
272
|
+
const u = spentUsage();
|
|
273
|
+
if (!budgetLogged) {
|
|
274
|
+
budgetLogged = true;
|
|
275
|
+
details.logs.push(`budget exhausted: $${u.cost.toFixed(4)} spent, ${u.input + u.output} tokens`);
|
|
276
|
+
emit();
|
|
277
|
+
}
|
|
278
|
+
throw new BudgetExceededError(
|
|
279
|
+
`Workflow budget exceeded (spent $${u.cost.toFixed(4)}, ${u.input + u.output} tokens); agent() refused`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const record: AgentRecord = {
|
|
284
|
+
label: opts.label ?? `agent-${agentCounter}`,
|
|
285
|
+
...(phase !== undefined ? { phase } : {}),
|
|
286
|
+
status: "queued",
|
|
287
|
+
promptPreview: prompt.slice(0, 200),
|
|
288
|
+
output: "",
|
|
289
|
+
usage: emptyUsage(),
|
|
290
|
+
};
|
|
291
|
+
details.agents.push(record);
|
|
292
|
+
emit();
|
|
293
|
+
|
|
294
|
+
const result = await semaphore.run(() => {
|
|
295
|
+
if (controller.signal.aborted) {
|
|
296
|
+
record.status = "aborted";
|
|
297
|
+
throw new WorkflowAbortError();
|
|
298
|
+
}
|
|
299
|
+
// Only mark "running" once the semaphore slot is held, so queued
|
|
300
|
+
// agents are not misreported as active subprocesses.
|
|
301
|
+
record.status = "running";
|
|
302
|
+
emit();
|
|
303
|
+
// Explicit options win over the agent-type definition's defaults.
|
|
304
|
+
const model = opts.model ?? agentDef?.model;
|
|
305
|
+
const tools = opts.tools ?? agentDef?.tools;
|
|
306
|
+
const appendPrompts: string[] = [];
|
|
307
|
+
if (agentDef) appendPrompts.push(agentDef.systemPrompt);
|
|
308
|
+
if (opts.appendSystemPrompt) appendPrompts.push(opts.appendSystemPrompt);
|
|
309
|
+
return runSubagent({
|
|
310
|
+
prompt,
|
|
311
|
+
...(model ? { model } : {}),
|
|
312
|
+
...(tools ? { tools } : {}),
|
|
313
|
+
cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.cwd,
|
|
314
|
+
...(opts.schema ? { schema: opts.schema } : {}),
|
|
315
|
+
...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
|
|
316
|
+
...(appendPrompts.length > 0 ? { appendSystemPrompt: appendPrompts } : {}),
|
|
317
|
+
...(opts.timeout ? { timeoutMs: opts.timeout } : {}),
|
|
318
|
+
signal: controller.signal,
|
|
319
|
+
onEvent: (update) => {
|
|
320
|
+
record.usage = update.usage;
|
|
321
|
+
emit();
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
record.usage = result.usage;
|
|
327
|
+
if (result.model !== undefined) record.model = result.model;
|
|
328
|
+
record.output = result.outputText;
|
|
329
|
+
|
|
330
|
+
if (result.aborted) {
|
|
331
|
+
record.status = "aborted";
|
|
332
|
+
emit();
|
|
333
|
+
throw new WorkflowAbortError();
|
|
334
|
+
}
|
|
335
|
+
if (!result.ok) {
|
|
336
|
+
record.status = "error";
|
|
337
|
+
record.error = result.errorMessage ?? "subagent failed";
|
|
338
|
+
emit();
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
record.status = "done";
|
|
343
|
+
if (result.structured !== undefined) record.structured = result.structured;
|
|
344
|
+
emit();
|
|
345
|
+
journal?.record({
|
|
346
|
+
hash: callHash,
|
|
347
|
+
label: record.label,
|
|
348
|
+
outputText: result.outputText,
|
|
349
|
+
...(result.structured !== undefined ? { structured: result.structured } : {}),
|
|
350
|
+
...(result.model !== undefined ? { model: result.model } : {}),
|
|
351
|
+
usage: result.usage,
|
|
352
|
+
});
|
|
353
|
+
return opts.schema ? result.structured : result.outputText;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const agentHook: ScriptHooks["agent"] = (prompt, options?: AgentOptions) => {
|
|
357
|
+
const promise = runAgent(prompt, options);
|
|
358
|
+
// A fire-and-forget agent() call (script never awaits it) would otherwise
|
|
359
|
+
// surface WorkflowAbortError as an unhandledRejection in the host pi
|
|
360
|
+
// process when the finally-abort fires. The no-op catch marks the
|
|
361
|
+
// rejection handled; awaited callers still receive it.
|
|
362
|
+
promise.catch(() => {});
|
|
363
|
+
return promise;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Nested saved workflows share this run's agent hook, semaphore, abort
|
|
367
|
+
// controller, and agent/budget accounting (all closures above). This is
|
|
368
|
+
// orthogonal to the PI_WORKFLOW_DEPTH guard in subagent.ts: no extra pi
|
|
369
|
+
// process is spawned for a nested workflow, only for its agent() calls.
|
|
370
|
+
// The one-level limit is enforced per invocation chain (the child gets a
|
|
371
|
+
// throwing workflow hook), not via a shared counter, so concurrent
|
|
372
|
+
// sibling workflow() calls at the top level are allowed.
|
|
373
|
+
const runNestedWorkflow = async (nameOrPath: string, nestedArgs?: unknown): Promise<unknown> => {
|
|
374
|
+
if (typeof nameOrPath !== "string" || !nameOrPath.trim()) {
|
|
375
|
+
throw new Error("workflow() requires a saved workflow name or a .js path");
|
|
376
|
+
}
|
|
377
|
+
const loaded = loadWorkflowSource(nameOrPath, ctx.cwd);
|
|
378
|
+
const childName = path.basename(nameOrPath, ".js");
|
|
379
|
+
const prevPhase = currentPhase;
|
|
380
|
+
hooks.log(`── workflow ${nameOrPath}`);
|
|
381
|
+
const childHooks: ScriptHooks = {
|
|
382
|
+
...hooks,
|
|
383
|
+
args: nestedArgs,
|
|
384
|
+
// Attribute the child's output: its logs and phases carry its name.
|
|
385
|
+
log: (message) => hooks.log(`[${childName}] ${message}`),
|
|
386
|
+
phase: (title) => hooks.phase(`${childName}: ${title}`),
|
|
387
|
+
workflow: async () => {
|
|
388
|
+
throw new Error("workflow() nesting is limited to one level");
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
return await runWorkflowScript(loaded.source, childHooks);
|
|
393
|
+
} finally {
|
|
394
|
+
// Best-effort restore; display-only, so last-finisher-wins among
|
|
395
|
+
// concurrent siblings is acceptable.
|
|
396
|
+
currentPhase = prevPhase;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const hooks: ScriptHooks = {
|
|
401
|
+
agent: agentHook,
|
|
402
|
+
parallel,
|
|
403
|
+
pipeline,
|
|
404
|
+
phase(title: string) {
|
|
405
|
+
currentPhase = String(title);
|
|
406
|
+
details.logs.push(`── ${currentPhase}`);
|
|
407
|
+
emit();
|
|
408
|
+
},
|
|
409
|
+
log(message: string) {
|
|
410
|
+
details.logs.push(typeof message === "string" ? message : JSON.stringify(message));
|
|
411
|
+
emit();
|
|
412
|
+
},
|
|
413
|
+
args: params.args,
|
|
414
|
+
budget,
|
|
415
|
+
workflow: runNestedWorkflow,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const markUnfinishedAgentsAborted = () => {
|
|
419
|
+
for (const a of details.agents) {
|
|
420
|
+
if (a.status === "running" || a.status === "queued") a.status = "aborted";
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const runToCompletion = async (): Promise<AgentToolResult<WorkflowDetails>> => {
|
|
425
|
+
try {
|
|
426
|
+
const sourceParams = [params.script, params.workflowName, params.scriptPath].filter(
|
|
427
|
+
(p) => p !== undefined,
|
|
428
|
+
).length;
|
|
429
|
+
if (sourceParams !== 1) {
|
|
430
|
+
throw new Error("Provide exactly one of `script`, `workflowName`, or `scriptPath`");
|
|
431
|
+
}
|
|
432
|
+
const scriptSource =
|
|
433
|
+
params.script ??
|
|
434
|
+
(params.scriptPath !== undefined
|
|
435
|
+
? fs.readFileSync(path.resolve(ctx.cwd, params.scriptPath), "utf-8")
|
|
436
|
+
: loadWorkflowSource(params.workflowName as string, ctx.cwd).source);
|
|
437
|
+
|
|
438
|
+
journal = new JournalWriter({
|
|
439
|
+
runId,
|
|
440
|
+
name: params.name,
|
|
441
|
+
description: params.description,
|
|
442
|
+
script: scriptSource,
|
|
443
|
+
});
|
|
444
|
+
// Persist a pointer in the session JSONL (outside LLM context) so
|
|
445
|
+
// resumable runs are discoverable even after a crash.
|
|
446
|
+
host.appendEntry?.("workflow-run", { runId, path: journal.filePath });
|
|
447
|
+
|
|
448
|
+
const returnValue = await runWorkflowScript(scriptSource, hooks);
|
|
449
|
+
details.returnValue = returnValue;
|
|
450
|
+
details.finishedAt = Date.now();
|
|
451
|
+
|
|
452
|
+
if (parentSignal?.aborted || controller.signal.aborted) {
|
|
453
|
+
details.aborted = true;
|
|
454
|
+
markUnfinishedAgentsAborted();
|
|
455
|
+
return {
|
|
456
|
+
content: [{ type: "text", text: `Workflow "${details.name}" aborted. Partial progress: ${progressLine()}` }],
|
|
457
|
+
details: snapshot(),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The finally-abort is about to kill any fire-and-forget agents still
|
|
462
|
+
// in flight; mark them aborted now so the final snapshot doesn't
|
|
463
|
+
// freeze them as "running".
|
|
464
|
+
markUnfinishedAgentsAborted();
|
|
465
|
+
|
|
466
|
+
const rendered =
|
|
467
|
+
returnValue === undefined
|
|
468
|
+
? "(no return value)"
|
|
469
|
+
: typeof returnValue === "string"
|
|
470
|
+
? returnValue
|
|
471
|
+
: JSON.stringify(returnValue, null, 2);
|
|
472
|
+
const truncation = truncateHead(rendered, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
473
|
+
let text = truncation.content;
|
|
474
|
+
if (truncation.truncated) {
|
|
475
|
+
text += `\n\n[Result truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown]`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const succeeded = details.agents.filter((a) => a.status === "done" || a.status === "cached").length;
|
|
479
|
+
const cachedCount = details.agents.filter((a) => a.status === "cached").length;
|
|
480
|
+
const header =
|
|
481
|
+
`Workflow "${details.name}" finished: ${succeeded}/${details.agents.length} agents succeeded` +
|
|
482
|
+
`${cachedCount > 0 ? ` (${cachedCount} from cache)` : ""}. Run id: ${runId} (resumable via resumeFromRunId).`;
|
|
483
|
+
return {
|
|
484
|
+
content: [{ type: "text", text: `${header}\n\n${text}` }],
|
|
485
|
+
details: snapshot(),
|
|
486
|
+
};
|
|
487
|
+
} catch (error) {
|
|
488
|
+
controller.abort();
|
|
489
|
+
details.finishedAt = Date.now();
|
|
490
|
+
markUnfinishedAgentsAborted();
|
|
491
|
+
|
|
492
|
+
if (error instanceof WorkflowAbortError || parentSignal?.aborted) {
|
|
493
|
+
details.aborted = true;
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text", text: `Workflow "${details.name}" aborted. Partial progress: ${progressLine()}` }],
|
|
496
|
+
details: snapshot(),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
details.scriptError = error instanceof Error ? error.message : String(error);
|
|
501
|
+
emit();
|
|
502
|
+
throw error;
|
|
503
|
+
} finally {
|
|
504
|
+
finished = true;
|
|
505
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
506
|
+
// Kill any stragglers from un-awaited agent() calls.
|
|
507
|
+
controller.abort();
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
if (params.background) {
|
|
512
|
+
// The tool call completes now; suppress all later onUpdate emissions.
|
|
513
|
+
finished = true;
|
|
514
|
+
activeBackgroundRuns.set(runId, controller);
|
|
515
|
+
const notify = (content: string) => {
|
|
516
|
+
activeBackgroundRuns.delete(runId);
|
|
517
|
+
host.sendMessage?.(
|
|
518
|
+
{ customType: WORKFLOW_COMPLETE_TYPE, content, display: true, details: snapshot() },
|
|
519
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
520
|
+
);
|
|
521
|
+
};
|
|
522
|
+
void runToCompletion().then(
|
|
523
|
+
(final) => {
|
|
524
|
+
const first = final.content[0];
|
|
525
|
+
notify(first?.type === "text" ? first.text : `Workflow "${params.name}" finished.`);
|
|
526
|
+
},
|
|
527
|
+
(error: unknown) => {
|
|
528
|
+
notify(
|
|
529
|
+
`Background workflow "${params.name}" failed: ` +
|
|
530
|
+
`${error instanceof Error ? error.message : String(error)} (run id: ${runId})`,
|
|
531
|
+
);
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
return {
|
|
535
|
+
content: [
|
|
536
|
+
{
|
|
537
|
+
type: "text",
|
|
538
|
+
text:
|
|
539
|
+
`Workflow "${params.name}" started in the background (run id: ${runId}). ` +
|
|
540
|
+
"A workflow-complete message will arrive when it finishes. " +
|
|
541
|
+
`Stop it with /workflow-stop ${runId}. If pi exits first, resume via resumeFromRunId.`,
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
details: snapshot(),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return runToCompletion();
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
renderCall(args, theme) {
|
|
552
|
+
return renderWorkflowCall(args, theme);
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
renderResult(result, options, theme) {
|
|
556
|
+
return renderWorkflowResult(result, options, theme);
|
|
557
|
+
},
|
|
558
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Shared types for the dynamic workflow extension. */
|
|
2
|
+
|
|
3
|
+
export interface UsageStats {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cacheRead: number;
|
|
7
|
+
cacheWrite: number;
|
|
8
|
+
cost: number;
|
|
9
|
+
turns: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function emptyUsage(): UsageStats {
|
|
13
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function addUsage(total: UsageStats, delta: UsageStats): void {
|
|
17
|
+
total.input += delta.input;
|
|
18
|
+
total.output += delta.output;
|
|
19
|
+
total.cacheRead += delta.cacheRead;
|
|
20
|
+
total.cacheWrite += delta.cacheWrite;
|
|
21
|
+
total.cost += delta.cost;
|
|
22
|
+
total.turns += delta.turns;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AgentStatus = "queued" | "running" | "done" | "error" | "aborted" | "cached";
|
|
26
|
+
|
|
27
|
+
/** One subagent invocation tracked in the run state. */
|
|
28
|
+
export interface AgentRecord {
|
|
29
|
+
label: string;
|
|
30
|
+
phase?: string;
|
|
31
|
+
status: AgentStatus;
|
|
32
|
+
/** Preview of the prompt (for rendering). */
|
|
33
|
+
promptPreview: string;
|
|
34
|
+
/** Final output text (or error message). */
|
|
35
|
+
output: string;
|
|
36
|
+
/** Parsed structured output when a schema was supplied. */
|
|
37
|
+
structured?: unknown;
|
|
38
|
+
usage: UsageStats;
|
|
39
|
+
model?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Budget caps configured on the run (kept in details for rendering). */
|
|
44
|
+
export interface BudgetCaps {
|
|
45
|
+
maxCost?: number;
|
|
46
|
+
maxTokens?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Full run state stored in the tool result `details`. */
|
|
50
|
+
export interface WorkflowDetails {
|
|
51
|
+
name: string;
|
|
52
|
+
description: string;
|
|
53
|
+
phases?: string[];
|
|
54
|
+
agents: AgentRecord[];
|
|
55
|
+
logs: string[];
|
|
56
|
+
startedAt: number;
|
|
57
|
+
finishedAt?: number;
|
|
58
|
+
returnValue?: unknown;
|
|
59
|
+
aborted?: boolean;
|
|
60
|
+
scriptError?: string;
|
|
61
|
+
budget?: BudgetCaps;
|
|
62
|
+
/** Journal id of this run (pass as resumeFromRunId to resume/re-run it). */
|
|
63
|
+
runId?: string;
|
|
64
|
+
/** Run id this run resumed from, when resumeFromRunId was given. */
|
|
65
|
+
resumedFrom?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options accepted by the sandboxed `agent()` hook. */
|
|
69
|
+
export interface AgentOptions {
|
|
70
|
+
label?: string;
|
|
71
|
+
phase?: string;
|
|
72
|
+
model?: string;
|
|
73
|
+
tools?: string[];
|
|
74
|
+
cwd?: string;
|
|
75
|
+
schema?: Record<string, unknown>;
|
|
76
|
+
/** Wall-clock cap in ms; on expiry the subprocess is killed and agent() resolves to null. */
|
|
77
|
+
timeout?: number;
|
|
78
|
+
/** Replace the subagent's system prompt entirely (pi --system-prompt). */
|
|
79
|
+
systemPrompt?: string;
|
|
80
|
+
/** Append text to the subagent's system prompt (pi --append-system-prompt). */
|
|
81
|
+
appendSystemPrompt?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Named agent definition resolved from pi's agents convention
|
|
84
|
+
* (~/.pi/agent/agents/*.md and <project>/.pi/agents/*.md). Supplies the
|
|
85
|
+
* subagent's system prompt (appended) plus default tools/model; explicit
|
|
86
|
+
* `tools`/`model` options win.
|
|
87
|
+
*/
|
|
88
|
+
agentType?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Budget view exposed to scripts as `budget`. Enforcement is a soft cap:
|
|
93
|
+
* agent() refuses to spawn once a cap is reached, but in-flight agents finish.
|
|
94
|
+
*/
|
|
95
|
+
export interface WorkflowBudget {
|
|
96
|
+
maxCost: number | null;
|
|
97
|
+
maxTokens: number | null;
|
|
98
|
+
spentCost(): number;
|
|
99
|
+
/** Tokens counted as input + output (cache reads/writes excluded). */
|
|
100
|
+
spentTokens(): number;
|
|
101
|
+
remainingCost(): number;
|
|
102
|
+
remainingTokens(): number;
|
|
103
|
+
exceeded(): boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Hooks injected into the workflow script sandbox. */
|
|
107
|
+
export interface ScriptHooks {
|
|
108
|
+
agent(prompt: string, options?: AgentOptions): Promise<unknown>;
|
|
109
|
+
parallel(thunks: Array<() => Promise<unknown>>): Promise<unknown[]>;
|
|
110
|
+
pipeline(
|
|
111
|
+
items: unknown[],
|
|
112
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown> | unknown>
|
|
113
|
+
): Promise<unknown[]>;
|
|
114
|
+
phase(title: string): void;
|
|
115
|
+
log(message: string): void;
|
|
116
|
+
args: unknown;
|
|
117
|
+
budget: WorkflowBudget;
|
|
118
|
+
/** Run a saved workflow (by name or .js path) inline as a sub-step; one nesting level only. */
|
|
119
|
+
workflow(nameOrPath: string, args?: unknown): Promise<unknown>;
|
|
120
|
+
}
|