pi-ultracode 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/README.md +225 -0
- package/examples/workflows/loop-until-dry-bugs.workflow.js +74 -0
- package/extensions/ultracode.ts +56 -0
- package/package.json +57 -0
- package/src/commands.ts +161 -0
- package/src/index.ts +55 -0
- package/src/mode.ts +196 -0
- package/src/prompts.ts +83 -0
- package/src/workflow/agent-runner.ts +329 -0
- package/src/workflow/agent-types.ts +195 -0
- package/src/workflow/display.ts +245 -0
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/json-schema.ts +116 -0
- package/src/workflow/parser.ts +213 -0
- package/src/workflow/registry.ts +65 -0
- package/src/workflow/runtime.ts +651 -0
- package/src/workflow/structured-output.ts +48 -0
- package/src/workflow/tool.ts +350 -0
- package/src/workflow/worktree.ts +381 -0
- package/types/workflow.d.ts +86 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `workflow` tool: parses a workflow script, persists it, runs it through the
|
|
3
|
+
* deterministic runtime with live progress, supports resume, and returns the
|
|
4
|
+
* structured result to the parent assistant.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
import {
|
|
14
|
+
WORKFLOW_GUIDELINES,
|
|
15
|
+
WORKFLOW_PROMPT_SNIPPET,
|
|
16
|
+
WORKFLOW_TOOL_DESCRIPTION,
|
|
17
|
+
} from "../prompts.ts";
|
|
18
|
+
import { parseWorkflowScript, normalizeScript } from "./parser.ts";
|
|
19
|
+
import { runWorkflow } from "./runtime.ts";
|
|
20
|
+
import type { ThinkingLevel } from "./agent-runner.ts";
|
|
21
|
+
import { RunJournal, hashString } from "./journal.ts";
|
|
22
|
+
import { getRegistry } from "./registry.ts";
|
|
23
|
+
import {
|
|
24
|
+
createSnapshot,
|
|
25
|
+
preview,
|
|
26
|
+
recompute,
|
|
27
|
+
renderWorkflowText,
|
|
28
|
+
type WorkflowSnapshot,
|
|
29
|
+
} from "./display.ts";
|
|
30
|
+
|
|
31
|
+
const workflowToolSchema = Type.Object({
|
|
32
|
+
script: Type.Optional(
|
|
33
|
+
Type.String({
|
|
34
|
+
description:
|
|
35
|
+
"Raw JavaScript workflow script (no Markdown fences). First statement: export const meta = { name: 'snake_case', description: '...' }. Must call agent() at least once. Required unless `name` or `scriptPath` is given.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
scriptPath: Type.Optional(
|
|
39
|
+
Type.String({ description: "Path to a workflow script file to run instead of an inline `script`." }),
|
|
40
|
+
),
|
|
41
|
+
name: Type.Optional(
|
|
42
|
+
Type.String({ description: "Name of a saved workflow (under .pi/ultracode/workflows/) to run." }),
|
|
43
|
+
),
|
|
44
|
+
args: Type.Optional(
|
|
45
|
+
Type.Any({ description: "Optional JSON value exposed to the workflow script as the global `args`." }),
|
|
46
|
+
),
|
|
47
|
+
budget: Type.Optional(
|
|
48
|
+
Type.Number({ description: "Optional output-token ceiling for this run; agent() calls throw once exhausted." }),
|
|
49
|
+
),
|
|
50
|
+
resumeFromRunId: Type.Optional(
|
|
51
|
+
Type.String({
|
|
52
|
+
description:
|
|
53
|
+
"Resume a prior run: agent() calls with unchanged (prompt, opts) return cached results; the first changed/new call and everything after run live.",
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export interface WorkflowToolDeps {
|
|
59
|
+
/** Default token budget from ultracode mode, if any. */
|
|
60
|
+
getDefaultBudget?: () => number | null;
|
|
61
|
+
/** The ultracode effort level to forward to every workflow subagent as its
|
|
62
|
+
* default thinking level (xhigh when ultracode is on, so each subagent's own
|
|
63
|
+
* session clamps it to that subagent model's max; undefined when off). Lets
|
|
64
|
+
* subagents inherit the parent's ultracode effort instead of falling back to
|
|
65
|
+
* the session default. A per-call `model: "X:level"` suffix or an agentType
|
|
66
|
+
* `thinking:` override still takes precedence. */
|
|
67
|
+
getThinkingLevel?: () => ThinkingLevel | undefined;
|
|
68
|
+
/** Test seam: inject a subagent runner so the tool path can run without a model. */
|
|
69
|
+
testRunner?: { run: (call: any) => Promise<any> };
|
|
70
|
+
/** Test seam: override the workflow runtime (lets tests capture the options,
|
|
71
|
+
* including the forwarded thinkingLevel, without spinning up real subagents). */
|
|
72
|
+
runWorkflowFn?: typeof runWorkflow;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let runCounter = 0;
|
|
76
|
+
|
|
77
|
+
/** Max chars of live subagent text retained per agent for the inspect view. */
|
|
78
|
+
const STREAM_TAIL_MAX = 240;
|
|
79
|
+
/** Throttle: min ms between activity-driven re-renders (avoids token-by-token
|
|
80
|
+
* re-render storms when many subagents stream concurrently). */
|
|
81
|
+
const ACTIVITY_RENDER_INTERVAL_MS = 200;
|
|
82
|
+
|
|
83
|
+
function nextRunId(): string {
|
|
84
|
+
runCounter += 1;
|
|
85
|
+
return `wf_${Date.now().toString(36)}-${runCounter.toString(36)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
89
|
+
return defineTool({
|
|
90
|
+
name: "workflow",
|
|
91
|
+
label: "Workflow",
|
|
92
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
93
|
+
promptSnippet: WORKFLOW_PROMPT_SNIPPET,
|
|
94
|
+
promptGuidelines: WORKFLOW_GUIDELINES,
|
|
95
|
+
parameters: workflowToolSchema,
|
|
96
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
97
|
+
const cwd = ctx.cwd;
|
|
98
|
+
const { script, sourceLabel } = resolveScript(params, cwd);
|
|
99
|
+
const parsed = parseWorkflowScript(script);
|
|
100
|
+
|
|
101
|
+
const runsDir = runsDirFor(ctx);
|
|
102
|
+
const runId = params.resumeFromRunId?.trim() || nextRunId();
|
|
103
|
+
const budgetTotal = params.budget ?? deps.getDefaultBudget?.() ?? null;
|
|
104
|
+
// Forward the RAW ultracode effort level (xhigh) so each subagent's own
|
|
105
|
+
// createAgentSession clamps it to THAT subagent model's max — mirroring the
|
|
106
|
+
// parent's "request xhigh, clamp per model" contract. Undefined when off.
|
|
107
|
+
const thinkingLevel = deps.getThinkingLevel?.();
|
|
108
|
+
const run = deps.runWorkflowFn ?? runWorkflow;
|
|
109
|
+
|
|
110
|
+
// Persist the script next to the session for resume / inspection.
|
|
111
|
+
const scriptPath = path.join(runsDir, `${runId}.workflow.js`);
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
114
|
+
fs.writeFileSync(scriptPath, script);
|
|
115
|
+
} catch {
|
|
116
|
+
// non-fatal
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Journal (create new, or resume an existing run id).
|
|
120
|
+
const journalMeta = {
|
|
121
|
+
type: "run" as const,
|
|
122
|
+
runId,
|
|
123
|
+
name: parsed.meta.name,
|
|
124
|
+
scriptHash: hashString(script),
|
|
125
|
+
args: params.args,
|
|
126
|
+
startedAt: Date.now(),
|
|
127
|
+
};
|
|
128
|
+
const resuming = Boolean(params.resumeFromRunId) && RunJournal.exists(runsDir, runId);
|
|
129
|
+
let journal: RunJournal | undefined;
|
|
130
|
+
try {
|
|
131
|
+
journal = resuming
|
|
132
|
+
? RunJournal.resume(runsDir, runId, journalMeta)
|
|
133
|
+
: RunJournal.create(runsDir, journalMeta);
|
|
134
|
+
} catch {
|
|
135
|
+
journal = undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Snapshot + registry + abort plumbing.
|
|
139
|
+
let snapshot = createSnapshot(parsed.meta, runId, budgetTotal);
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const onOuterAbort = () => controller.abort();
|
|
142
|
+
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
143
|
+
const handle = getRegistry().register(runId, snapshot, () => controller.abort());
|
|
144
|
+
|
|
145
|
+
const update = () => {
|
|
146
|
+
snapshot = recompute(snapshot);
|
|
147
|
+
handle.snapshot = snapshot;
|
|
148
|
+
onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Throttle state for activity-driven re-renders. Agent fields below are
|
|
152
|
+
// mutated on every activity tick; only the TUI re-render is throttled, so
|
|
153
|
+
// `/workflows <runId>` still reads fully-live fields between renders.
|
|
154
|
+
let lastActivityRenderMs = 0;
|
|
155
|
+
|
|
156
|
+
const recordPhase = (title?: string) => {
|
|
157
|
+
if (title && !snapshot.phases.includes(title)) snapshot.phases.push(title);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Heartbeat: keep elapsed/idle markers live in the compact panel even when a
|
|
161
|
+
// subagent emits no events (so a silently-stuck agent actually shows "⚠ idle").
|
|
162
|
+
const heartbeat = setInterval(() => update(), 1000);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = await run(script, {
|
|
166
|
+
cwd,
|
|
167
|
+
args: params.args,
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
tokenBudget: budgetTotal,
|
|
170
|
+
thinkingLevel,
|
|
171
|
+
modelRegistry: ctx.modelRegistry as any,
|
|
172
|
+
model: ctx.model as any,
|
|
173
|
+
runner: deps.testRunner,
|
|
174
|
+
journal,
|
|
175
|
+
onLog(message) {
|
|
176
|
+
snapshot.logs.push(message);
|
|
177
|
+
update();
|
|
178
|
+
},
|
|
179
|
+
onPhase(title) {
|
|
180
|
+
snapshot.currentPhase = title;
|
|
181
|
+
recordPhase(title);
|
|
182
|
+
update();
|
|
183
|
+
},
|
|
184
|
+
onAgentStart(event) {
|
|
185
|
+
recordPhase(event.phase);
|
|
186
|
+
const startedAt = Date.now();
|
|
187
|
+
snapshot.agents.push({
|
|
188
|
+
id: event.id,
|
|
189
|
+
label: event.label,
|
|
190
|
+
phase: event.phase,
|
|
191
|
+
status: event.cached ? "cached" : "running",
|
|
192
|
+
startedAt,
|
|
193
|
+
lastActivityAt: startedAt,
|
|
194
|
+
});
|
|
195
|
+
update();
|
|
196
|
+
},
|
|
197
|
+
onAgentEnd(event) {
|
|
198
|
+
const agent = snapshot.agents.find((a) => a.id === event.id);
|
|
199
|
+
if (agent) {
|
|
200
|
+
if (agent.status !== "cached") agent.status = event.status;
|
|
201
|
+
agent.resultPreview = preview(event.result);
|
|
202
|
+
if (event.status === "error") agent.error = preview(event.result);
|
|
203
|
+
const endedAt = Date.now();
|
|
204
|
+
agent.endedAt = endedAt;
|
|
205
|
+
if (agent.startedAt != null) agent.durationMs = endedAt - agent.startedAt;
|
|
206
|
+
}
|
|
207
|
+
update();
|
|
208
|
+
},
|
|
209
|
+
onAgentActivity(event) {
|
|
210
|
+
const agent = snapshot.agents.find((a) => a.id === event.id);
|
|
211
|
+
if (!agent) return;
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
agent.lastActivityAt = now;
|
|
214
|
+
agent.activity = event.kind === "tool" ? (event.detail ?? "tool") : event.kind;
|
|
215
|
+
if (event.kind === "text" && event.detail) {
|
|
216
|
+
const tail = (agent.streamTail ?? "") + event.detail;
|
|
217
|
+
agent.streamTail = tail.length > STREAM_TAIL_MAX ? tail.slice(-STREAM_TAIL_MAX) : tail;
|
|
218
|
+
}
|
|
219
|
+
if (now - lastActivityRenderMs >= ACTIVITY_RENDER_INTERVAL_MS) {
|
|
220
|
+
lastActivityRenderMs = now;
|
|
221
|
+
update();
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
snapshot.result = result.result;
|
|
227
|
+
snapshot.spentTokens = result.spentTokens;
|
|
228
|
+
snapshot.durationMs = result.durationMs;
|
|
229
|
+
snapshot.status = "completed";
|
|
230
|
+
snapshot = recompute(snapshot);
|
|
231
|
+
handle.snapshot = snapshot;
|
|
232
|
+
journal?.recordResult({
|
|
233
|
+
ok: true,
|
|
234
|
+
result: result.result,
|
|
235
|
+
agentCount: result.agentCount,
|
|
236
|
+
durationMs: result.durationMs,
|
|
237
|
+
});
|
|
238
|
+
onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
|
|
239
|
+
|
|
240
|
+
ctx.ui?.notify(
|
|
241
|
+
`Workflow ${result.meta.name} completed: ${result.agentCount} agent(s), ~${result.spentTokens} output tokens.`,
|
|
242
|
+
"info",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const cachedNote = result.cachedCount ? ` (${result.cachedCount} cached from resume)` : "";
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text",
|
|
250
|
+
text:
|
|
251
|
+
`Workflow ${result.meta.name} completed: ${result.agentCount} agent(s)${cachedNote}, ` +
|
|
252
|
+
`~${result.spentTokens} output tokens, ${Math.round(result.durationMs)}ms.\n` +
|
|
253
|
+
`runId: ${runId} (script: ${scriptPath})\n\n` +
|
|
254
|
+
`Result:\n${safeJson(result.result)}`,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
details: { ...snapshot, runId, scriptPath, source: sourceLabel },
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const aborted = controller.signal.aborted || isAbortError(error);
|
|
261
|
+
for (const agent of snapshot.agents) {
|
|
262
|
+
if (agent.status === "running") {
|
|
263
|
+
agent.status = "skipped";
|
|
264
|
+
agent.error = "aborted";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
snapshot.status = aborted ? "aborted" : "failed";
|
|
268
|
+
snapshot = recompute(snapshot);
|
|
269
|
+
handle.snapshot = snapshot;
|
|
270
|
+
journal?.recordResult({
|
|
271
|
+
ok: false,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
agentCount: snapshot.agentCount,
|
|
274
|
+
durationMs: snapshot.durationMs ?? 0,
|
|
275
|
+
});
|
|
276
|
+
onUpdate?.({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
|
|
277
|
+
ctx.ui?.notify(
|
|
278
|
+
`Workflow ${parsed.meta.name} ${aborted ? "was aborted" : "failed"}${aborted ? "" : `: ${error instanceof Error ? error.message : String(error)}`}`,
|
|
279
|
+
aborted ? "warning" : "error",
|
|
280
|
+
);
|
|
281
|
+
if (aborted) throw new Error(`Workflow ${parsed.meta.name} was aborted (runId: ${runId})`);
|
|
282
|
+
throw error;
|
|
283
|
+
} finally {
|
|
284
|
+
clearInterval(heartbeat);
|
|
285
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
286
|
+
journal?.close();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
renderCall(_args, theme) {
|
|
290
|
+
return new Text(theme.fg("toolTitle", theme.bold("workflow")), 0, 0);
|
|
291
|
+
},
|
|
292
|
+
renderResult(result, { isPartial }, theme) {
|
|
293
|
+
const snapshot = result.details as WorkflowSnapshot | undefined;
|
|
294
|
+
if (snapshot?.name) {
|
|
295
|
+
return new Text(renderWorkflowText(snapshot, { showResultPreviews: !isPartial }), 0, 0);
|
|
296
|
+
}
|
|
297
|
+
const text = result.content?.[0];
|
|
298
|
+
return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function resolveScript(
|
|
304
|
+
params: { script?: string; scriptPath?: string; name?: string },
|
|
305
|
+
cwd: string,
|
|
306
|
+
): { script: string; sourceLabel: string } {
|
|
307
|
+
if (params.script && params.script.trim()) {
|
|
308
|
+
return { script: normalizeScript(params.script), sourceLabel: "inline" };
|
|
309
|
+
}
|
|
310
|
+
if (params.scriptPath) {
|
|
311
|
+
const full = path.isAbsolute(params.scriptPath) ? params.scriptPath : path.join(cwd, params.scriptPath);
|
|
312
|
+
return { script: fs.readFileSync(full, "utf8"), sourceLabel: `scriptPath:${params.scriptPath}` };
|
|
313
|
+
}
|
|
314
|
+
if (params.name) {
|
|
315
|
+
const dirs = [
|
|
316
|
+
path.join(cwd, ".pi", "ultracode", "workflows"),
|
|
317
|
+
path.join(os.homedir(), ".pi", "ultracode", "workflows"),
|
|
318
|
+
];
|
|
319
|
+
for (const dir of dirs) {
|
|
320
|
+
for (const candidate of [`${params.name}.workflow.js`, `${params.name}.js`]) {
|
|
321
|
+
const full = path.join(dir, candidate);
|
|
322
|
+
if (fs.existsSync(full)) return { script: fs.readFileSync(full, "utf8"), sourceLabel: `name:${params.name}` };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`workflow: no saved workflow named "${params.name}" found under .pi/ultracode/workflows/`);
|
|
326
|
+
}
|
|
327
|
+
throw new Error("workflow requires one of: `script`, `scriptPath`, or `name`.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function runsDirFor(ctx: { sessionManager?: { getSessionDir?: () => string }; cwd: string }): string {
|
|
331
|
+
try {
|
|
332
|
+
const sessionDir = ctx.sessionManager?.getSessionDir?.();
|
|
333
|
+
if (sessionDir) return path.join(sessionDir, "ultracode-runs");
|
|
334
|
+
} catch {
|
|
335
|
+
// fall through
|
|
336
|
+
}
|
|
337
|
+
return path.join(ctx.cwd, ".pi", "ultracode-runs");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function safeJson(value: unknown): string {
|
|
341
|
+
try {
|
|
342
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
343
|
+
} catch {
|
|
344
|
+
return String(value);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isAbortError(error: unknown): boolean {
|
|
349
|
+
return error instanceof Error && /\babort(?:ed)?\b/i.test(error.message);
|
|
350
|
+
}
|