@pify/workflow 0.1.1 → 0.3.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 CHANGED
@@ -21,7 +21,20 @@ return { findings: verified.filter(Boolean) };
21
21
 
22
22
  ## The contract
23
23
 
24
- - **Globals**: `agent(prompt, {agent?, label?, phase?})` child's report 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.
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
+ - **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
38
  - **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
39
  - **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
40
  - **Limits**: 20 agents per run, 4 concurrent (shared semaphore), 10-minute script timeout.
@@ -29,10 +29,14 @@ import { Type } from "typebox";
29
29
  import { readFileSync, readdirSync } from "node:fs";
30
30
  import { basename, join } from "node:path";
31
31
 
32
+ import { spawnSync } from "node:child_process";
33
+
32
34
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
35
+ import { createIsolationWorktree, isolationNote, type Isolation } from "../src/isolate.ts";
33
36
  import { parseAgentFile } from "../src/frontmatter.ts";
34
37
  import { buildWidgetLines, formatResult, formatStatus } from "../src/report.ts";
35
38
  import { runScript, type AgentOptions } from "../src/sandbox.ts";
39
+ import { readStructured, retryPrompt, schemaInstruction } from "../src/schema.ts";
36
40
  import {
37
41
  AGENT_CONCURRENCY,
38
42
  MAX_PERSISTED_RESULT_CHARS,
@@ -44,6 +48,28 @@ import {
44
48
 
45
49
  const RUN_ENTRY = "workflow-run";
46
50
  const FALLBACK_AGENT = "scout";
51
+ const GATE_TIMEOUT_MS = 120_000;
52
+
53
+ /**
54
+ * Run a gate command with the shell in the child's working directory.
55
+ * Gate commands come from the workflow script — the same trust level as the
56
+ * bash tool in this session.
57
+ */
58
+ function runGate(command: string, cwd: string): { ok: boolean; output: string } {
59
+ try {
60
+ const result = spawnSync(command, {
61
+ shell: true,
62
+ cwd,
63
+ encoding: "utf8",
64
+ timeout: GATE_TIMEOUT_MS,
65
+ windowsHide: true,
66
+ });
67
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
68
+ return { ok: result.status === 0, output };
69
+ } catch (err) {
70
+ return { ok: false, output: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ }
47
73
 
48
74
  type UiContext = ExtensionContext;
49
75
 
@@ -125,7 +151,7 @@ export default function workflow(pi: ExtensionAPI) {
125
151
  run: WorkflowRun,
126
152
  prompt: string,
127
153
  opts: AgentOptions | undefined,
128
- ): Promise<string | null> {
154
+ ): Promise<unknown> {
129
155
  const def = defs.get((opts?.agent ?? FALLBACK_AGENT).toLowerCase()) ?? defs.get(FALLBACK_AGENT);
130
156
  if (!def) return null;
131
157
 
@@ -154,18 +180,25 @@ export default function workflow(pi: ExtensionAPI) {
154
180
  }
155
181
  if (!model) throw new Error("No model available");
156
182
 
183
+ // v0.2: worktree isolation for mutating steps.
184
+ let isolation: Isolation | null = null;
185
+ if (opts?.isolation === "worktree") {
186
+ isolation = createIsolationWorktree(ctx.cwd, `${run.runId}-${call.label}`);
187
+ }
188
+ const workDir = isolation?.path ?? ctx.cwd;
189
+
157
190
  const promptHost = ctx as unknown as {
158
191
  getSystemPromptOptions?: () => { customPrompt?: string; appendSystemPrompt?: string };
159
192
  };
160
193
  const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
161
194
 
162
195
  const created = await createAgentSession({
163
- sessionManager: SessionManager.inMemory(ctx.cwd),
196
+ sessionManager: SessionManager.inMemory(workDir),
164
197
  model,
165
198
  thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
166
199
  tools: def.tools,
167
200
  resourceLoader: new DefaultResourceLoader({
168
- cwd: ctx.cwd,
201
+ cwd: workDir,
169
202
  agentDir: getAgentDir(),
170
203
  noExtensions: true,
171
204
  noPromptTemplates: true,
@@ -175,6 +208,7 @@ export default function workflow(pi: ExtensionAPI) {
175
208
  ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
176
209
  def.systemPrompt,
177
210
  "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.",
211
+ ...(opts?.schema ? [schemaInstruction(opts.schema)] : []),
178
212
  ],
179
213
  }),
180
214
  });
@@ -192,28 +226,82 @@ export default function workflow(pi: ExtensionAPI) {
192
226
 
193
227
  await session.prompt(prompt, { source: "extension" } as never);
194
228
 
195
- const messages = session.messages as Array<{
196
- role?: string;
197
- stopReason?: unknown;
198
- content?: Array<{ type?: string; text?: string }>;
199
- }>;
200
- const last = [...messages].reverse().find((m) => m.role === "assistant");
201
- const text = (last?.content ?? [])
202
- .filter((c) => c.type === "text" && typeof c.text === "string")
203
- .map((c) => c.text)
204
- .join("\n")
205
- .trim();
206
-
207
- if (last?.stopReason === "aborted") {
229
+ /** Text of the newest assistant message, with its stop reason. */
230
+ const lastAnswer = () => {
231
+ const messages = session!.messages as Array<{
232
+ role?: string;
233
+ stopReason?: unknown;
234
+ content?: Array<{ type?: string; text?: string }>;
235
+ }>;
236
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
237
+ return {
238
+ stopReason: last?.stopReason,
239
+ text: (last?.content ?? [])
240
+ .filter((c) => c.type === "text" && typeof c.text === "string")
241
+ .map((c) => c.text)
242
+ .join("\n")
243
+ .trim(),
244
+ };
245
+ };
246
+
247
+ let { stopReason, text } = lastAnswer();
248
+
249
+ if (stopReason === "aborted") {
208
250
  call.status = "aborted";
209
251
  return text || null;
210
252
  }
211
- if (last?.stopReason === "error" || !text) {
253
+ if (stopReason === "error" || !text) {
212
254
  call.status = "error";
213
255
  return null;
214
256
  }
257
+
258
+ // v0.3 schema: the script asked for data, so hand it data. One retry
259
+ // with the validation errors — models fix their own shape far more
260
+ // reliably than a second model can guess what was meant.
261
+ if (opts?.schema) {
262
+ let outcome = readStructured(text, opts.schema);
263
+ if (!outcome.ok) {
264
+ run.logs.push(`${call.label}: schema mismatch, retrying (${outcome.errors[0] ?? "invalid"})`);
265
+ renderWidget();
266
+ await session.prompt(retryPrompt(outcome.errors, opts.schema), { source: "extension" } as never);
267
+ ({ text } = lastAnswer());
268
+ outcome = readStructured(text, opts.schema);
269
+ }
270
+ if (!outcome.ok) {
271
+ call.status = "error";
272
+ run.logs.push(`${call.label}: schema still unmet — ${outcome.errors.slice(0, 2).join("; ")}`);
273
+ renderWidget();
274
+ return null;
275
+ }
276
+ if (opts.gate) {
277
+ const gate = runGate(opts.gate, workDir);
278
+ if (!gate.ok) {
279
+ call.status = "error";
280
+ run.logs.push(`gate failed for ${call.label}: ${gate.output.slice(0, 200)}`);
281
+ renderWidget();
282
+ return null;
283
+ }
284
+ }
285
+ call.status = "done";
286
+ // Structured results cross the vm boundary as plain data.
287
+ return JSON.parse(JSON.stringify(outcome.value)) as unknown;
288
+ }
289
+
290
+ // v0.2 gate: verify the child's work by running a command instead of
291
+ // asking another model (tintinweb). Non-zero exit fails the call.
292
+ if (opts?.gate) {
293
+ const gate = runGate(opts.gate, workDir);
294
+ if (!gate.ok) {
295
+ call.status = "error";
296
+ run.logs.push(`gate failed for ${call.label}: ${gate.output.slice(0, 200)}`);
297
+ renderWidget();
298
+ return null;
299
+ }
300
+ run.logs.push(`gate passed for ${call.label}`);
301
+ }
302
+
215
303
  call.status = "done";
216
- return text;
304
+ return isolation ? `${text}\n\n${isolationNote(isolation)}` : text;
217
305
  } catch {
218
306
  call.status = "error";
219
307
  return null;
@@ -285,12 +373,16 @@ export default function workflow(pi: ExtensionAPI) {
285
373
  label: "Run workflow",
286
374
  description:
287
375
  "Run a deterministic JavaScript orchestration script that fans work out across child agents. " +
288
- "Globals: agent(prompt, {agent?, label?, phase?}) -> Promise<string|null> (agent types: " +
376
+ "Globals: agent(prompt, {agent?, label?, phase?, gate?, isolation?, schema?}) -> Promise<string|object|null> (agent types: " +
289
377
  "reviewer/scout/worker + .pi/agents custom; write prompts as self-contained briefs); " +
290
378
  "parallel(thunks) (barrier, failures resolve null); pipeline(items, ...stages) (no barrier " +
291
379
  "between stages); phase(title); log(msg); args. The script's return value is the tool result. " +
292
380
  "Date.now()/Math.random()/eval throw (determinism). Provide script XOR name " +
293
- "(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status.",
381
+ "(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status. " +
382
+ "agent() extras: gate=shell command run after the child (non-zero exit fails the call); " +
383
+ "isolation=worktree runs the child in its own git worktree for mutating steps; " +
384
+ "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.",
294
386
  parameters: Type.Object({
295
387
  script: Type.Optional(Type.String({ description: "JavaScript orchestration script body" })),
296
388
  name: Type.Optional(Type.String({ description: "Saved workflow name in .pi/workflows/" })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/workflow",
3
- "version": "0.1.1",
3
+ "version": "0.3.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/isolate.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+
6
+ /**
7
+ * Worktree isolation for child agents (v0.2 integration with the suite's
8
+ * worktree conventions): a mutating child gets its own git worktree on an
9
+ * agent/<slug> branch under ~/.worktrees/<repo>/, so parallel edits can
10
+ * never collide with the main checkout. All git calls are execFile argv —
11
+ * no shell, no interpolation. The worktree is NOT auto-removed: the result
12
+ * reports it so the user merges (worktree_merge from @pify/worktree, or
13
+ * plain git) or discards deliberately.
14
+ */
15
+
16
+ export interface Isolation {
17
+ path: string;
18
+ branch: string;
19
+ }
20
+
21
+ function git(cwd: string, args: string[]): string {
22
+ return execFileSync("git", args, {
23
+ cwd,
24
+ encoding: "utf8",
25
+ timeout: 30_000,
26
+ windowsHide: true,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ }).trim();
29
+ }
30
+
31
+ export function sanitizeSlug(raw: string): string {
32
+ const slug = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
33
+ return slug || "run";
34
+ }
35
+
36
+ export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
37
+ let toplevel: string;
38
+ try {
39
+ toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
40
+ } catch {
41
+ throw new Error("Worktree isolation requires a git repository.");
42
+ }
43
+ const repo = basename(toplevel);
44
+ const slug = sanitizeSlug(rawSlug);
45
+
46
+ let branch = `agent/${slug}`;
47
+ let path = join(homedir(), ".worktrees", repo, slug);
48
+ let counter = 2;
49
+ while (existsSync(path) || branchExists(cwd, branch)) {
50
+ branch = `agent/${slug}-${counter}`;
51
+ path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
52
+ counter++;
53
+ if (counter > 50) throw new Error("Could not find a free worktree slot.");
54
+ }
55
+
56
+ try {
57
+ git(cwd, ["worktree", "add", "-b", branch, path, "HEAD"]);
58
+ } catch (err) {
59
+ const e = err as { stderr?: string; message?: string };
60
+ throw new Error(`git worktree add failed: ${(e.stderr ?? e.message ?? "unknown").toString().trim()}`);
61
+ }
62
+ return { path, branch };
63
+ }
64
+
65
+ function branchExists(cwd: string, branch: string): boolean {
66
+ try {
67
+ git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Note appended to a child's report when it ran isolated. */
75
+ export function isolationNote(isolation: Isolation): string {
76
+ return [
77
+ `Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
78
+ `The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
79
+ `or inspect: cd "${isolation.path}" && git log --stat`,
80
+ ].join("\n");
81
+ }
package/src/sandbox.ts CHANGED
@@ -15,11 +15,20 @@ export interface AgentOptions {
15
15
  agent?: string;
16
16
  label?: string;
17
17
  phase?: string;
18
+ /** Shell command run after the child finishes; non-zero exit → result null (v0.2). */
19
+ gate?: string;
20
+ /** "worktree": run the child in an isolated git worktree (v0.2). */
21
+ isolation?: string;
22
+ /** JSON Schema: the child answers with data, agent() resolves an object (v0.3). */
23
+ schema?: Record<string, unknown>;
18
24
  }
19
25
 
20
26
  export interface SandboxHooks {
21
- /** Spawn one child agent; resolves to its report text or null on failure. */
22
- agent(prompt: string, opts?: AgentOptions): Promise<string | null>;
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>;
23
32
  log(message: string): void;
24
33
  phase(title: string): void;
25
34
  }
@@ -75,7 +84,7 @@ export async function runScript(
75
84
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
76
85
  let agentCalls = 0;
77
86
 
78
- const agent = (prompt: unknown, opts?: AgentOptions): Promise<string | null> => {
87
+ const agent = (prompt: unknown, opts?: AgentOptions): Promise<unknown> => {
79
88
  if (typeof prompt !== "string" || !prompt.trim()) {
80
89
  throw new Error("agent() requires a non-empty prompt string.");
81
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
+ }