@baryonlabs/pi-dynamic-workflows 1.0.2
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 +22 -0
- package/README.md +175 -0
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +86 -0
- package/dist/display.d.ts +54 -0
- package/dist/display.js +169 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/workflow-tool.d.ts +16 -0
- package/dist/workflow-tool.js +183 -0
- package/dist/workflow.d.ts +53 -0
- package/dist/workflow.js +426 -0
- package/extensions/workflow.ts +14 -0
- package/package.json +71 -0
- package/src/agent.ts +131 -0
- package/src/display.ts +235 -0
- package/src/index.ts +30 -0
- package/src/structured-output.ts +47 -0
- package/src/workflow-tool.ts +209 -0
- package/src/workflow.ts +502 -0
- package/types/workflow.d.ts +95 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { createToolUpdateWorkflowDisplay, createWorkflowSnapshot, preview, recomputeWorkflowSnapshot, renderWorkflowText, } from "./display.js";
|
|
5
|
+
import { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
6
|
+
const workflowToolSchema = Type.Object({
|
|
7
|
+
script: Type.String({
|
|
8
|
+
description: [
|
|
9
|
+
"Required raw JavaScript workflow script, with no Markdown fences.",
|
|
10
|
+
"First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. meta.phases is optional documentation; live progress is driven by phase(title).",
|
|
11
|
+
"Use phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, and budget. The workflow must call agent() at least once.",
|
|
12
|
+
"parallel() requires functions, not promises: await parallel(items.map(item => () => agent(...))).",
|
|
13
|
+
].join(" "),
|
|
14
|
+
}),
|
|
15
|
+
args: Type.Optional(Type.Any({ description: "Optional JSON value exposed to the workflow script as global `args`." })),
|
|
16
|
+
});
|
|
17
|
+
const workflowDisplayOptions = {
|
|
18
|
+
key: "workflow",
|
|
19
|
+
streamToolUpdates: true,
|
|
20
|
+
maxAgents: 4,
|
|
21
|
+
maxLogs: 1,
|
|
22
|
+
showResultPreviews: false,
|
|
23
|
+
};
|
|
24
|
+
export function createWorkflowTool(options = {}) {
|
|
25
|
+
return defineTool({
|
|
26
|
+
name: "workflow",
|
|
27
|
+
label: "Workflow",
|
|
28
|
+
description: [
|
|
29
|
+
"Execute a deterministic JavaScript workflow that orchestrates multiple subagents with agent(), parallel(), and pipeline().",
|
|
30
|
+
"script is required raw JavaScript. It must start with export const meta = { name, description } and must call agent() at least once; phases are optional metadata.",
|
|
31
|
+
].join(" "),
|
|
32
|
+
promptSnippet: "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Use phase(title) at runtime to create progress groups.",
|
|
33
|
+
promptGuidelines: [
|
|
34
|
+
"Use workflow only when the user explicitly asks for a workflow, workflows, fan-out, or multi-agent orchestration.",
|
|
35
|
+
"For workflow, always pass one raw JavaScript string in the required script parameter; do not include Markdown fences or prose around the script.",
|
|
36
|
+
"For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description' }`; meta.name and meta.description are required non-empty strings, and meta.phases is optional metadata for a stable upfront outline.",
|
|
37
|
+
"For workflow, write plain JavaScript after the meta export. Do not use TypeScript syntax, imports, require(), fs, Date.now(), Math.random(), or new Date().",
|
|
38
|
+
"For workflow, available globals are agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), args, cwd, process.cwd(), and budget. Every workflow must call agent() at least once; do not use workflow only to declare phases or return a static object.",
|
|
39
|
+
"For workflow, call phase(title) when a new group of work starts. Phase names may be conditional or built in a loop; do not predeclare speculative phases just in case.",
|
|
40
|
+
"For workflow, prefer it for decomposable work: repository inspection, independent research/checks, multi-perspective review, or fan-out/fan-in synthesis. Do not use it for a single quick file read/edit or when ordinary tools are enough.",
|
|
41
|
+
"For workflow, parallel() takes functions, not promises: use `await parallel(items.map(item => () => agent('...', { label: '...' })))`, never `await parallel(items.map(item => agent(...)))`. Results are returned in input order.",
|
|
42
|
+
"For workflow, pipeline(items, ...stages) runs each item through stages sequentially, while different items may run concurrently. Each stage receives (previousValue, originalItem, index).",
|
|
43
|
+
"For workflow, every agent() call should include a unique short label option, 2-5 words, such as { label: 'repo inventory' } or { label: 'source modules' }; unique labels make live status and error reporting readable.",
|
|
44
|
+
"For workflow, failed agent(), parallel(), or pipeline() branches return null and log the failure unless the workflow is aborted. Check for nulls before synthesizing conclusions.",
|
|
45
|
+
"For workflow, include a final synthesis/assertion agent when combining multiple subagent results; return a compact JSON-serializable value with ok/verdict plus the important outputs.",
|
|
46
|
+
"For workflow, if agent() needs machine-readable output, pass a plain JSON Schema via opts.schema; agent() will return the validated object. Use JSON Schema syntax, not TypeScript or TypeBox constructors.",
|
|
47
|
+
"For workflow, do not assume the parent assistant has repository code context inside subagents; include enough task context and relevant paths in each agent prompt.",
|
|
48
|
+
],
|
|
49
|
+
parameters: workflowToolSchema,
|
|
50
|
+
prepareArguments(args) {
|
|
51
|
+
return normalizeWorkflowToolArgs(args);
|
|
52
|
+
},
|
|
53
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
54
|
+
const script = normalizeWorkflowScript(params.script);
|
|
55
|
+
const parsed = parseWorkflowScript(script);
|
|
56
|
+
let snapshot = createWorkflowSnapshot(parsed.meta);
|
|
57
|
+
const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, workflowDisplayOptions);
|
|
58
|
+
const update = () => {
|
|
59
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
60
|
+
display.update(snapshot);
|
|
61
|
+
};
|
|
62
|
+
const recordPhase = (title) => {
|
|
63
|
+
if (!title)
|
|
64
|
+
return;
|
|
65
|
+
if (!snapshot.phases.includes(title))
|
|
66
|
+
snapshot.phases.push(title);
|
|
67
|
+
};
|
|
68
|
+
let result;
|
|
69
|
+
try {
|
|
70
|
+
result = await runWorkflow(script, {
|
|
71
|
+
cwd: options.cwd ?? ctx.cwd,
|
|
72
|
+
args: params.args,
|
|
73
|
+
signal,
|
|
74
|
+
concurrency: options.concurrency,
|
|
75
|
+
session: {
|
|
76
|
+
modelRegistry: ctx.modelRegistry,
|
|
77
|
+
model: ctx.model,
|
|
78
|
+
},
|
|
79
|
+
onLog(message) {
|
|
80
|
+
snapshot.logs.push(message);
|
|
81
|
+
update();
|
|
82
|
+
},
|
|
83
|
+
onPhase(title) {
|
|
84
|
+
snapshot.currentPhase = title;
|
|
85
|
+
recordPhase(title);
|
|
86
|
+
update();
|
|
87
|
+
},
|
|
88
|
+
onAgentStart(event) {
|
|
89
|
+
if (signal?.aborted)
|
|
90
|
+
throw new Error("Workflow was aborted");
|
|
91
|
+
recordPhase(event.phase);
|
|
92
|
+
snapshot.agents.push({
|
|
93
|
+
id: snapshot.agents.length + 1,
|
|
94
|
+
label: event.label,
|
|
95
|
+
phase: event.phase,
|
|
96
|
+
prompt: event.prompt,
|
|
97
|
+
status: "running",
|
|
98
|
+
});
|
|
99
|
+
update();
|
|
100
|
+
},
|
|
101
|
+
onAgentEnd(event) {
|
|
102
|
+
const agent = [...snapshot.agents]
|
|
103
|
+
.reverse()
|
|
104
|
+
.find((item) => item.label === event.label && item.status === "running");
|
|
105
|
+
if (agent) {
|
|
106
|
+
agent.status = event.result === null ? "error" : "done";
|
|
107
|
+
agent.resultPreview = preview(event.result);
|
|
108
|
+
}
|
|
109
|
+
update();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
115
|
+
for (const agent of snapshot.agents) {
|
|
116
|
+
if (agent.status === "running") {
|
|
117
|
+
agent.status = "skipped";
|
|
118
|
+
agent.error = "aborted";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
122
|
+
display.complete(snapshot);
|
|
123
|
+
throw new Error("Workflow was aborted");
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
if (result.agentCount === 0) {
|
|
128
|
+
throw new Error("workflow scripts must call agent() at least once; this workflow declared phases but did not run any subagents");
|
|
129
|
+
}
|
|
130
|
+
snapshot.result = result.result;
|
|
131
|
+
snapshot.durationMs = result.durationMs;
|
|
132
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
133
|
+
display.complete(snapshot);
|
|
134
|
+
return {
|
|
135
|
+
content: [
|
|
136
|
+
{
|
|
137
|
+
type: "text",
|
|
138
|
+
text: `Workflow ${result.meta.name} completed with ${result.agentCount} agent(s).\n\nResult:\n${JSON.stringify(result.result, null, 2)}`,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
details: {
|
|
142
|
+
...snapshot,
|
|
143
|
+
meta: result.meta,
|
|
144
|
+
phases: result.phases,
|
|
145
|
+
logs: result.logs,
|
|
146
|
+
result: result.result,
|
|
147
|
+
durationMs: result.durationMs,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
renderCall(_args, theme) {
|
|
152
|
+
return new Text(theme.fg("toolTitle", theme.bold("workflow")), 0, 0);
|
|
153
|
+
},
|
|
154
|
+
renderResult(result, { isPartial }, theme) {
|
|
155
|
+
const snapshot = result.details;
|
|
156
|
+
if (snapshot?.name) {
|
|
157
|
+
return new Text(renderWorkflowText(snapshot, !isPartial, workflowDisplayOptions), 0, 0);
|
|
158
|
+
}
|
|
159
|
+
const text = result.content?.[0];
|
|
160
|
+
return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function normalizeWorkflowToolArgs(args) {
|
|
165
|
+
if (!args || typeof args !== "object")
|
|
166
|
+
throw new Error("workflow requires an object argument with a script string");
|
|
167
|
+
const value = args;
|
|
168
|
+
if (typeof value.script !== "string")
|
|
169
|
+
throw new Error("workflow requires `script` to be a string");
|
|
170
|
+
return { ...value, script: normalizeWorkflowScript(value.script) };
|
|
171
|
+
}
|
|
172
|
+
function normalizeWorkflowScript(script) {
|
|
173
|
+
let text = script.trim();
|
|
174
|
+
const fence = text.match(/^```(?:js|javascript)?\s*\n([\s\S]*?)\n```$/i);
|
|
175
|
+
if (fence)
|
|
176
|
+
text = fence[1].trim();
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
function isAbortError(error) {
|
|
180
|
+
if (!(error instanceof Error))
|
|
181
|
+
return false;
|
|
182
|
+
return /\babort(?:ed)?\b/i.test(error.message);
|
|
183
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
import { WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
|
|
3
|
+
export interface WorkflowMetaPhase {
|
|
4
|
+
title: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkflowMeta {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
whenToUse?: string;
|
|
12
|
+
phases?: WorkflowMetaPhase[];
|
|
13
|
+
}
|
|
14
|
+
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
15
|
+
args?: unknown;
|
|
16
|
+
agent?: Pick<WorkflowAgent, "run">;
|
|
17
|
+
concurrency?: number;
|
|
18
|
+
tokenBudget?: number | null;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
onLog?: (message: string) => void;
|
|
21
|
+
onPhase?: (title: string) => void;
|
|
22
|
+
onAgentStart?: (event: {
|
|
23
|
+
label: string;
|
|
24
|
+
phase?: string;
|
|
25
|
+
prompt: string;
|
|
26
|
+
}) => void;
|
|
27
|
+
onAgentEnd?: (event: {
|
|
28
|
+
label: string;
|
|
29
|
+
phase?: string;
|
|
30
|
+
result: unknown;
|
|
31
|
+
}) => void;
|
|
32
|
+
}
|
|
33
|
+
export interface WorkflowRunResult<T = unknown> {
|
|
34
|
+
meta: WorkflowMeta;
|
|
35
|
+
result: T;
|
|
36
|
+
logs: string[];
|
|
37
|
+
phases: string[];
|
|
38
|
+
agentCount: number;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
}
|
|
41
|
+
export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema | undefined> {
|
|
42
|
+
label?: string;
|
|
43
|
+
phase?: string;
|
|
44
|
+
schema?: TSchemaDef;
|
|
45
|
+
model?: string;
|
|
46
|
+
isolation?: "worktree";
|
|
47
|
+
agentType?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function runWorkflow<T = unknown>(script: string, options?: WorkflowRunOptions): Promise<WorkflowRunResult<T>>;
|
|
50
|
+
export declare function parseWorkflowScript(script: string): {
|
|
51
|
+
meta: WorkflowMeta;
|
|
52
|
+
body: string;
|
|
53
|
+
};
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import vm from "node:vm";
|
|
2
|
+
import { parse } from "acorn";
|
|
3
|
+
import { WorkflowAgent } from "./agent.js";
|
|
4
|
+
const NONDETERMINISM_ERROR = "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable";
|
|
5
|
+
export async function runWorkflow(script, options = {}) {
|
|
6
|
+
const started = Date.now();
|
|
7
|
+
const { meta, body } = parseWorkflowScript(script);
|
|
8
|
+
const state = { logs: [], phases: [], agentCount: 0, spent: 0 };
|
|
9
|
+
const agentRunner = options.agent ?? new WorkflowAgent(options);
|
|
10
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), 16));
|
|
11
|
+
const limiter = createLimiter(concurrency);
|
|
12
|
+
const pendingAgentRuns = new Set();
|
|
13
|
+
const log = (message) => {
|
|
14
|
+
const text = String(message);
|
|
15
|
+
state.logs.push(text);
|
|
16
|
+
options.onLog?.(text);
|
|
17
|
+
};
|
|
18
|
+
const phase = (title) => {
|
|
19
|
+
const text = requireString(title, "phase title");
|
|
20
|
+
state.currentPhase = text;
|
|
21
|
+
if (!state.phases.includes(text))
|
|
22
|
+
state.phases.push(text);
|
|
23
|
+
options.onPhase?.(text);
|
|
24
|
+
};
|
|
25
|
+
const budget = Object.freeze({
|
|
26
|
+
total: options.tokenBudget ?? null,
|
|
27
|
+
spent: () => state.spent,
|
|
28
|
+
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
|
|
29
|
+
});
|
|
30
|
+
const throwIfAborted = () => {
|
|
31
|
+
if (options.signal?.aborted)
|
|
32
|
+
throw new Error("workflow aborted");
|
|
33
|
+
};
|
|
34
|
+
const agent = async (prompt, agentOptions = {}) => {
|
|
35
|
+
throwIfAborted();
|
|
36
|
+
if (budget.total !== null && budget.remaining() <= 0)
|
|
37
|
+
throw new Error("workflow token budget exhausted");
|
|
38
|
+
const taskPrompt = requireString(prompt, "agent prompt");
|
|
39
|
+
const normalizedOptions = normalizeAgentOptions(agentOptions);
|
|
40
|
+
const assignedPhase = normalizedOptions.phase ?? state.currentPhase;
|
|
41
|
+
const requestedLabel = normalizedOptions.label?.trim();
|
|
42
|
+
const run = limiter(async () => {
|
|
43
|
+
state.agentCount++;
|
|
44
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
45
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt: taskPrompt });
|
|
46
|
+
try {
|
|
47
|
+
throwIfAborted();
|
|
48
|
+
const result = await agentRunner.run(taskPrompt, {
|
|
49
|
+
label,
|
|
50
|
+
schema: normalizedOptions.schema,
|
|
51
|
+
signal: options.signal,
|
|
52
|
+
instructions: buildAgentInstructions(assignedPhase, normalizedOptions),
|
|
53
|
+
});
|
|
54
|
+
throwIfAborted();
|
|
55
|
+
state.spent += estimateTokens(result);
|
|
56
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result });
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (options.signal?.aborted)
|
|
61
|
+
throw error;
|
|
62
|
+
log(`agent ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
pendingAgentRuns.add(run);
|
|
68
|
+
run.then(() => pendingAgentRuns.delete(run), () => pendingAgentRuns.delete(run));
|
|
69
|
+
return run;
|
|
70
|
+
};
|
|
71
|
+
const parallel = async (thunks) => {
|
|
72
|
+
throwIfAborted();
|
|
73
|
+
if (!Array.isArray(thunks))
|
|
74
|
+
throw new TypeError("parallel() expects an array of functions");
|
|
75
|
+
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
76
|
+
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
77
|
+
}
|
|
78
|
+
return Promise.all(thunks.map(async (thunk, index) => {
|
|
79
|
+
try {
|
|
80
|
+
return await thunk();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (options.signal?.aborted)
|
|
84
|
+
throw error;
|
|
85
|
+
log(`parallel[${index}] failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
};
|
|
90
|
+
const pipeline = async (items, ...stages) => {
|
|
91
|
+
throwIfAborted();
|
|
92
|
+
if (!Array.isArray(items))
|
|
93
|
+
throw new TypeError("pipeline() expects an array as the first argument");
|
|
94
|
+
if (stages.some((stage) => typeof stage !== "function")) {
|
|
95
|
+
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
96
|
+
}
|
|
97
|
+
return Promise.all(items.map(async (item, index) => {
|
|
98
|
+
let value = item;
|
|
99
|
+
for (const stage of stages) {
|
|
100
|
+
try {
|
|
101
|
+
throwIfAborted();
|
|
102
|
+
value = await stage(value, item, index);
|
|
103
|
+
throwIfAborted();
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (options.signal?.aborted)
|
|
107
|
+
throw error;
|
|
108
|
+
log(`pipeline[${index}] failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}));
|
|
114
|
+
};
|
|
115
|
+
// Models sometimes hallucinate host APIs inside workflow scripts (bash, fetch,
|
|
116
|
+
// require, ...). A bare ReferenceError is opaque and, when thrown inside an
|
|
117
|
+
// un-awaited async call, escapes as an unhandled rejection. Predefine the
|
|
118
|
+
// usual suspects as guards that throw a descriptive, actionable error instead.
|
|
119
|
+
const unavailable = (name, hint) => () => {
|
|
120
|
+
throw new Error(`${name}() is not available in workflow scripts — ${hint}`);
|
|
121
|
+
};
|
|
122
|
+
const shellHint = 'delegate shell/file work to a subagent instead, e.g. await agent("run <command> and report the output")';
|
|
123
|
+
const guards = Object.fromEntries(["bash", "sh", "shell", "exec", "execSync", "spawn", "spawnSync", "system"].map((name) => [
|
|
124
|
+
name,
|
|
125
|
+
unavailable(name, shellHint),
|
|
126
|
+
]));
|
|
127
|
+
guards.fetch = unavailable("fetch", 'use await agent("fetch <url> and report ...") instead');
|
|
128
|
+
guards.require = unavailable("require", "workflow scripts have no module system; use agent()/parallel()/pipeline()");
|
|
129
|
+
const context = vm.createContext({
|
|
130
|
+
...guards,
|
|
131
|
+
agent,
|
|
132
|
+
parallel,
|
|
133
|
+
pipeline,
|
|
134
|
+
log,
|
|
135
|
+
phase,
|
|
136
|
+
args: options.args,
|
|
137
|
+
cwd: options.cwd ?? process.cwd(),
|
|
138
|
+
process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
|
|
139
|
+
budget,
|
|
140
|
+
console: {
|
|
141
|
+
log,
|
|
142
|
+
info: log,
|
|
143
|
+
warn: (m) => log(`[warn] ${String(m)}`),
|
|
144
|
+
error: (m) => log(`[error] ${String(m)}`),
|
|
145
|
+
},
|
|
146
|
+
JSON,
|
|
147
|
+
Math,
|
|
148
|
+
Array,
|
|
149
|
+
Object,
|
|
150
|
+
String,
|
|
151
|
+
Number,
|
|
152
|
+
Boolean,
|
|
153
|
+
Set,
|
|
154
|
+
Map,
|
|
155
|
+
Promise,
|
|
156
|
+
});
|
|
157
|
+
const wrapped = `(async () => {\n${body}\n})()`;
|
|
158
|
+
// A floating rejection inside the script (e.g. an un-awaited `main()` that
|
|
159
|
+
// throws) bypasses this await and escapes as a process-level unhandled
|
|
160
|
+
// rejection — killing the HOST process (pi exits, losing the session).
|
|
161
|
+
// Capture unhandled rejections for the duration of the run and fail the
|
|
162
|
+
// workflow with them instead, so the error comes back as a tool result.
|
|
163
|
+
// Node emits `unhandledRejection` at end-of-tick, possibly AFTER the script
|
|
164
|
+
// resolves — so keep listening one extra event-loop turn before deciding.
|
|
165
|
+
let floated = false;
|
|
166
|
+
let floatingReject;
|
|
167
|
+
const floatingRejection = new Promise((_, reject) => {
|
|
168
|
+
floatingReject = (reason) => {
|
|
169
|
+
floated = true;
|
|
170
|
+
reject(reason instanceof Error ? reason : new Error(String(reason)));
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
floatingRejection.catch(() => { }); // avoid the sentinel itself going unhandled
|
|
174
|
+
const onUnhandled = (reason) => floatingReject(reason);
|
|
175
|
+
process.on("unhandledRejection", onUnhandled);
|
|
176
|
+
let result;
|
|
177
|
+
try {
|
|
178
|
+
const scriptRun = new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
|
|
179
|
+
result = await Promise.race([scriptRun, floatingRejection]);
|
|
180
|
+
await Promise.allSettled([...pendingAgentRuns]);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
// Late rejections surface on the next tick — absorb them before detaching,
|
|
184
|
+
// otherwise they'd escape to the process the instant we stop listening.
|
|
185
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
186
|
+
process.off("unhandledRejection", onUnhandled);
|
|
187
|
+
}
|
|
188
|
+
if (floated)
|
|
189
|
+
await floatingRejection; // rethrow the captured floating error
|
|
190
|
+
assertStructuredCloneable(result, "workflow result");
|
|
191
|
+
return {
|
|
192
|
+
meta,
|
|
193
|
+
result: result,
|
|
194
|
+
logs: state.logs,
|
|
195
|
+
phases: state.phases,
|
|
196
|
+
agentCount: state.agentCount,
|
|
197
|
+
durationMs: Date.now() - started,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export function parseWorkflowScript(script) {
|
|
201
|
+
const ast = parse(script, {
|
|
202
|
+
ecmaVersion: "latest",
|
|
203
|
+
sourceType: "module",
|
|
204
|
+
allowAwaitOutsideFunction: true,
|
|
205
|
+
allowReturnOutsideFunction: true,
|
|
206
|
+
ranges: false,
|
|
207
|
+
});
|
|
208
|
+
assertDeterministicAst(ast);
|
|
209
|
+
const first = ast.body?.[0];
|
|
210
|
+
if (first?.type !== "ExportNamedDeclaration") {
|
|
211
|
+
throw new Error("`export const meta = { name, description }` must be the first statement in the script");
|
|
212
|
+
}
|
|
213
|
+
const declaration = first.declaration;
|
|
214
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
|
|
215
|
+
throw new Error("meta export must be `export const meta = ...`");
|
|
216
|
+
}
|
|
217
|
+
if (declaration.declarations.length !== 1) {
|
|
218
|
+
throw new Error("meta export must declare only `meta`");
|
|
219
|
+
}
|
|
220
|
+
const declarator = declaration.declarations[0];
|
|
221
|
+
if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
|
|
222
|
+
throw new Error("meta export must declare `meta`");
|
|
223
|
+
}
|
|
224
|
+
if (!declarator.init)
|
|
225
|
+
throw new Error("meta must have a literal value");
|
|
226
|
+
const meta = evaluateLiteral(declarator.init, "meta");
|
|
227
|
+
validateMeta(meta);
|
|
228
|
+
return {
|
|
229
|
+
meta,
|
|
230
|
+
body: script.slice(0, first.start) + script.slice(first.end),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function evaluateLiteral(node, path) {
|
|
234
|
+
switch (node.type) {
|
|
235
|
+
case "ObjectExpression": {
|
|
236
|
+
const out = {};
|
|
237
|
+
for (const prop of node.properties) {
|
|
238
|
+
if (prop.type === "SpreadElement")
|
|
239
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
240
|
+
if (prop.type !== "Property")
|
|
241
|
+
throw new Error(`only plain properties allowed in ${path}`);
|
|
242
|
+
if (prop.computed)
|
|
243
|
+
throw new Error(`computed keys not allowed in ${path}`);
|
|
244
|
+
if (prop.kind !== "init" || prop.method)
|
|
245
|
+
throw new Error(`methods/accessors not allowed in ${path}`);
|
|
246
|
+
const key = propertyKey(prop.key, path);
|
|
247
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
248
|
+
throw new Error(`reserved key name not allowed in ${path}: ${key}`);
|
|
249
|
+
}
|
|
250
|
+
out[key] = evaluateLiteral(prop.value, `${path}.${key}`);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
case "ArrayExpression":
|
|
255
|
+
return node.elements.map((element, index) => {
|
|
256
|
+
if (!element)
|
|
257
|
+
throw new Error(`sparse arrays not allowed in ${path}`);
|
|
258
|
+
if (element.type === "SpreadElement")
|
|
259
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
260
|
+
return evaluateLiteral(element, `${path}[${index}]`);
|
|
261
|
+
});
|
|
262
|
+
case "Literal":
|
|
263
|
+
return node.value;
|
|
264
|
+
case "TemplateLiteral":
|
|
265
|
+
if (node.expressions.length > 0)
|
|
266
|
+
throw new Error(`template interpolation not allowed in ${path}`);
|
|
267
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
|
|
268
|
+
case "UnaryExpression":
|
|
269
|
+
if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
|
|
270
|
+
return -node.argument.value;
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`only negative-number unary allowed in ${path}`);
|
|
273
|
+
default:
|
|
274
|
+
throw new Error(`non-literal node type in ${path}: ${node.type}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function propertyKey(node, path) {
|
|
278
|
+
if (node.type === "Identifier")
|
|
279
|
+
return node.name;
|
|
280
|
+
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
|
|
281
|
+
return String(node.value);
|
|
282
|
+
throw new Error(`unsupported key type in ${path}: ${node.type}`);
|
|
283
|
+
}
|
|
284
|
+
function assertDeterministicAst(node) {
|
|
285
|
+
if (isDateNowCall(node) || isMathRandomCall(node) || isNewDateExpression(node)) {
|
|
286
|
+
throw new Error(NONDETERMINISM_ERROR);
|
|
287
|
+
}
|
|
288
|
+
for (const child of astChildren(node))
|
|
289
|
+
assertDeterministicAst(child);
|
|
290
|
+
}
|
|
291
|
+
function astChildren(node) {
|
|
292
|
+
const children = [];
|
|
293
|
+
for (const value of Object.values(node)) {
|
|
294
|
+
if (Array.isArray(value))
|
|
295
|
+
children.push(...value.filter(isAstNode));
|
|
296
|
+
else if (isAstNode(value))
|
|
297
|
+
children.push(value);
|
|
298
|
+
}
|
|
299
|
+
return children;
|
|
300
|
+
}
|
|
301
|
+
function isAstNode(value) {
|
|
302
|
+
return !!value && typeof value === "object" && typeof value.type === "string";
|
|
303
|
+
}
|
|
304
|
+
function isDateNowCall(node) {
|
|
305
|
+
return node.type === "CallExpression" && isMemberExpression(node.callee, "Date", "now");
|
|
306
|
+
}
|
|
307
|
+
function isMathRandomCall(node) {
|
|
308
|
+
return node.type === "CallExpression" && isMemberExpression(node.callee, "Math", "random");
|
|
309
|
+
}
|
|
310
|
+
function isNewDateExpression(node) {
|
|
311
|
+
return node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "Date";
|
|
312
|
+
}
|
|
313
|
+
function isMemberExpression(node, objectName, propertyName) {
|
|
314
|
+
if (node?.type !== "MemberExpression" || node.object?.type !== "Identifier" || node.object.name !== objectName) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
return propertyNameOf(node) === propertyName;
|
|
318
|
+
}
|
|
319
|
+
function propertyNameOf(node) {
|
|
320
|
+
if (!node.computed && node.property?.type === "Identifier")
|
|
321
|
+
return node.property.name;
|
|
322
|
+
return staticStringOf(node.property);
|
|
323
|
+
}
|
|
324
|
+
function staticStringOf(node) {
|
|
325
|
+
if (node?.type === "Literal" && typeof node.value === "string")
|
|
326
|
+
return node.value;
|
|
327
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
328
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
|
|
329
|
+
}
|
|
330
|
+
if (node?.type === "BinaryExpression" && node.operator === "+") {
|
|
331
|
+
const left = staticStringOf(node.left);
|
|
332
|
+
const right = staticStringOf(node.right);
|
|
333
|
+
if (left !== undefined && right !== undefined)
|
|
334
|
+
return left + right;
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
function validateMeta(meta) {
|
|
339
|
+
if (!meta || typeof meta !== "object")
|
|
340
|
+
throw new Error("meta must be an object");
|
|
341
|
+
const value = meta;
|
|
342
|
+
if (typeof value.name !== "string" || !value.name.trim())
|
|
343
|
+
throw new Error("meta.name must be a non-empty string");
|
|
344
|
+
if (typeof value.description !== "string" || !value.description.trim())
|
|
345
|
+
throw new Error("meta.description must be a non-empty string");
|
|
346
|
+
if (value.whenToUse !== undefined && typeof value.whenToUse !== "string")
|
|
347
|
+
throw new Error("meta.whenToUse must be a string");
|
|
348
|
+
if (value.phases !== undefined) {
|
|
349
|
+
if (!Array.isArray(value.phases))
|
|
350
|
+
throw new Error("meta.phases must be an array");
|
|
351
|
+
for (const phase of value.phases) {
|
|
352
|
+
if (!phase || typeof phase !== "object" || typeof phase.title !== "string") {
|
|
353
|
+
throw new Error("each meta phase must have a title string");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function createLimiter(limit) {
|
|
359
|
+
let active = 0;
|
|
360
|
+
const queue = [];
|
|
361
|
+
const next = () => {
|
|
362
|
+
active--;
|
|
363
|
+
queue.shift()?.();
|
|
364
|
+
};
|
|
365
|
+
return async (fn) => {
|
|
366
|
+
if (active >= limit)
|
|
367
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
368
|
+
active++;
|
|
369
|
+
try {
|
|
370
|
+
return await fn();
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
next();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function requireString(value, name) {
|
|
378
|
+
if (typeof value !== "string")
|
|
379
|
+
throw new TypeError(`${name} must be a string`);
|
|
380
|
+
return value;
|
|
381
|
+
}
|
|
382
|
+
function optionalString(value, name) {
|
|
383
|
+
if (value === undefined)
|
|
384
|
+
return undefined;
|
|
385
|
+
return requireString(value, name);
|
|
386
|
+
}
|
|
387
|
+
function normalizeAgentOptions(value) {
|
|
388
|
+
if (!value || typeof value !== "object")
|
|
389
|
+
throw new TypeError("agent options must be an object");
|
|
390
|
+
const options = value;
|
|
391
|
+
return {
|
|
392
|
+
...options,
|
|
393
|
+
label: optionalString(options.label, "agent label"),
|
|
394
|
+
phase: optionalString(options.phase, "agent phase"),
|
|
395
|
+
model: optionalString(options.model, "agent model"),
|
|
396
|
+
isolation: options.isolation,
|
|
397
|
+
agentType: optionalString(options.agentType, "agent type"),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function assertStructuredCloneable(value, name) {
|
|
401
|
+
try {
|
|
402
|
+
structuredClone(value);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
const detail = error instanceof Error ? ` ${error.message}` : "";
|
|
406
|
+
throw new Error(`${name} must be structured-cloneable; did you forget to await agent(), parallel(), or pipeline()?${detail}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function defaultAgentLabel(phase, index) {
|
|
410
|
+
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
411
|
+
}
|
|
412
|
+
function buildAgentInstructions(phase, options) {
|
|
413
|
+
const lines = [];
|
|
414
|
+
if (phase)
|
|
415
|
+
lines.push(`Workflow phase: ${phase}`);
|
|
416
|
+
if (options.agentType)
|
|
417
|
+
lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
418
|
+
if (options.isolation)
|
|
419
|
+
lines.push(`Requested isolation: ${options.isolation}`);
|
|
420
|
+
if (options.model)
|
|
421
|
+
lines.push(`Requested model: ${options.model}`);
|
|
422
|
+
return lines.length ? lines.join("\n") : undefined;
|
|
423
|
+
}
|
|
424
|
+
function estimateTokens(value) {
|
|
425
|
+
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
426
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createWorkflowTool } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
export default function extension(pi: ExtensionAPI) {
|
|
5
|
+
const workflowTool = createWorkflowTool();
|
|
6
|
+
pi.registerTool(workflowTool);
|
|
7
|
+
|
|
8
|
+
pi.on("session_start", () => {
|
|
9
|
+
const active = pi.getActiveTools();
|
|
10
|
+
if (!active.includes(workflowTool.name)) {
|
|
11
|
+
pi.setActiveTools([...active, workflowTool.name]);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|