@pify/workflow 0.3.0 → 0.4.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 +4 -0
- package/extensions/workflow.ts +97 -8
- package/package.json +1 -1
- package/src/resume.ts +86 -0
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -34,6 +34,10 @@ const high = review.findings.filter((f) => f.severity === "high"); // a real a
|
|
|
34
34
|
|
|
35
35
|
The supported subset is the part of JSON Schema workflow authors actually write — `type` (incl. `integer`/`null`), `properties`, `required`, `items`, `enum`, `minItems`/`maxItems`, `minimum`/`maximum`, `minLength`/`maxLength`. Keywords outside it are ignored rather than rejected, so a richer schema still works, just with less checking.
|
|
36
36
|
|
|
37
|
+
- **Resume** (v0.4): `workflow_run({ script, resumeFromRunId: "w3" })` replays the previous run's agent results for as long as the calls match — same prompt, same options, same position — and runs live from the first difference onward. Editing the last stage of a five-stage workflow costs one stage, not five.
|
|
38
|
+
|
|
39
|
+
It is a prefix, not a lookup table, and that is deliberate: a workflow's later prompts are built from earlier results, so once one step's answer changes, every downstream call is potentially different even when its text happens to match. Only calls that finished with a recorded result are reusable; a failed or aborted step always runs again. Runs are replayed from the session file, so a resume still works after `/reload`.
|
|
40
|
+
|
|
37
41
|
- **Globals**: `agent(prompt, {agent?, label?, phase?, gate?, isolation?, schema?})` → child's report, structured object, or `null`; `parallel(thunks)` (barrier, failures → null); `pipeline(items, ...stages)` (no barrier between stages); `phase(title)`; `log(msg)`; `args`. The script's return value is the tool result.
|
|
38
42
|
- **Determinism enforced** in a poisoned `node:vm` context: `Date.now()`, `Math.random()`, argless `new Date()`, `eval`, and `Function` throw — control flow stays reproducible. (Cooperative discipline, not a security boundary: scripts run at the same trust level as the bash tool.)
|
|
39
43
|
- **One agent catalog**: `agent()` uses the same `reviewer`/`scout`/`worker` builtins and `.pi/agents/*.md` custom types as [`@pify/subagent`](https://github.com/pifydev/subagent) and [`@pify/swarm`](https://github.com/pifydev/swarm).
|
package/extensions/workflow.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
type AgentDef,
|
|
46
46
|
type WorkflowRun,
|
|
47
47
|
} from "../src/types.ts";
|
|
48
|
+
import { ResumeCursor, buildCache, callKey, resumeSummary } from "../src/resume.ts";
|
|
48
49
|
|
|
49
50
|
const RUN_ENTRY = "workflow-run";
|
|
50
51
|
const FALLBACK_AGENT = "scout";
|
|
@@ -146,7 +147,52 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
146
147
|
if (next) next();
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* One agent call, with the resume cache in front of it. The cursor hands
|
|
152
|
+
* back the prior run's result while the calls still match; the first
|
|
153
|
+
* difference ends the cache and everything after it runs live.
|
|
154
|
+
*/
|
|
149
155
|
async function runChildAgent(
|
|
156
|
+
ctx: UiContext,
|
|
157
|
+
run: WorkflowRun,
|
|
158
|
+
cursor: ResumeCursor | null,
|
|
159
|
+
prompt: string,
|
|
160
|
+
opts: AgentOptions | undefined,
|
|
161
|
+
): Promise<unknown> {
|
|
162
|
+
const key = callKey(prompt, opts as Record<string, unknown> | undefined);
|
|
163
|
+
if (cursor) {
|
|
164
|
+
const cached = cursor.next(key);
|
|
165
|
+
if (cached.hit) {
|
|
166
|
+
const def = defs.get((opts?.agent ?? FALLBACK_AGENT).toLowerCase()) ?? defs.get(FALLBACK_AGENT);
|
|
167
|
+
run.agents.push({
|
|
168
|
+
id: run.agents.length + 1,
|
|
169
|
+
label: opts?.label ?? `${def?.name ?? FALLBACK_AGENT}-${run.agents.length + 1}`,
|
|
170
|
+
agent: def?.name ?? FALLBACK_AGENT,
|
|
171
|
+
phase: opts?.phase ?? (run.phases[run.phases.length - 1] ?? null),
|
|
172
|
+
status: "done",
|
|
173
|
+
turns: 0,
|
|
174
|
+
tokens: 0,
|
|
175
|
+
key,
|
|
176
|
+
result: cached.value,
|
|
177
|
+
cached: true,
|
|
178
|
+
});
|
|
179
|
+
renderWidget();
|
|
180
|
+
return cached.value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// The spawner pushes its call state synchronously, so this index is the
|
|
184
|
+
// entry it will use.
|
|
185
|
+
const index = run.agents.length;
|
|
186
|
+
const value = await spawnChildAgent(ctx, run, prompt, opts);
|
|
187
|
+
const call = run.agents[index];
|
|
188
|
+
if (call) {
|
|
189
|
+
call.key = key;
|
|
190
|
+
if (call.status === "done") call.result = value;
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function spawnChildAgent(
|
|
150
196
|
ctx: UiContext,
|
|
151
197
|
run: WorkflowRun,
|
|
152
198
|
prompt: string,
|
|
@@ -327,10 +373,16 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
327
373
|
|
|
328
374
|
// ── Execution ────────────────────────────────────────────────────────
|
|
329
375
|
|
|
330
|
-
async function execute(
|
|
376
|
+
async function execute(
|
|
377
|
+
ctx: UiContext,
|
|
378
|
+
run: WorkflowRun,
|
|
379
|
+
script: string,
|
|
380
|
+
args: unknown,
|
|
381
|
+
cursor: ResumeCursor | null = null,
|
|
382
|
+
): Promise<void> {
|
|
331
383
|
try {
|
|
332
384
|
const value = await runScript(script, args, {
|
|
333
|
-
agent: (prompt, opts) => runChildAgent(ctx, run, prompt, opts),
|
|
385
|
+
agent: (prompt, opts) => runChildAgent(ctx, run, cursor, prompt, opts),
|
|
334
386
|
log: (message) => {
|
|
335
387
|
run.logs.push(message.slice(0, 500));
|
|
336
388
|
renderWidget();
|
|
@@ -382,16 +434,27 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
382
434
|
"agent() extras: gate=shell command run after the child (non-zero exit fails the call); " +
|
|
383
435
|
"isolation=worktree runs the child in its own git worktree for mutating steps; " +
|
|
384
436
|
"schema=<JSON Schema> makes the child answer with data — agent() then resolves the validated object " +
|
|
385
|
-
"(one retry on mismatch, null if it still fails), so scripts never parse prose."
|
|
437
|
+
"(one retry on mismatch, null if it still fails), so scripts never parse prose. " +
|
|
438
|
+
"resumeFromRunId replays a prior run's agent results for as long as the calls match, then runs live — " +
|
|
439
|
+
"edit a script and re-run it without paying for the steps that did not change.",
|
|
386
440
|
parameters: Type.Object({
|
|
387
441
|
script: Type.Optional(Type.String({ description: "JavaScript orchestration script body" })),
|
|
388
442
|
name: Type.Optional(Type.String({ description: "Saved workflow name in .pi/workflows/" })),
|
|
389
443
|
args: Type.Optional(Type.Unknown({ description: "Value exposed to the script as `args`" })),
|
|
390
444
|
background: Type.Optional(Type.Boolean()),
|
|
445
|
+
resumeFromRunId: Type.Optional(
|
|
446
|
+
Type.String({ description: "Reuse a prior run's agent results for the unchanged prefix" }),
|
|
447
|
+
),
|
|
391
448
|
}),
|
|
392
449
|
async execute(
|
|
393
450
|
_id,
|
|
394
|
-
params: {
|
|
451
|
+
params: {
|
|
452
|
+
script?: string;
|
|
453
|
+
name?: string;
|
|
454
|
+
args?: unknown;
|
|
455
|
+
background?: boolean;
|
|
456
|
+
resumeFromRunId?: string;
|
|
457
|
+
},
|
|
395
458
|
_signal,
|
|
396
459
|
_onUpdate,
|
|
397
460
|
ctx,
|
|
@@ -423,9 +486,26 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
423
486
|
}
|
|
424
487
|
if (!script.trim()) throw new Error("workflow requires a script (or a saved name).");
|
|
425
488
|
|
|
489
|
+
// v0.4 resume: rebuild the prior run's reusable prefix.
|
|
490
|
+
let cursor: ResumeCursor | null = null;
|
|
491
|
+
let cacheSize = 0;
|
|
492
|
+
const resumeId = params.resumeFromRunId?.trim();
|
|
493
|
+
if (resumeId) {
|
|
494
|
+
const prior = runs.get(resumeId);
|
|
495
|
+
if (!prior) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`No run "${resumeId}" in this session. Known runs: ${[...runs.keys()].join(", ") || "(none)"}`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const cache = buildCache(prior.agents);
|
|
501
|
+
cacheSize = cache.length;
|
|
502
|
+
cursor = new ResumeCursor(cache);
|
|
503
|
+
}
|
|
504
|
+
|
|
426
505
|
runCounter++;
|
|
427
506
|
const run: WorkflowRun = {
|
|
428
507
|
runId: `w${runCounter}`,
|
|
508
|
+
...(resumeId ? { resumedFrom: resumeId } : {}),
|
|
429
509
|
background: params.background === true,
|
|
430
510
|
status: "running",
|
|
431
511
|
startedAt: Date.now(),
|
|
@@ -440,8 +520,10 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
440
520
|
activeRun = run;
|
|
441
521
|
renderWidget(uiCtx);
|
|
442
522
|
|
|
523
|
+
if (cursor) run.logs.push(`resume: ${cacheSize} cached agent result(s) available from ${resumeId}`);
|
|
524
|
+
|
|
443
525
|
if (run.background) {
|
|
444
|
-
void execute(uiCtx, run, script, params.args).then(() => {
|
|
526
|
+
void execute(uiCtx, run, script, params.args, cursor).then(() => {
|
|
445
527
|
notify(uiCtx, `workflow ${run.runId}: ${run.status}`, run.status === "done" ? "info" : "warning");
|
|
446
528
|
});
|
|
447
529
|
return {
|
|
@@ -450,10 +532,17 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
450
532
|
};
|
|
451
533
|
}
|
|
452
534
|
|
|
453
|
-
await execute(uiCtx, run, script, params.args);
|
|
535
|
+
await execute(uiCtx, run, script, params.args, cursor);
|
|
536
|
+
const resumeNote = cursor ? `
|
|
537
|
+
${resumeSummary(cursor.reused, cacheSize)}` : "";
|
|
454
538
|
return {
|
|
455
|
-
content: [{ type: "text", text: formatResult(run) }],
|
|
456
|
-
details: {
|
|
539
|
+
content: [{ type: "text", text: `${formatResult(run)}${resumeNote}` }],
|
|
540
|
+
details: {
|
|
541
|
+
runId: run.runId,
|
|
542
|
+
status: run.status,
|
|
543
|
+
agents: run.agents.length,
|
|
544
|
+
...(cursor ? { resumedFrom: resumeId, reused: cursor.reused } : {}),
|
|
545
|
+
},
|
|
457
546
|
};
|
|
458
547
|
},
|
|
459
548
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Deterministic multi-step agent orchestration for pi: a Claude Code-style workflow tool with agent()/parallel()/pipeline() scripts over the shared agent catalog",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume: re-run a workflow and reuse the previous run's agent results for
|
|
3
|
+
* the part of the script that did not change.
|
|
4
|
+
*
|
|
5
|
+
* The rule is a PREFIX, not a lookup table. Agent calls are cached by their
|
|
6
|
+
* position in the run and the exact (prompt, options) they were made with;
|
|
7
|
+
* the first call that differs ends the cache for the rest of the run. That
|
|
8
|
+
* is the only version of this that stays correct: a workflow's later calls
|
|
9
|
+
* are built from earlier results, so once one step's answer changes, every
|
|
10
|
+
* downstream prompt is potentially different even when its text happens to
|
|
11
|
+
* match.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentCallState } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export interface CachedCall {
|
|
16
|
+
key: string;
|
|
17
|
+
result: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable key for one agent call: same prompt, same options, same key. */
|
|
21
|
+
export function callKey(prompt: string, opts: Record<string, unknown> | undefined): string {
|
|
22
|
+
const normalized: Record<string, unknown> = {};
|
|
23
|
+
for (const name of Object.keys(opts ?? {}).sort()) {
|
|
24
|
+
const value = (opts as Record<string, unknown>)[name];
|
|
25
|
+
if (value !== undefined) normalized[name] = value;
|
|
26
|
+
}
|
|
27
|
+
return JSON.stringify([prompt, normalized]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The reusable calls from a finished run, in the order they were made. */
|
|
31
|
+
export function buildCache(agents: readonly AgentCallState[]): CachedCall[] {
|
|
32
|
+
const cache: CachedCall[] = [];
|
|
33
|
+
for (const call of agents) {
|
|
34
|
+
// Only calls that finished with a recorded result can be replayed; a
|
|
35
|
+
// failed or aborted step must run again.
|
|
36
|
+
if (call.status !== "done" || typeof call.key !== "string" || call.result === undefined) break;
|
|
37
|
+
cache.push({ key: call.key, result: call.result });
|
|
38
|
+
}
|
|
39
|
+
return cache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CacheHit {
|
|
43
|
+
hit: boolean;
|
|
44
|
+
value?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Track how far the cache still matches. Once `broken` is set, every
|
|
49
|
+
* subsequent call runs live regardless of what it looks like.
|
|
50
|
+
*/
|
|
51
|
+
export class ResumeCursor {
|
|
52
|
+
private index = 0;
|
|
53
|
+
private broken = false;
|
|
54
|
+
private readonly cache: readonly CachedCall[];
|
|
55
|
+
|
|
56
|
+
constructor(cache: readonly CachedCall[]) {
|
|
57
|
+
this.cache = cache;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get reused(): number {
|
|
61
|
+
return this.index;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get exhausted(): boolean {
|
|
65
|
+
return this.broken || this.index >= this.cache.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Consume the next cached result if this call is the same one. */
|
|
69
|
+
next(key: string): CacheHit {
|
|
70
|
+
if (this.broken) return { hit: false };
|
|
71
|
+
const entry = this.cache[this.index];
|
|
72
|
+
if (!entry || entry.key !== key) {
|
|
73
|
+
this.broken = true;
|
|
74
|
+
return { hit: false };
|
|
75
|
+
}
|
|
76
|
+
this.index++;
|
|
77
|
+
return { hit: true, value: entry.result };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resumeSummary(reused: number, total: number): string {
|
|
82
|
+
if (total === 0) return "nothing to resume from — the prior run recorded no reusable agent results";
|
|
83
|
+
if (reused === 0) return `resumed from 0 of ${total} cached results (the first call already differs)`;
|
|
84
|
+
if (reused === total) return `reused all ${total} cached results, then continued live`;
|
|
85
|
+
return `reused ${reused} of ${total} cached results, then continued live`;
|
|
86
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -59,12 +59,20 @@ export interface AgentCallState {
|
|
|
59
59
|
status: AgentCallStatus;
|
|
60
60
|
turns: number;
|
|
61
61
|
tokens: number;
|
|
62
|
+
/** Identity of the call (prompt + options), for resume (v0.4). */
|
|
63
|
+
key?: string;
|
|
64
|
+
/** What the call returned, replayed on resume. */
|
|
65
|
+
result?: unknown;
|
|
66
|
+
/** True when this result came from a prior run instead of a model. */
|
|
67
|
+
cached?: boolean;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export type RunStatus = "running" | "done" | "error";
|
|
65
71
|
|
|
66
72
|
export interface WorkflowRun {
|
|
67
73
|
runId: string;
|
|
74
|
+
/** Run this one resumed from, when it did (v0.4). */
|
|
75
|
+
resumedFrom?: string;
|
|
68
76
|
background: boolean;
|
|
69
77
|
status: RunStatus;
|
|
70
78
|
startedAt: number;
|