@pify/workflow 0.2.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 +18 -1
- package/extensions/workflow.ts +157 -24
- package/package.json +1 -1
- package/src/resume.ts +86 -0
- package/src/sandbox.ts +8 -3
- package/src/schema.ts +156 -0
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -21,7 +21,24 @@ return { findings: verified.filter(Boolean) };
|
|
|
21
21
|
|
|
22
22
|
## The contract
|
|
23
23
|
|
|
24
|
-
- **
|
|
24
|
+
- **Structured output** (v0.3): `agent(prompt, { schema })` makes the child answer with data and resolves the **validated object** instead of prose — no more parsing reports in the script. A mismatch buys exactly one retry, with the validation errors handed back to the child; if it still fails, the call returns `null` like any other failure. This is load-bearing rather than decorative: in a live run against GPT-5.6 the first answer was prose and the retry produced a clean object.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const REVIEW = { type: "object", required: ["findings"], properties: {
|
|
28
|
+
findings: { type: "array", maxItems: 3, items: { type: "object",
|
|
29
|
+
required: ["file", "severity"],
|
|
30
|
+
properties: { file: { type: "string" }, severity: { type: "string", enum: ["low", "high"] } } } } } };
|
|
31
|
+
const review = await agent("Review src/auth for security issues.", { schema: REVIEW });
|
|
32
|
+
const high = review.findings.filter((f) => f.severity === "high"); // a real array
|
|
33
|
+
```
|
|
34
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
25
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.)
|
|
26
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).
|
|
27
44
|
- **Limits**: 20 agents per run, 4 concurrent (shared semaphore), 10-minute script timeout.
|
package/extensions/workflow.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { createIsolationWorktree, isolationNote, type Isolation } from "../src/i
|
|
|
36
36
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
37
37
|
import { buildWidgetLines, formatResult, formatStatus } from "../src/report.ts";
|
|
38
38
|
import { runScript, type AgentOptions } from "../src/sandbox.ts";
|
|
39
|
+
import { readStructured, retryPrompt, schemaInstruction } from "../src/schema.ts";
|
|
39
40
|
import {
|
|
40
41
|
AGENT_CONCURRENCY,
|
|
41
42
|
MAX_PERSISTED_RESULT_CHARS,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
type AgentDef,
|
|
45
46
|
type WorkflowRun,
|
|
46
47
|
} from "../src/types.ts";
|
|
48
|
+
import { ResumeCursor, buildCache, callKey, resumeSummary } from "../src/resume.ts";
|
|
47
49
|
|
|
48
50
|
const RUN_ENTRY = "workflow-run";
|
|
49
51
|
const FALLBACK_AGENT = "scout";
|
|
@@ -145,12 +147,57 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
145
147
|
if (next) next();
|
|
146
148
|
}
|
|
147
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
|
+
*/
|
|
148
155
|
async function runChildAgent(
|
|
149
156
|
ctx: UiContext,
|
|
150
157
|
run: WorkflowRun,
|
|
158
|
+
cursor: ResumeCursor | null,
|
|
151
159
|
prompt: string,
|
|
152
160
|
opts: AgentOptions | undefined,
|
|
153
|
-
): Promise<
|
|
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(
|
|
196
|
+
ctx: UiContext,
|
|
197
|
+
run: WorkflowRun,
|
|
198
|
+
prompt: string,
|
|
199
|
+
opts: AgentOptions | undefined,
|
|
200
|
+
): Promise<unknown> {
|
|
154
201
|
const def = defs.get((opts?.agent ?? FALLBACK_AGENT).toLowerCase()) ?? defs.get(FALLBACK_AGENT);
|
|
155
202
|
if (!def) return null;
|
|
156
203
|
|
|
@@ -207,6 +254,7 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
207
254
|
...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
|
|
208
255
|
def.systemPrompt,
|
|
209
256
|
"You are one step of a scripted workflow. Your final assistant message IS the value returned to the script — return raw data/report, no pleasantries, no questions.",
|
|
257
|
+
...(opts?.schema ? [schemaInstruction(opts.schema)] : []),
|
|
210
258
|
],
|
|
211
259
|
}),
|
|
212
260
|
});
|
|
@@ -224,27 +272,67 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
224
272
|
|
|
225
273
|
await session.prompt(prompt, { source: "extension" } as never);
|
|
226
274
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
275
|
+
/** Text of the newest assistant message, with its stop reason. */
|
|
276
|
+
const lastAnswer = () => {
|
|
277
|
+
const messages = session!.messages as Array<{
|
|
278
|
+
role?: string;
|
|
279
|
+
stopReason?: unknown;
|
|
280
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
281
|
+
}>;
|
|
282
|
+
const last = [...messages].reverse().find((m) => m.role === "assistant");
|
|
283
|
+
return {
|
|
284
|
+
stopReason: last?.stopReason,
|
|
285
|
+
text: (last?.content ?? [])
|
|
286
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
287
|
+
.map((c) => c.text)
|
|
288
|
+
.join("\n")
|
|
289
|
+
.trim(),
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
let { stopReason, text } = lastAnswer();
|
|
294
|
+
|
|
295
|
+
if (stopReason === "aborted") {
|
|
240
296
|
call.status = "aborted";
|
|
241
297
|
return text || null;
|
|
242
298
|
}
|
|
243
|
-
if (
|
|
299
|
+
if (stopReason === "error" || !text) {
|
|
244
300
|
call.status = "error";
|
|
245
301
|
return null;
|
|
246
302
|
}
|
|
247
303
|
|
|
304
|
+
// v0.3 schema: the script asked for data, so hand it data. One retry
|
|
305
|
+
// with the validation errors — models fix their own shape far more
|
|
306
|
+
// reliably than a second model can guess what was meant.
|
|
307
|
+
if (opts?.schema) {
|
|
308
|
+
let outcome = readStructured(text, opts.schema);
|
|
309
|
+
if (!outcome.ok) {
|
|
310
|
+
run.logs.push(`${call.label}: schema mismatch, retrying (${outcome.errors[0] ?? "invalid"})`);
|
|
311
|
+
renderWidget();
|
|
312
|
+
await session.prompt(retryPrompt(outcome.errors, opts.schema), { source: "extension" } as never);
|
|
313
|
+
({ text } = lastAnswer());
|
|
314
|
+
outcome = readStructured(text, opts.schema);
|
|
315
|
+
}
|
|
316
|
+
if (!outcome.ok) {
|
|
317
|
+
call.status = "error";
|
|
318
|
+
run.logs.push(`${call.label}: schema still unmet — ${outcome.errors.slice(0, 2).join("; ")}`);
|
|
319
|
+
renderWidget();
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
if (opts.gate) {
|
|
323
|
+
const gate = runGate(opts.gate, workDir);
|
|
324
|
+
if (!gate.ok) {
|
|
325
|
+
call.status = "error";
|
|
326
|
+
run.logs.push(`gate failed for ${call.label}: ${gate.output.slice(0, 200)}`);
|
|
327
|
+
renderWidget();
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
call.status = "done";
|
|
332
|
+
// Structured results cross the vm boundary as plain data.
|
|
333
|
+
return JSON.parse(JSON.stringify(outcome.value)) as unknown;
|
|
334
|
+
}
|
|
335
|
+
|
|
248
336
|
// v0.2 gate: verify the child's work by running a command instead of
|
|
249
337
|
// asking another model (tintinweb). Non-zero exit fails the call.
|
|
250
338
|
if (opts?.gate) {
|
|
@@ -285,10 +373,16 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
285
373
|
|
|
286
374
|
// ── Execution ────────────────────────────────────────────────────────
|
|
287
375
|
|
|
288
|
-
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> {
|
|
289
383
|
try {
|
|
290
384
|
const value = await runScript(script, args, {
|
|
291
|
-
agent: (prompt, opts) => runChildAgent(ctx, run, prompt, opts),
|
|
385
|
+
agent: (prompt, opts) => runChildAgent(ctx, run, cursor, prompt, opts),
|
|
292
386
|
log: (message) => {
|
|
293
387
|
run.logs.push(message.slice(0, 500));
|
|
294
388
|
renderWidget();
|
|
@@ -331,23 +425,36 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
331
425
|
label: "Run workflow",
|
|
332
426
|
description:
|
|
333
427
|
"Run a deterministic JavaScript orchestration script that fans work out across child agents. " +
|
|
334
|
-
"Globals: agent(prompt, {agent?, label?, phase?, gate?, isolation?}) -> Promise<string|null> (agent types: " +
|
|
428
|
+
"Globals: agent(prompt, {agent?, label?, phase?, gate?, isolation?, schema?}) -> Promise<string|object|null> (agent types: " +
|
|
335
429
|
"reviewer/scout/worker + .pi/agents custom; write prompts as self-contained briefs); " +
|
|
336
430
|
"parallel(thunks) (barrier, failures resolve null); pipeline(items, ...stages) (no barrier " +
|
|
337
431
|
"between stages); phase(title); log(msg); args. The script's return value is the tool result. " +
|
|
338
432
|
"Date.now()/Math.random()/eval throw (determinism). Provide script XOR name " +
|
|
339
433
|
"(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status. " +
|
|
340
434
|
"agent() extras: gate=shell command run after the child (non-zero exit fails the call); " +
|
|
341
|
-
"isolation=worktree runs the child in its own git worktree for mutating steps
|
|
435
|
+
"isolation=worktree runs the child in its own git worktree for mutating steps; " +
|
|
436
|
+
"schema=<JSON Schema> makes the child answer with data — agent() then resolves the validated object " +
|
|
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.",
|
|
342
440
|
parameters: Type.Object({
|
|
343
441
|
script: Type.Optional(Type.String({ description: "JavaScript orchestration script body" })),
|
|
344
442
|
name: Type.Optional(Type.String({ description: "Saved workflow name in .pi/workflows/" })),
|
|
345
443
|
args: Type.Optional(Type.Unknown({ description: "Value exposed to the script as `args`" })),
|
|
346
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
|
+
),
|
|
347
448
|
}),
|
|
348
449
|
async execute(
|
|
349
450
|
_id,
|
|
350
|
-
params: {
|
|
451
|
+
params: {
|
|
452
|
+
script?: string;
|
|
453
|
+
name?: string;
|
|
454
|
+
args?: unknown;
|
|
455
|
+
background?: boolean;
|
|
456
|
+
resumeFromRunId?: string;
|
|
457
|
+
},
|
|
351
458
|
_signal,
|
|
352
459
|
_onUpdate,
|
|
353
460
|
ctx,
|
|
@@ -379,9 +486,26 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
379
486
|
}
|
|
380
487
|
if (!script.trim()) throw new Error("workflow requires a script (or a saved name).");
|
|
381
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
|
+
|
|
382
505
|
runCounter++;
|
|
383
506
|
const run: WorkflowRun = {
|
|
384
507
|
runId: `w${runCounter}`,
|
|
508
|
+
...(resumeId ? { resumedFrom: resumeId } : {}),
|
|
385
509
|
background: params.background === true,
|
|
386
510
|
status: "running",
|
|
387
511
|
startedAt: Date.now(),
|
|
@@ -396,8 +520,10 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
396
520
|
activeRun = run;
|
|
397
521
|
renderWidget(uiCtx);
|
|
398
522
|
|
|
523
|
+
if (cursor) run.logs.push(`resume: ${cacheSize} cached agent result(s) available from ${resumeId}`);
|
|
524
|
+
|
|
399
525
|
if (run.background) {
|
|
400
|
-
void execute(uiCtx, run, script, params.args).then(() => {
|
|
526
|
+
void execute(uiCtx, run, script, params.args, cursor).then(() => {
|
|
401
527
|
notify(uiCtx, `workflow ${run.runId}: ${run.status}`, run.status === "done" ? "info" : "warning");
|
|
402
528
|
});
|
|
403
529
|
return {
|
|
@@ -406,10 +532,17 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
406
532
|
};
|
|
407
533
|
}
|
|
408
534
|
|
|
409
|
-
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)}` : "";
|
|
410
538
|
return {
|
|
411
|
-
content: [{ type: "text", text: formatResult(run) }],
|
|
412
|
-
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
|
+
},
|
|
413
546
|
};
|
|
414
547
|
},
|
|
415
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/sandbox.ts
CHANGED
|
@@ -19,11 +19,16 @@ export interface AgentOptions {
|
|
|
19
19
|
gate?: string;
|
|
20
20
|
/** "worktree": run the child in an isolated git worktree (v0.2). */
|
|
21
21
|
isolation?: string;
|
|
22
|
+
/** JSON Schema: the child answers with data, agent() resolves an object (v0.3). */
|
|
23
|
+
schema?: Record<string, unknown>;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface SandboxHooks {
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Spawn one child agent. Resolves to its report text, or — when a schema
|
|
29
|
+
* was given — the validated object; null on failure.
|
|
30
|
+
*/
|
|
31
|
+
agent(prompt: string, opts?: AgentOptions): Promise<unknown>;
|
|
27
32
|
log(message: string): void;
|
|
28
33
|
phase(title: string): void;
|
|
29
34
|
}
|
|
@@ -79,7 +84,7 @@ export async function runScript(
|
|
|
79
84
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
80
85
|
let agentCalls = 0;
|
|
81
86
|
|
|
82
|
-
const agent = (prompt: unknown, opts?: AgentOptions): Promise<
|
|
87
|
+
const agent = (prompt: unknown, opts?: AgentOptions): Promise<unknown> => {
|
|
83
88
|
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
84
89
|
throw new Error("agent() requires a non-empty prompt string.");
|
|
85
90
|
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured output for agent() (v0.3). A script that wants data back
|
|
3
|
+
* currently gets prose and has to parse it — every caller reinventing the
|
|
4
|
+
* same brittle extraction. With `schema`, the child is told to answer with
|
|
5
|
+
* one JSON object, the answer is validated here, and the script receives a
|
|
6
|
+
* real object.
|
|
7
|
+
*
|
|
8
|
+
* The validator is a deliberate subset of JSON Schema: the keywords a
|
|
9
|
+
* workflow author actually writes (type, properties, required, items, enum,
|
|
10
|
+
* bounds). Unknown keywords are ignored rather than rejected — a schema that
|
|
11
|
+
* says more than we understand should still work, just with less checking.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type JsonSchema = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export function schemaInstruction(schema: JsonSchema): string {
|
|
17
|
+
return [
|
|
18
|
+
"Your entire final message must be ONE JSON object matching this schema, and nothing else:",
|
|
19
|
+
JSON.stringify(schema),
|
|
20
|
+
"No prose before or after it, no markdown fence, no explanation.",
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pull a JSON value out of an answer that may be fenced or padded with prose. */
|
|
25
|
+
export function extractJson(text: string): unknown {
|
|
26
|
+
const trimmed = (text ?? "").trim();
|
|
27
|
+
if (!trimmed) return undefined;
|
|
28
|
+
|
|
29
|
+
const candidates: string[] = [];
|
|
30
|
+
const fenced = /```(?:json)?\s*\n([\s\S]*?)```/i.exec(trimmed);
|
|
31
|
+
if (fenced) candidates.push(fenced[1]!.trim());
|
|
32
|
+
candidates.push(trimmed);
|
|
33
|
+
|
|
34
|
+
// Last resort: the outermost {...} or [...] span in the message.
|
|
35
|
+
const firstBrace = trimmed.search(/[{[]/);
|
|
36
|
+
const lastBrace = Math.max(trimmed.lastIndexOf("}"), trimmed.lastIndexOf("]"));
|
|
37
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
38
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const candidate of candidates) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(candidate);
|
|
44
|
+
} catch {
|
|
45
|
+
// try the next shape
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function typeOf(value: unknown): string {
|
|
52
|
+
if (value === null) return "null";
|
|
53
|
+
if (Array.isArray(value)) return "array";
|
|
54
|
+
return typeof value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function typeMatches(value: unknown, expected: string): boolean {
|
|
58
|
+
if (expected === "integer") return typeof value === "number" && Number.isInteger(value);
|
|
59
|
+
if (expected === "number") return typeof value === "number" && Number.isFinite(value);
|
|
60
|
+
return typeOf(value) === expected;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a value against the supported subset. Returns human-readable
|
|
65
|
+
* errors (empty array = valid) — they are handed back to the child agent, so
|
|
66
|
+
* they read as instructions rather than as codes.
|
|
67
|
+
*/
|
|
68
|
+
export function validateAgainstSchema(value: unknown, schema: JsonSchema, path = "value"): string[] {
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
if (!schema || typeof schema !== "object") return errors;
|
|
71
|
+
|
|
72
|
+
const expected = schema.type;
|
|
73
|
+
if (typeof expected === "string" && !typeMatches(value, expected)) {
|
|
74
|
+
errors.push(`${path} must be ${expected}, got ${typeOf(value)}`);
|
|
75
|
+
return errors; // everything below assumes the type held
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(expected) && !expected.some((t) => typeof t === "string" && typeMatches(value, t))) {
|
|
78
|
+
errors.push(`${path} must be one of ${expected.join("|")}, got ${typeOf(value)}`);
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((option) => option === value)) {
|
|
83
|
+
errors.push(`${path} must be one of ${schema.enum.map((o) => JSON.stringify(o)).join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeOf(value) === "object") {
|
|
87
|
+
const object = value as Record<string, unknown>;
|
|
88
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
89
|
+
for (const key of required) {
|
|
90
|
+
if (typeof key === "string" && !(key in object)) errors.push(`${path}.${key} is required`);
|
|
91
|
+
}
|
|
92
|
+
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
|
|
93
|
+
for (const [key, sub] of Object.entries(properties)) {
|
|
94
|
+
if (key in object) errors.push(...validateAgainstSchema(object[key], sub, `${path}.${key}`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
100
|
+
errors.push(`${path} needs at least ${schema.minItems} item(s), got ${value.length}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
|
|
103
|
+
errors.push(`${path} allows at most ${schema.maxItems} item(s), got ${value.length}`);
|
|
104
|
+
}
|
|
105
|
+
const items = schema.items as JsonSchema | undefined;
|
|
106
|
+
if (items && typeof items === "object") {
|
|
107
|
+
value.forEach((item, index) => errors.push(...validateAgainstSchema(item, items, `${path}[${index}]`)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof value === "number") {
|
|
112
|
+
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
|
113
|
+
errors.push(`${path} must be >= ${schema.minimum}`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
|
116
|
+
errors.push(`${path} must be <= ${schema.maximum}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
122
|
+
errors.push(`${path} must be at least ${schema.minLength} characters`);
|
|
123
|
+
}
|
|
124
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
|
125
|
+
errors.push(`${path} must be at most ${schema.maxLength} characters`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return errors;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The one retry the child gets: what was wrong, and what to send instead. */
|
|
133
|
+
export function retryPrompt(errors: string[], schema: JsonSchema): string {
|
|
134
|
+
return [
|
|
135
|
+
"That answer did not match the required schema:",
|
|
136
|
+
...errors.slice(0, 8).map((error) => `- ${error}`),
|
|
137
|
+
"",
|
|
138
|
+
schemaInstruction(schema),
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SchemaOutcome {
|
|
143
|
+
ok: boolean;
|
|
144
|
+
value: unknown;
|
|
145
|
+
errors: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Extract + validate in one step, for both the first answer and the retry. */
|
|
149
|
+
export function readStructured(text: string, schema: JsonSchema): SchemaOutcome {
|
|
150
|
+
const value = extractJson(text);
|
|
151
|
+
if (value === undefined) {
|
|
152
|
+
return { ok: false, value: undefined, errors: ["the answer contained no JSON value"] };
|
|
153
|
+
}
|
|
154
|
+
const errors = validateAgainstSchema(value, schema);
|
|
155
|
+
return { ok: errors.length === 0, value, errors };
|
|
156
|
+
}
|
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;
|