@tangleai/agents 0.21.1 → 0.25.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/src/program.js CHANGED
@@ -1,4 +1,3 @@
1
- //@ts-check
2
1
  /**
3
2
  * The action language: compile it, then run it.
4
3
  *
@@ -50,59 +49,46 @@
50
49
  * cancellable).** `map` is the seam between the two halves, and it is
51
50
  * the only one.
52
51
  */
53
-
54
52
  import { CodedError } from '@jarenjs/core/errors';
55
53
  import { excerpt } from '@jarenjs/core/chunk';
56
54
  import { mapConcurrent } from '@jarenjs/core/async';
57
55
  import { JarenValidator } from '@jarenjs/validate';
58
-
59
- import { MAX_PROGRAM_CHARS, NAME_PATTERN, PROGRAM_SCHEMA, programSchema } from './schemas/program.js';
56
+ import { MAX_PROGRAM_CHARS, NAME_PATTERN, PROGRAM_SCHEMA, programSchema } from "./schemas/program.js";
60
57
  import { checkOutcome } from '@jarenjs/core/check';
61
58
  import { unfence } from '@tangleai/models/structured';
62
- import { recursiveShape, recursiveSchema, recursiveItems } from './program-shape.js';
59
+ import { recursiveShape, recursiveSchema, recursiveItems } from "./program-shape.js";
63
60
  import { createRoutedClient } from '@tangleai/models/routing';
64
-
65
- export { readProgramAnswer } from './program-result.js';
66
- /** @typedef {import('./program-result.js').ProgramRunResult} ProgramRunResult */
67
-
61
+ export { readProgramAnswer } from "./program-result.js";
68
62
  /** How much of one piece a sub-call is shown. The sub-call is the only
69
63
  * place content reaches a model at all, and it sees ONE piece — a cap
70
64
  * here is the difference between a bounded fan-out and the corpus
71
65
  * arriving in a different envelope. */
72
66
  const SUBCALL_CHARS = 8000;
73
-
74
67
  /** Sub-calls one run may make, whatever the corpus. A hard ceiling, not
75
68
  * a hint: depth × fan-out is multiplicative and a runaway map is a bill. */
76
69
  const MAX_SUBCALLS = 64;
77
-
78
70
  /** Sub-calls in flight at once. Four is the paper's limitation fixed —
79
71
  * it reports its own sub-calls are sequential and slow — and low enough
80
72
  * that a free-tier provider does not answer with 429s. */
81
73
  const MAX_CONCURRENT = 4;
82
-
83
74
  /** The characters a reduce may assemble from its map's results. The
84
75
  * reduction is small BY CONSTRUCTION (that is what a map is for), so
85
76
  * this cap is a tripwire on a program that mapped identity over a
86
77
  * corpus, not a working limit. */
87
78
  const MAX_REDUCE_CHARS = 200000;
88
-
89
79
  /** How much of the answer slot the final step returns unasked. */
90
80
  const ANSWER_CHARS = 2000;
91
-
92
81
  /** How much of a failed reply is kept beside its error. */
93
82
  const RAW_EXCERPT = 200;
94
-
95
83
  /** The namespace a run's own results live under, kept apart from the
96
84
  * corpus so a digest can tell working notes from the thing being worked
97
85
  * on — and so a re-run overwrites rather than accumulating. */
98
86
  const RESULT_PREFIX = 'program/';
99
-
100
87
  const NAME_RE = new RegExp(NAME_PATTERN);
101
-
102
88
  /**
103
89
  * A program that will not compile.
104
90
  *
105
- * Thrown, unlike `refine.js`'s AI01xx records, because this is a
91
+ * Thrown, unlike `refine.ts`'s AI01xx records, because this is a
106
92
  * COMPILER and the suite's compilers throw coded errors with a
107
93
  * `docPath` — which is what makes the documented two-line gate adapter
108
94
  * (`try { compile(doc) } catch (e) { … }`) work here exactly as it does
@@ -125,35 +111,30 @@ const NAME_RE = new RegExp(NAME_PATTERN);
125
111
  * A query that does not compile keeps the QUERY engine's own code
126
112
  * (`JQ0002`, …) and its pointer is rebased onto the program document,
127
113
  * so the model is told which step and which member — the same posture
128
- * `refine.js` takes with the patch engine's codes.
114
+ * `refine.ts` takes with the patch engine's codes.
129
115
  */
130
116
  export class ProgramError extends CodedError {
131
- /**
132
- * @param {string} code - 'AI0200' … 'AI0209'
133
- * @param {string} reason - the bare reason
134
- * @param {string} [docPath] - JSON Pointer into the program document
135
- */
136
- constructor(code, reason, docPath) {
137
- super('ProgramError', code, reason, docPath);
138
- }
117
+ /**
118
+ * @param code - 'AI0200' … 'AI0209'
119
+ * @param reason - the bare reason
120
+ * @param [docPath] - JSON Pointer into the program document
121
+ */
122
+ constructor(code, reason, docPath) {
123
+ super('ProgramError', code, reason, docPath);
124
+ }
139
125
  }
140
-
141
126
  /**
142
127
  * One error in the shape a repair prompt carries. A rebased query error
143
128
  * keeps its engine code and gains the step it came from.
144
- * @param {any} err
145
- * @returns {{ code: string, docPath: string, message: string }}
146
129
  */
147
130
  function errorRecord(err) {
148
- return {
149
- code: err?.code ?? 'AI0200',
150
- docPath: err?.docPath ?? '',
151
- message: err?.reason ?? err?.message ?? String(err),
152
- };
131
+ return {
132
+ code: err?.code ?? 'AI0200',
133
+ docPath: err?.docPath ?? '',
134
+ message: err?.reason ?? err?.message ?? String(err),
135
+ };
153
136
  }
154
-
155
137
  //#region the compiler
156
-
157
138
  /**
158
139
  * Compile a program document.
159
140
  *
@@ -163,9 +144,8 @@ function errorRecord(err) {
163
144
  * model, so a compile is cheap enough to run on every authored candidate
164
145
  * — which is exactly what makes it usable as a decoding gate.
165
146
  *
166
- * @param {any} doc - the program document
167
- * @param {{ compileQuery?: ((document: any) => (data: any) => any) | null,
168
- * known?: Iterable<string>, recursive?: boolean, analyzeQuery?: any, annotateTypes?: any }} [options]
147
+ * @param doc - the program document
148
+ * @param [options]
169
149
  * - `compileQuery` is the D3 seam. Absent, a program using `select` or
170
150
  * `reduce` is refused with AI0206 rather than half-compiled.
171
151
  * - `known` is what the environment already holds. Given, a `from`
@@ -174,220 +154,180 @@ function errorRecord(err) {
174
154
  * reason a compile gate catches what a schema cannot. Absent, only
175
155
  * bindings are resolved: nothing else can be checked without an
176
156
  * environment, and inventing an error would be worse than saying so.
177
- * @returns {{ steps: any[], answer: { from: string, chars: number },
178
- * bindings: string[], chars: number }}
157
+ * @returns
179
158
  * @throws {ProgramError}
180
159
  */
181
160
  export function compileProgram(doc, options = {}) {
182
- const compileQuery = typeof options.compileQuery === 'function' ? options.compileQuery : null;
183
- const known = options.known === undefined ? null : new Set(options.known);
184
-
185
- if (doc === null || typeof doc !== 'object' || !Array.isArray(doc.steps))
186
- throw new ProgramError('AI0200', 'a program is an object with a steps array', '');
187
- if (doc.steps.length === 0)
188
- throw new ProgramError('AI0200', 'a program needs at least one step', '/steps');
189
-
190
- const text = JSON.stringify(doc);
191
- if (text.length > MAX_PROGRAM_CHARS) {
192
- throw new ProgramError('AI0205',
193
- `the program is ${text.length} characters, over the ${MAX_PROGRAM_CHARS} cap —`
194
- + ' a plan naming slots is small; one carrying content is not', '');
195
- }
196
- const shape = checkOutcome(new JarenValidator({ skipErrors: false, collectErrors: true }).compile(PROGRAM_SCHEMA)(doc));
197
- if (!shape.valid) throw new ProgramError('AI0200', 'the document violates the program schema', shape.errors[0]?.instancePath ?? '');
198
-
199
- /** binding name → the step index that produced it and what kind it is */
200
- const bound = new Map();
201
- const steps = [];
202
-
203
- for (let i = 0; i < doc.steps.length; i++) {
204
- const raw = doc.steps[i];
205
- const at = `/steps/${i}`;
206
- const op = raw?.op;
207
- const from = raw?.from;
208
-
209
- if (typeof from !== 'string' || from === '')
210
- throw new ProgramError('AI0200', `step ${i} has no "from"`, `${at}/from`);
211
-
212
- // resolve the input: a binding first, then the environment
213
- const source = bound.get(from);
214
- if (source === undefined && known !== null && !known.has(from)) {
215
- // the names a model could plausibly have meant: this program's own
216
- // bindings, then the slots a digest would have shown it — never the
217
- // runner's `program/` scratch and never a derived chunk address,
218
- // both of which are noise in a repair prompt
219
- const names = [...bound.keys(),
220
- ...[...known].filter((n) => !n.startsWith(RESULT_PREFIX) && !n.includes('#'))]
221
- .slice(0, 8);
222
- throw new ProgramError('AI0201',
223
- `"${from}" is not a slot and no earlier step produced it`
224
- + (names.length === 0 ? '' : ` — available: ${names.join(', ')}`),
225
- `${at}/from`);
226
- }
227
-
228
- // `chunk` and `map` bind a FAMILY of pieces, and two steps read one
229
- // slot rather than a family: a select parses a document, an answer
230
- // reads a text. Catching it here is worth a code of its own —
231
- // "reduce it first" is a repair a model lands, where the runtime
232
- // error it would otherwise get ("no slot program/found/") points at
233
- // an address the model never wrote
234
- if ((op === 'answer' || op === 'select') && (source?.op === 'chunk' || source?.op === 'map')) {
235
- throw new ProgramError('AI0207',
236
- `${op} reads one slot and "${from}" is a ${source.op} — every piece of it. `
237
- + (source.op === 'map'
238
- ? 'Combine them with a reduce step and read that.'
239
- : 'Map over it, then reduce, and read that.'),
240
- `${at}/from`);
161
+ const compileQuery = typeof options.compileQuery === 'function' ? options.compileQuery : null;
162
+ const known = options.known === undefined ? null : new Set(options.known);
163
+ if (doc === null || typeof doc !== 'object' || !Array.isArray(doc.steps))
164
+ throw new ProgramError('AI0200', 'a program is an object with a steps array', '');
165
+ if (doc.steps.length === 0)
166
+ throw new ProgramError('AI0200', 'a program needs at least one step', '/steps');
167
+ const text = JSON.stringify(doc);
168
+ if (text.length > MAX_PROGRAM_CHARS) {
169
+ throw new ProgramError('AI0205', `the program is ${text.length} characters, over the ${MAX_PROGRAM_CHARS} cap —`
170
+ + ' a plan naming slots is small; one carrying content is not', '');
241
171
  }
242
-
243
- if (op === 'answer') {
244
- if (i !== doc.steps.length - 1)
245
- throw new ProgramError('AI0203', 'answer is the last step of a program', `${at}/op`);
246
- steps.push({ op, from, index: i });
247
- continue;
248
- }
249
-
250
- const as = raw?.as;
251
- if (typeof as !== 'string' || !NAME_RE.test(as))
252
- throw new ProgramError('AI0200', `step ${i} has no usable "as"`, `${at}/as`);
253
- if (bound.has(as))
254
- throw new ProgramError('AI0202', `"${as}" is already the name of step ${bound.get(as).index}`, `${at}/as`);
255
-
256
- /** @type {any} */
257
- const step = { op, from, as, index: i };
258
-
259
- if (op === 'reduce' && source?.op !== 'map') {
260
- throw new ProgramError('AI0204',
261
- `reduce combines a map's results; "${from}" is ${source === undefined ? 'a slot' : `a ${source.op}`}`,
262
- `${at}/from`);
263
- }
264
-
265
- if (op === 'select' || op === 'reduce') {
266
- if (compileQuery === null) {
267
- throw new ProgramError('AI0206',
268
- `${op} needs the compileQuery seam — inject compileJsonQuery from @jarenjs/json/query`,
269
- `${at}/query`);
270
- }
271
- try {
272
- // compiled once, here, and kept: the house two-stage rule, and
273
- // it is also what lets the gate reject a bad query before a
274
- // single slot has been touched
275
- step.run = compileQuery(raw.query);
276
- }
277
- catch (err) {
278
- const e = /** @type {any} */ (err);
279
- // the engine's own code and its pointer, rebased onto the step:
280
- // "which member of which step" is the difference between a
281
- // repair round that converges and one that flails
282
- throw new ProgramError(e?.code ?? 'AI0200',
283
- e?.reason ?? e?.message ?? 'the query does not compile',
284
- `${at}/query${e?.docPath ?? ''}`);
285
- }
286
- step.query = raw.query;
287
- if (options.analyzeQuery && options.annotateTypes) {
288
- try { step.outputType = options.annotateTypes(options.analyzeQuery(raw.query)).root; }
289
- catch { step.outputType = undefined; }
290
- }
291
- if (raw.outputSchema !== undefined) {
292
- try { step.validateOutput = new JarenValidator({ skipErrors: false, collectErrors: true }).compile(raw.outputSchema); }
293
- catch { throw new ProgramError('AI0208', 'invalid outputSchema', `${at}/outputSchema`); }
294
- }
295
- if (options.recursive === true && op === 'reduce') {
296
- const inferred = recursiveShape(step.outputType);
297
- if (inferred === 'incompatible' || (inferred === 'unknown' && !recursiveSchema(raw.outputSchema))) {
298
- throw new ProgramError('AI0208', `reduce ${as} infers ${inferred}; required {slot:string,value:any} item or sequence. Unknown inference needs outputSchema.`, `${at}/query`);
172
+ const shape = checkOutcome(new JarenValidator({ skipErrors: false, collectErrors: true }).compile(PROGRAM_SCHEMA)(doc));
173
+ if (!shape.valid)
174
+ throw new ProgramError('AI0200', 'the document violates the program schema', shape.errors[0]?.instancePath ?? '');
175
+ /** binding name the step index that produced it and what kind it is */
176
+ const bound = new Map();
177
+ const steps = [];
178
+ for (let i = 0; i < doc.steps.length; i++) {
179
+ const raw = doc.steps[i];
180
+ const at = `/steps/${i}`;
181
+ const op = raw?.op;
182
+ const from = raw?.from;
183
+ if (typeof from !== 'string' || from === '')
184
+ throw new ProgramError('AI0200', `step ${i} has no "from"`, `${at}/from`);
185
+ // resolve the input: a binding first, then the environment
186
+ const source = bound.get(from);
187
+ if (source === undefined && known !== null && !known.has(from)) {
188
+ // the names a model could plausibly have meant: this program's own
189
+ // bindings, then the slots a digest would have shown it — never the
190
+ // runner's `program/` scratch and never a derived chunk address,
191
+ // both of which are noise in a repair prompt
192
+ const names = [...bound.keys(),
193
+ ...[...known].filter((n) => !n.startsWith(RESULT_PREFIX) && !n.includes('#'))]
194
+ .slice(0, 8);
195
+ throw new ProgramError('AI0201', `"${from}" is not a slot and no earlier step produced it`
196
+ + (names.length === 0 ? '' : ` — available: ${names.join(', ')}`), `${at}/from`);
299
197
  }
300
- step.recursive = true;
301
- }
302
- }
303
-
304
- if (op === 'map') {
305
- if (typeof raw.prompt !== 'string' || raw.prompt.trim() === '')
306
- throw new ProgramError('AI0200', 'a map needs a prompt', `${at}/prompt`);
307
- step.prompt = raw.prompt;
308
- }
309
-
310
- if (op === 'grep') {
311
- if (typeof raw.pattern !== 'string' || raw.pattern === '')
312
- throw new ProgramError('AI0200', 'a grep needs a pattern', `${at}/pattern`);
313
- step.pattern = raw.pattern;
314
- step.flags = raw.flags;
315
- step.limit = raw.limit;
316
- }
317
-
318
- if (op === 'chunk') {
319
- step.strategy = raw.strategy;
320
- step.size = raw.size;
198
+ // `chunk` and `map` bind a FAMILY of pieces, and two steps read one
199
+ // slot rather than a family: a select parses a document, an answer
200
+ // reads a text. Catching it here is worth a code of its own —
201
+ // "reduce it first" is a repair a model lands, where the runtime
202
+ // error it would otherwise get ("no slot program/found/") points at
203
+ // an address the model never wrote
204
+ if ((op === 'answer' || op === 'select') && (source?.op === 'chunk' || source?.op === 'map')) {
205
+ throw new ProgramError('AI0207', `${op} reads one slot and "${from}" is a ${source.op} every piece of it. `
206
+ + (source.op === 'map'
207
+ ? 'Combine them with a reduce step and read that.'
208
+ : 'Map over it, then reduce, and read that.'), `${at}/from`);
209
+ }
210
+ if (op === 'answer') {
211
+ if (i !== doc.steps.length - 1)
212
+ throw new ProgramError('AI0203', 'answer is the last step of a program', `${at}/op`);
213
+ steps.push({ op, from, index: i });
214
+ continue;
215
+ }
216
+ const as = raw?.as;
217
+ if (typeof as !== 'string' || !NAME_RE.test(as))
218
+ throw new ProgramError('AI0200', `step ${i} has no usable "as"`, `${at}/as`);
219
+ if (bound.has(as))
220
+ throw new ProgramError('AI0202', `"${as}" is already the name of step ${bound.get(as).index}`, `${at}/as`);
221
+ const step = { op, from, as, index: i };
222
+ if (op === 'reduce' && source?.op !== 'map') {
223
+ throw new ProgramError('AI0204', `reduce combines a map's results; "${from}" is ${source === undefined ? 'a slot' : `a ${source.op}`}`, `${at}/from`);
224
+ }
225
+ if (op === 'select' || op === 'reduce') {
226
+ if (compileQuery === null) {
227
+ throw new ProgramError('AI0206', `${op} needs the compileQuery seam — inject compileJsonQuery from @jarenjs/json/query`, `${at}/query`);
228
+ }
229
+ try {
230
+ // compiled once, here, and kept: the house two-stage rule, and
231
+ // it is also what lets the gate reject a bad query before a
232
+ // single slot has been touched
233
+ step.run = compileQuery(raw.query);
234
+ }
235
+ catch (err) {
236
+ const e = err;
237
+ // the engine's own code and its pointer, rebased onto the step:
238
+ // "which member of which step" is the difference between a
239
+ // repair round that converges and one that flails
240
+ throw new ProgramError(e?.code ?? 'AI0200', e?.reason ?? e?.message ?? 'the query does not compile', `${at}/query${e?.docPath ?? ''}`);
241
+ }
242
+ step.query = raw.query;
243
+ if (options.analyzeQuery && options.annotateTypes) {
244
+ try {
245
+ step.outputType = options.annotateTypes(options.analyzeQuery(raw.query)).root;
246
+ }
247
+ catch {
248
+ step.outputType = undefined;
249
+ }
250
+ }
251
+ if (raw.outputSchema !== undefined) {
252
+ try {
253
+ step.validateOutput = new JarenValidator({ skipErrors: false, collectErrors: true }).compile(raw.outputSchema);
254
+ }
255
+ catch {
256
+ throw new ProgramError('AI0208', 'invalid outputSchema', `${at}/outputSchema`);
257
+ }
258
+ }
259
+ if (options.recursive === true && op === 'reduce') {
260
+ const inferred = recursiveShape(step.outputType);
261
+ if (inferred === 'incompatible' || (inferred === 'unknown' && !recursiveSchema(raw.outputSchema))) {
262
+ throw new ProgramError('AI0208', `reduce ${as} infers ${inferred}; required {slot:string,value:any} item or sequence. Unknown inference needs outputSchema.`, `${at}/query`);
263
+ }
264
+ step.recursive = true;
265
+ }
266
+ }
267
+ if (op === 'map') {
268
+ if (typeof raw.prompt !== 'string' || raw.prompt.trim() === '')
269
+ throw new ProgramError('AI0200', 'a map needs a prompt', `${at}/prompt`);
270
+ step.prompt = raw.prompt;
271
+ }
272
+ if (op === 'grep') {
273
+ if (typeof raw.pattern !== 'string' || raw.pattern === '')
274
+ throw new ProgramError('AI0200', 'a grep needs a pattern', `${at}/pattern`);
275
+ step.pattern = raw.pattern;
276
+ step.flags = raw.flags;
277
+ step.limit = raw.limit;
278
+ }
279
+ if (op === 'chunk') {
280
+ step.strategy = raw.strategy;
281
+ step.size = raw.size;
282
+ }
283
+ bound.set(as, step);
284
+ steps.push(step);
321
285
  }
322
-
323
- bound.set(as, step);
324
- steps.push(step);
325
- }
326
-
327
- const last = steps[steps.length - 1];
328
- if (last.op !== 'answer')
329
- throw new ProgramError('AI0203', 'a program ends with an answer step', `/steps/${last.index}/op`);
330
- if (steps.filter((s) => s.op === 'answer').length > 1)
331
- throw new ProgramError('AI0203', 'a program has exactly one answer step', '/steps');
332
- if (options.recursive === true && !bound.get(last.from)?.recursive)
333
- throw new ProgramError('AI0208', 'a recursive answer must read a shape-checked reduce', `/steps/${last.index}/from`);
334
-
335
- return {
336
- steps,
337
- answer: {
338
- from: last.from,
339
- chars: Math.min(doc.steps[last.index].chars ?? ANSWER_CHARS, ANSWER_CHARS * 4),
340
- },
341
- bindings: [...bound.keys()],
342
- chars: text.length,
343
- };
286
+ const last = steps[steps.length - 1];
287
+ if (last.op !== 'answer')
288
+ throw new ProgramError('AI0203', 'a program ends with an answer step', `/steps/${last.index}/op`);
289
+ if (steps.filter((s) => s.op === 'answer').length > 1)
290
+ throw new ProgramError('AI0203', 'a program has exactly one answer step', '/steps');
291
+ if (options.recursive === true && !bound.get(last.from)?.recursive)
292
+ throw new ProgramError('AI0208', 'a recursive answer must read a shape-checked reduce', `/steps/${last.index}/from`);
293
+ return {
294
+ steps,
295
+ answer: {
296
+ from: last.from,
297
+ chars: Math.min(doc.steps[last.index].chars ?? ANSWER_CHARS, ANSWER_CHARS * 4),
298
+ },
299
+ bindings: [...bound.keys()],
300
+ chars: text.length,
301
+ };
344
302
  }
345
-
346
303
  /**
347
304
  * The compile gate, ready for `createStructuredOutput({ gate })`.
348
305
  *
349
306
  * One implementation of "does this program compile", used by the
350
307
  * authoring path and by the runner, so a program that authored cleanly
351
308
  * cannot fail differently when it runs.
352
- * @param {{ compileQuery?: any, known?: Iterable<string>, recursive?: boolean, analyzeQuery?: any, annotateTypes?: any }} [options]
353
- * @returns {(doc: any) => true | { valid: false, errors: any[] }}
309
+ * @param [options]
354
310
  */
355
311
  export function programGate(options = {}) {
356
- return (doc) => {
357
- try {
358
- compileProgram(doc, options);
359
- return true;
360
- }
361
- catch (err) {
362
- return { valid: false, errors: [errorRecord(err)] };
363
- }
364
- };
312
+ return (doc) => {
313
+ try {
314
+ compileProgram(doc, options);
315
+ return true;
316
+ }
317
+ catch (err) {
318
+ return { valid: false, errors: [errorRecord(err)] };
319
+ }
320
+ };
365
321
  }
366
-
367
322
  //#endregion
368
-
369
323
  //#region the runner
370
-
371
324
  /** The address a step's result is stored under, derived from its binding. */
372
325
  const resultSlot = (as) => `${RESULT_PREFIX}${as}`;
373
-
374
326
  /** The family a map's per-piece results live under. */
375
327
  const mapFamily = (as) => `${RESULT_PREFIX}${as}/`;
376
-
377
328
  /**
378
329
  * Create a runner over an environment.
379
330
  *
380
- * @param {{ environment: any,
381
- * client?: { complete: (request: any) => Promise<any> },
382
- * compileQuery?: any,
383
- * recursive?: boolean, analyzeQuery?: any, annotateTypes?: any,
384
- * selectModel?: any, limits?: any, onRoute?: any, depth?: number,
385
- * model?: string,
386
- * maxSubcalls?: number, maxConcurrentSubcalls?: number,
387
- * subcallChars?: number, maxReduceChars?: number,
388
- * sequential?: boolean,
389
- * subcall?: (name: string, prompt: string, signal?: AbortSignal, index?: number) => Promise<any>,
390
- * account?: { reserve: Function, settle: Function, stop: () => string | null } }} options
391
331
  * - `client` is only needed by `map`; a program without one is a
392
332
  * perfectly good program (chunk / grep / select / stat / answer are
393
333
  * model-free), and running a `map` without a client is a stated
@@ -404,462 +344,449 @@ const mapFamily = (as) => `${RESULT_PREFIX}${as}/`;
404
344
  * - `account` is a budget shared with everything else in the run,
405
345
  * including other depths. Checked before each sub-call and charged
406
346
  * by it, so a tree cannot outspend the sum of its branches.
407
- * @returns {{ run: (doc: any, hooks?: { signal?: AbortSignal }) => Promise<ProgramRunResult> }}
408
347
  */
409
348
  export function createProgramRunner(options) {
410
- const { environment } = options;
411
- if (environment === null || typeof environment !== 'object')
412
- throw new TypeError('createProgramRunner needs an environment');
413
- const client = options.client ?? null;
414
- const compileQuery = options.compileQuery ?? null;
415
- const maxSubcalls = options.maxSubcalls ?? MAX_SUBCALLS;
416
- const concurrency = options.sequential === true
417
- ? 1
418
- : Math.max(1, options.maxConcurrentSubcalls ?? MAX_CONCURRENT);
419
- const subcallChars = options.subcallChars ?? SUBCALL_CHARS;
420
- const maxReduceChars = options.maxReduceChars ?? MAX_REDUCE_CHARS;
421
- const account = options.account ?? null;
422
-
423
- /**
424
- * The shape gate, compiled once.
425
- *
426
- * The runner validates the document as well as compiling it, so the
427
- * two ways a program arrives authored under constrained decoding, or
428
- * hand-written by a caller meet the same two gates in the same
429
- * order. Without this a hand-written program could carry a member the
430
- * grammar forbids (the D2 rule that no step inlines content is
431
- * `additionalProperties: false` on every step), and the restriction
432
- * would hold only for the path that happened to go through
433
- * `createStructuredOutput`.
434
- *
435
- * The seam-free schema on purpose: `query` is unconstrained here and
436
- * the compile gate judges it, so a runner needs no grammar registered
437
- * to check a shape (D3).
438
- */
439
- const shape = new JarenValidator({ skipErrors: false, collectErrors: true })
440
- .compile(PROGRAM_SCHEMA);
441
-
442
- /** Compile against what the environment actually holds right now —
443
- * which is what turns AI0201 from a shape check into a real one. */
444
- async function compileHere(doc) {
445
- const outcome = checkOutcome(shape(doc));
446
- if (!outcome.valid) {
447
- throw new ProgramError('AI0200',
448
- outcome.errors[0]?.message ?? 'the document is not a program',
449
- outcome.errors[0]?.instancePath ?? '');
450
- }
451
- const names = (await environment.ledger.listSlots()).map((s) => s.name);
452
- return compileProgram(doc, { compileQuery, known: names, recursive: options.recursive,
453
- analyzeQuery: options.analyzeQuery, annotateTypes: options.annotateTypes });
454
- }
455
-
456
- /**
457
- * The slots one binding stands for, in address order.
458
- *
459
- * Three cases, and the third is why `grep` is worth having: a chunk or
460
- * a map binding is a family; a grep binding is the set of slots it
461
- * MATCHED, so `map` over a grep visits the matching pieces and not the
462
- * match list. That is the narrowing the whole design is for — grep to
463
- * find which forty of four hundred pieces are relevant, then spend
464
- * sub-calls on those forty only. Anything else is one slot.
465
- * @param {any} target - `{ family }`, `{ matches }` or `{ slot }`
466
- * @returns {Promise<string[]>}
467
- */
468
- async function membersOf(target) {
469
- if (target.matches !== undefined) {
470
- const stored = await environment.ledger.readSlot(target.slot);
471
- /** @type {any} */
472
- let listing;
473
- try { listing = JSON.parse(String(stored ?? '{}')); }
474
- catch { return [target.slot]; }
475
- const names = [...new Set((listing.matches ?? []).map((m) => m.slot))];
476
- return /** @type {string[]} */ (names);
477
- }
478
- if (target.slot !== undefined) return [target.slot];
479
- const slots = await environment.ledger.listSlots();
480
- return slots
481
- .filter((s) => s.name.startsWith(target.family))
482
- .map((s) => s.name)
483
- .sort((a, b) => {
484
- // numeric on the trailing index, so piece 10 follows piece 9 and
485
- // a reduce sees its map's results in the corpus's own order
486
- const ai = Number(a.slice(a.lastIndexOf('/') + 1));
487
- const bi = Number(b.slice(b.lastIndexOf('/') + 1));
488
- return Number.isNaN(ai) || Number.isNaN(bi) ? a.localeCompare(b) : ai - bi;
489
- }).slice(0, target.count ?? Infinity);
490
- }
491
-
492
- /**
493
- * One sub-call: the model sees ONE piece and answers with one JSON
494
- * value. Failures are recorded, never thrown — §3.
495
- * @param {string} name - the piece's slot
496
- * @param {string} prompt
497
- * @param {AbortSignal} [signal]
498
- */
499
- async function subcall(name, prompt, signal) {
500
- const piece = await environment.read(name, { chars: subcallChars });
501
- if (piece.error !== undefined) return { slot: name, error: piece.error };
502
- /** @type {any} */
503
- let reply;
504
- // the turn is taken before the call, not after it: four sub-calls
505
- // launched together would otherwise each see the same unspent budget
506
- try {
507
- reply = await createRoutedClient({ client, selectModel: options.selectModel,
508
- limits: options.limits, onRoute: options.onRoute, account },
509
- { purpose: 'subcall', grammar: 'program', depth: options.depth ?? 0 }).complete({
510
- // `stream` is deliberately NOT set. A sub-call has no UI to
511
- // stream to, so `stream: false` looks right — and it is the
512
- // exact request shape this package's own benchmark measured
513
- // hanging past a 300 s deadline on the cheap tier and answering
514
- // in seconds when streamed. Leaving it to the client's default
515
- // (streaming) costs nothing here, because `complete` returns the
516
- // accumulated message either way.
517
- signal,
518
- messages: [
519
- {
520
- role: 'system',
521
- content: 'You are given ONE piece of a larger corpus and a question about it.'
522
- + ' Answer with a single JSON value and nothing else — no prose, no code fences.'
523
- + ' If the piece does not contain what was asked for, answer with null.',
524
- },
525
- { role: 'user', content: `${prompt}\n\n--- piece ${name} ---\n${piece.text}` },
526
- ],
527
- });
528
- }
529
- catch (err) {
530
- if (signal?.aborted === true) throw err;
531
- return { slot: name, error: `the sub-call failed: ${/** @type {Error} */ (err).message}` };
532
- }
533
- const raw = String(reply?.message?.content ?? '');
534
- // settled here rather than by the caller: this is where the call
535
- // actually happened, and an account that only saw the calls someone
536
- // remembered to report is not a bound
537
- try {
538
- return { slot: name, value: JSON.parse(unfence(raw)) };
539
- }
540
- catch (err) {
541
- return {
542
- slot: name,
543
- error: `the reply is not JSON: ${/** @type {Error} */ (err).message}`,
544
- raw: excerpt(raw, RAW_EXCERPT),
545
- };
546
- }
547
- }
548
-
549
- /**
550
- * Execute a compiled program.
551
- * @param {any} doc
552
- * @param {{ signal?: AbortSignal }} [hooks]
553
- * @returns {Promise<ProgramRunResult>}
554
- */
555
- async function run(doc, hooks = {}) {
556
- const signal = hooks.signal;
557
- const started = Date.now();
558
-
559
- /** @type {any} */
560
- let plan;
561
- try {
562
- plan = await compileHere(doc);
563
- }
564
- catch (err) {
565
- // NOTHING has run: the compile reads no slot and writes none, so a
566
- // rejected program leaves the environment exactly as it was
567
- return { ok: false, ran: 0, steps: [], subcalls: 0, failed: 0, concurrency,
568
- answer: null, ms: Date.now() - started,
569
- error: 'the program does not compile', errors: [errorRecord(err)] };
349
+ const { environment } = options;
350
+ if (environment === null || typeof environment !== 'object')
351
+ throw new TypeError('createProgramRunner needs an environment');
352
+ const client = options.client ?? null;
353
+ const compileQuery = options.compileQuery ?? null;
354
+ const maxSubcalls = options.maxSubcalls ?? MAX_SUBCALLS;
355
+ const concurrency = options.sequential === true
356
+ ? 1
357
+ : Math.max(1, options.maxConcurrentSubcalls ?? MAX_CONCURRENT);
358
+ const subcallChars = options.subcallChars ?? SUBCALL_CHARS;
359
+ const maxReduceChars = options.maxReduceChars ?? MAX_REDUCE_CHARS;
360
+ const account = options.account ?? null;
361
+ /**
362
+ * The shape gate, compiled once.
363
+ *
364
+ * The runner validates the document as well as compiling it, so the
365
+ * two ways a program arrives authored under constrained decoding, or
366
+ * hand-written by a callermeet the same two gates in the same
367
+ * order. Without this a hand-written program could carry a member the
368
+ * grammar forbids (the D2 rule that no step inlines content is
369
+ * `additionalProperties: false` on every step), and the restriction
370
+ * would hold only for the path that happened to go through
371
+ * `createStructuredOutput`.
372
+ *
373
+ * The seam-free schema on purpose: `query` is unconstrained here and
374
+ * the compile gate judges it, so a runner needs no grammar registered
375
+ * to check a shape (D3).
376
+ */
377
+ const shape = new JarenValidator({ skipErrors: false, collectErrors: true })
378
+ .compile(PROGRAM_SCHEMA);
379
+ /** Compile against what the environment actually holds right now —
380
+ * which is what turns AI0201 from a shape check into a real one. */
381
+ async function compileHere(doc) {
382
+ const outcome = checkOutcome(shape(doc));
383
+ if (!outcome.valid) {
384
+ throw new ProgramError('AI0200', outcome.errors[0]?.message ?? 'the document is not a program', outcome.errors[0]?.instancePath ?? '');
385
+ }
386
+ const names = (await environment.ledger.listSlots()).map((s) => s.name);
387
+ return compileProgram(doc, {
388
+ compileQuery, known: names, recursive: options.recursive,
389
+ analyzeQuery: options.analyzeQuery, annotateTypes: options.annotateTypes
390
+ });
570
391
  }
571
-
572
- /** binding → `{ slot }` or `{ family, count }` */
573
- const bindings = new Map();
574
- /** @type {any[]} */
575
- const report = [];
576
- let subcalls = 0;
577
- let failed = 0;
578
- /** @type {string|null} */
579
- let stopped = null;
580
-
581
- /** Where a step's `from` points, as an address the environment knows. */
582
- const address = (from) => {
583
- const binding = bindings.get(from);
584
- if (binding === undefined) return { slot: from };
585
- return binding;
586
- };
587
- const addressOf = (from) => {
588
- const target = address(from);
589
- return target.slot ?? target.family;
590
- };
591
-
592
392
  /**
593
- * Run a step's compiled query over `data`, store the result, bind it.
594
- * `select` and `reduce` differ only in where their input comes from
595
- * one slot's document, or a map's collected results so the
596
- * running and the storing live here once.
597
- * @param {any} step
598
- * @param {any} data
393
+ * The slots one binding stands for, in address order.
394
+ *
395
+ * Three cases, and the third is why `grep` is worth having: a chunk or
396
+ * a map binding is a family; a grep binding is the set of slots it
397
+ * MATCHED, so `map` over a grep visits the matching pieces and not the
398
+ * match list. That is the narrowing the whole design is for — grep to
399
+ * find which forty of four hundred pieces are relevant, then spend
400
+ * sub-calls on those forty only. Anything else is one slot.
401
+ * @param target - `{ family }`, `{ matches }` or `{ slot }`
599
402
  */
600
- async function store(step, data) {
601
- /** @type {any} */
602
- let value;
603
- try {
604
- value = step.run(data);
605
- }
606
- catch (err) {
607
- const e = /** @type {any} */ (err);
608
- return { error: `the query failed: ${e?.reason ?? e?.message ?? err}`, code: e?.code };
609
- }
610
- if ((step.validateOutput && !checkOutcome(step.validateOutput(value)).valid)
611
- || (step.recursive && recursiveItems(value) === null))
612
- return { error: `reduce ${step.as} violates its output shape before storage`, code: 'AI0209' };
613
- const written = await environment.put(resultSlot(step.as), JSON.stringify(value ?? null),
614
- { kind: 'selection', count: Array.isArray(value) ? value.length : undefined });
615
- if (written.error === undefined) bindings.set(step.as, { slot: resultSlot(step.as) });
616
- return written;
403
+ async function membersOf(target) {
404
+ if (target.matches !== undefined) {
405
+ const stored = await environment.ledger.readSlot(target.slot);
406
+ let listing;
407
+ try {
408
+ listing = JSON.parse(String(stored ?? '{}'));
409
+ }
410
+ catch {
411
+ return [target.slot];
412
+ }
413
+ const names = [...new Set((listing.matches ?? []).map((m) => m.slot))];
414
+ return names;
415
+ }
416
+ if (target.slot !== undefined)
417
+ return [target.slot];
418
+ const slots = await environment.ledger.listSlots();
419
+ return slots
420
+ .filter((s) => s.name.startsWith(target.family))
421
+ .map((s) => s.name)
422
+ .sort((a, b) => {
423
+ // numeric on the trailing index, so piece 10 follows piece 9 and
424
+ // a reduce sees its map's results in the corpus's own order
425
+ const ai = Number(a.slice(a.lastIndexOf('/') + 1));
426
+ const bi = Number(b.slice(b.lastIndexOf('/') + 1));
427
+ return Number.isNaN(ai) || Number.isNaN(bi) ? a.localeCompare(b) : ai - bi;
428
+ }).slice(0, target.count ?? Infinity);
617
429
  }
618
-
619
- for (const step of plan.steps) {
620
- if (signal?.aborted === true) { stopped = 'aborted'; break; }
621
- const target = address(step.from);
622
-
623
- if (step.op === 'chunk') {
624
- const result = await environment.chunk(addressOf(step.from), {
625
- strategy: step.strategy, size: step.size,
626
- });
627
- if (result.error !== undefined) return failure(step, result);
628
- bindings.set(step.as, { family: result.family, count: result.count });
629
- report.push({ op: step.op, as: step.as, count: result.count, family: result.family });
630
- continue;
631
- }
632
-
633
- if (step.op === 'grep') {
634
- const result = await environment.grep(step.pattern, {
635
- in: addressOf(step.from), limit: step.limit, flags: step.flags,
636
- });
637
- if (result.error !== undefined) return failure(step, result);
638
- const stored = await environment.put(resultSlot(step.as), JSON.stringify(result),
639
- { kind: 'selection', count: result.total });
640
- // `matches` marks this binding as a SET OF ADDRESSES: reading it
641
- // gives the listing, mapping over it visits what it found
642
- bindings.set(step.as, { slot: resultSlot(step.as), matches: result.matches.length });
643
- report.push({
644
- op: step.op, as: step.as, total: result.total,
645
- slots: new Set(result.matches.map((m) => m.slot)).size, size: stored.size,
646
- });
647
- continue;
648
- }
649
-
650
- if (step.op === 'select') {
651
- // the query is the one this program COMPILED, run here rather
652
- // than handed back to `environment.select` as a document. Two
653
- // seams for one job is how a runner with a compiler wired ends
654
- // up refused by an environment without one — and the compiled
655
- // function is the better artifact anyway: compiled once at
656
- // compile time, not once per call site
657
- const name = addressOf(step.from);
658
- const raw = await environment.ledger.readSlot(name);
659
- if (raw === null || raw === undefined) return failure(step, { error: `no slot '${name}'` });
660
- /** @type {any} */
661
- let data;
662
- try { data = JSON.parse(String(raw)); }
430
+ /**
431
+ * One sub-call: the model sees ONE piece and answers with one JSON
432
+ * value. Failures are recorded, never thrown §3.
433
+ * @param name - the piece's slot
434
+ * @param [signal]
435
+ */
436
+ async function subcall(name, prompt, signal) {
437
+ const piece = await environment.read(name, { chars: subcallChars });
438
+ if (piece.error !== undefined)
439
+ return { slot: name, error: piece.error };
440
+ let reply;
441
+ // the turn is taken before the call, not after it: four sub-calls
442
+ // launched together would otherwise each see the same unspent budget
443
+ try {
444
+ reply = await createRoutedClient({
445
+ client, selectModel: options.selectModel,
446
+ limits: options.limits, onRoute: options.onRoute, account
447
+ }, { purpose: 'subcall', grammar: 'program', depth: options.depth ?? 0 }).complete({
448
+ // `stream` is deliberately NOT set. A sub-call has no UI to
449
+ // stream to, so `stream: false` looks right — and it is the
450
+ // exact request shape this package's own benchmark measured
451
+ // hanging past a 300 s deadline on the cheap tier and answering
452
+ // in seconds when streamed. Leaving it to the client's default
453
+ // (streaming) costs nothing here, because `complete` returns the
454
+ // accumulated message either way.
455
+ signal,
456
+ messages: [
457
+ {
458
+ role: 'system',
459
+ content: 'You are given ONE piece of a larger corpus and a question about it.'
460
+ + ' Answer with a single JSON value and nothing else — no prose, no code fences.'
461
+ + ' If the piece does not contain what was asked for, answer with null.',
462
+ },
463
+ { role: 'user', content: `${prompt}\n\n--- piece ${name} ---\n${piece.text}` },
464
+ ],
465
+ });
466
+ }
663
467
  catch (err) {
664
- return failure(step,
665
- { error: `slot '${name}' is not JSON: ${/** @type {Error} */ (err).message}` });
468
+ if (signal?.aborted === true)
469
+ throw err;
470
+ return { slot: name, error: `the sub-call failed: ${err.message}` };
471
+ }
472
+ const raw = String(reply?.message?.content ?? '');
473
+ // settled here rather than by the caller: this is where the call
474
+ // actually happened, and an account that only saw the calls someone
475
+ // remembered to report is not a bound
476
+ try {
477
+ return { slot: name, value: JSON.parse(unfence(raw)) };
666
478
  }
667
- const stored = await store(step, data);
668
- if (stored.error !== undefined) return failure(step, stored);
669
- report.push({ op: step.op, as: step.as, size: stored.size, count: stored.count });
670
- continue;
671
- }
672
-
673
- if (step.op === 'stat' || step.op === 'peek') {
674
- const result = step.op === 'stat'
675
- ? await environment.stat(addressOf(step.from))
676
- : await environment.peek(addressOf(step.from));
677
- if (result.error !== undefined) return failure(step, result);
678
- const stored = await environment.put(resultSlot(step.as), JSON.stringify(result),
679
- { kind: 'selection' });
680
- bindings.set(step.as, { slot: resultSlot(step.as) });
681
- report.push({ op: step.op, as: step.as, size: stored.size });
682
- continue;
683
- }
684
-
685
- if (step.op === 'map') {
686
- if (client === null && options.subcall === undefined) {
687
- return failure(step, { error: 'map needs a client — this runner was built without one' });
479
+ catch (err) {
480
+ return {
481
+ slot: name,
482
+ error: `the reply is not JSON: ${err.message}`,
483
+ raw: excerpt(raw, RAW_EXCERPT),
484
+ };
688
485
  }
689
- const members = await membersOf(target);
690
- const budgeted = members.slice(0, Math.max(0, maxSubcalls - subcalls));
691
- const skipped = members.length - budgeted.length;
692
- /** @type {string|null} */
693
- let spent = null;
694
- /** @type {any[]} */
695
- let results;
486
+ }
487
+ /**
488
+ * Execute a compiled program.
489
+ * @param [hooks]
490
+ */
491
+ async function run(doc, hooks = {}) {
492
+ const signal = hooks.signal;
493
+ const started = Date.now();
494
+ let plan;
696
495
  try {
697
- // the whole of §3's "fan-out is parallel" is this call: the
698
- // paper states its own sub-calls are sequential and that "RLMs
699
- // without asynchronous LM calls are slow", and doing the fan-out
700
- // in the harness instead of inside an evaluator is what makes
701
- // concurrency available at all. `concurrency` of 1 is the
702
- // sequential mode the benchmark compares against — one
703
- // implementation, so the comparison is of scheduling and
704
- // nothing else
705
- results = await mapConcurrent(budgeted, concurrency, (name, index) => {
706
- // BEFORE the call, at every depth: an exhausted budget
707
- // refuses rather than overruns, and the pieces it did not
708
- // reach are recorded rather than silently missing
709
- const reason = account?.stop() ?? null;
710
- if (reason !== null) {
711
- spent = reason;
712
- return Promise.resolve({ slot: name, error: `stopped: ${reason}` });
713
- }
714
- return (options.subcall ?? subcall)(name, step.prompt, signal, index);
715
- }, { signal });
496
+ plan = await compileHere(doc);
716
497
  }
717
498
  catch (err) {
718
- // the only way out of the map is an abort: a sub-call's own
719
- // failure is a value (§3), so a throw here is the run being
720
- // cancelled — the sub-calls that were in flight have settled
721
- // by now, and the partial work is kept
722
- if (signal?.aborted !== true) throw err;
723
- stopped = 'aborted';
724
- break;
499
+ // NOTHING has run: the compile reads no slot and writes none, so a
500
+ // rejected program leaves the environment exactly as it was
501
+ return {
502
+ ok: false, ran: 0, steps: [], subcalls: 0, failed: 0, concurrency,
503
+ answer: null, ms: Date.now() - started,
504
+ error: 'the program does not compile', errors: [errorRecord(err)]
505
+ };
725
506
  }
726
- subcalls += budgeted.length;
727
- for (let i = 0; i < results.length; i++) {
728
- if (results[i].error !== undefined) failed++;
729
- // Recursive failures remain distinguishable from an ordinary null
730
- // reply while satisfying the same envelope as successful leaves.
731
- if (options.recursive && results[i].error !== undefined && !Object.hasOwn(results[i], 'value'))
732
- results[i] = { ...results[i], value: null };
733
- await environment.put(`${mapFamily(step.as)}${i}`, JSON.stringify(results[i]),
734
- { kind: 'selection' });
507
+ /** binding → `{ slot }` or `{ family, count }` */
508
+ const bindings = new Map();
509
+ const report = [];
510
+ let subcalls = 0;
511
+ let failed = 0;
512
+ let stopped = null;
513
+ /** Where a step's `from` points, as an address the environment knows. */
514
+ const address = (from) => {
515
+ const binding = bindings.get(from);
516
+ if (binding === undefined)
517
+ return { slot: from };
518
+ return binding;
519
+ };
520
+ const addressOf = (from) => {
521
+ const target = address(from);
522
+ return target.slot ?? target.family;
523
+ };
524
+ /**
525
+ * Run a step's compiled query over `data`, store the result, bind it.
526
+ * `select` and `reduce` differ only in where their input comes from
527
+ * — one slot's document, or a map's collected results — so the
528
+ * running and the storing live here once.
529
+ */
530
+ async function store(step, data) {
531
+ let value;
532
+ try {
533
+ value = step.run(data);
534
+ }
535
+ catch (err) {
536
+ const e = err;
537
+ return { error: `the query failed: ${e?.reason ?? e?.message ?? err}`, code: e?.code };
538
+ }
539
+ if ((step.validateOutput && !checkOutcome(step.validateOutput(value)).valid)
540
+ || (step.recursive && recursiveItems(value) === null))
541
+ return { error: `reduce ${step.as} violates its output shape before storage`, code: 'AI0209' };
542
+ const written = await environment.put(resultSlot(step.as), JSON.stringify(value ?? null), { kind: 'selection', count: Array.isArray(value) ? value.length : undefined });
543
+ if (written.error === undefined)
544
+ bindings.set(step.as, { slot: resultSlot(step.as) });
545
+ return written;
735
546
  }
736
- bindings.set(step.as, { family: mapFamily(step.as), count: results.length });
737
- if (spent !== null) stopped = spent;
738
- report.push({
739
- op: step.op,
740
- as: step.as,
741
- subcalls: budgeted.length,
742
- failed: results.filter((r) => r.error !== undefined).length,
743
- ...(spent === null ? {} : { stopped: spent }),
744
- // never silently: a capped map that said nothing would read as
745
- // a map over everything
746
- ...(skipped > 0
747
- ? { skipped, note: `maxSubcalls ${maxSubcalls} reached — ${skipped} piece(s) not visited` }
748
- : {}),
749
- concurrency,
750
- });
751
- // a spent budget ends the run here rather than reducing over a
752
- // map it knows is incomplete: the pieces that DID answer are in
753
- // their slots, which is what makes a stopped run resumable
754
- // instead of merely failed (§2)
755
- if (spent !== null) break;
756
- continue;
757
- }
758
-
759
- if (step.op === 'reduce') {
760
- const members = await membersOf(target);
761
- /** @type {any[]} */
762
- const collected = [];
763
- let chars = 0;
764
- for (const name of members) {
765
- const text = String(await environment.ledger.readSlot(name) ?? 'null');
766
- chars += text.length;
767
- if (chars > maxReduceChars) {
768
- return failure(step, {
769
- error: `the reduce input passed ${maxReduceChars} characters at ${name} —`
770
- + ' a map that returns its input is not a reduction; narrow the map prompt',
771
- });
772
- }
773
- try {
774
- const item = JSON.parse(text);
775
- if (options.recursive && Array.isArray(item.items)) collected.push(...item.items);
776
- else collected.push(item);
777
- }
778
- catch { collected.push({ slot: name, error: 'the stored result is not JSON' }); }
547
+ for (const step of plan.steps) {
548
+ if (signal?.aborted === true) {
549
+ stopped = 'aborted';
550
+ break;
551
+ }
552
+ const target = address(step.from);
553
+ if (step.op === 'chunk') {
554
+ const result = await environment.chunk(addressOf(step.from), {
555
+ strategy: step.strategy, size: step.size,
556
+ });
557
+ if (result.error !== undefined)
558
+ return failure(step, result);
559
+ bindings.set(step.as, { family: result.family, count: result.count });
560
+ report.push({ op: step.op, as: step.as, count: result.count, family: result.family });
561
+ continue;
562
+ }
563
+ if (step.op === 'grep') {
564
+ const result = await environment.grep(step.pattern, {
565
+ in: addressOf(step.from), limit: step.limit, flags: step.flags,
566
+ });
567
+ if (result.error !== undefined)
568
+ return failure(step, result);
569
+ const stored = await environment.put(resultSlot(step.as), JSON.stringify(result), { kind: 'selection', count: result.total });
570
+ // `matches` marks this binding as a SET OF ADDRESSES: reading it
571
+ // gives the listing, mapping over it visits what it found
572
+ bindings.set(step.as, { slot: resultSlot(step.as), matches: result.matches.length });
573
+ report.push({
574
+ op: step.op, as: step.as, total: result.total,
575
+ slots: new Set(result.matches.map((m) => m.slot)).size, size: stored.size,
576
+ });
577
+ continue;
578
+ }
579
+ if (step.op === 'select') {
580
+ // the query is the one this program COMPILED, run here rather
581
+ // than handed back to `environment.select` as a document. Two
582
+ // seams for one job is how a runner with a compiler wired ends
583
+ // up refused by an environment without one — and the compiled
584
+ // function is the better artifact anyway: compiled once at
585
+ // compile time, not once per call site
586
+ const name = addressOf(step.from);
587
+ const raw = await environment.ledger.readSlot(name);
588
+ if (raw === null || raw === undefined)
589
+ return failure(step, { error: `no slot '${name}'` });
590
+ let data;
591
+ try {
592
+ data = JSON.parse(String(raw));
593
+ }
594
+ catch (err) {
595
+ return failure(step, { error: `slot '${name}' is not JSON: ${err.message}` });
596
+ }
597
+ const stored = await store(step, data);
598
+ if (stored.error !== undefined)
599
+ return failure(step, stored);
600
+ report.push({ op: step.op, as: step.as, size: stored.size, count: stored.count });
601
+ continue;
602
+ }
603
+ if (step.op === 'stat' || step.op === 'peek') {
604
+ const result = step.op === 'stat'
605
+ ? await environment.stat(addressOf(step.from))
606
+ : await environment.peek(addressOf(step.from));
607
+ if (result.error !== undefined)
608
+ return failure(step, result);
609
+ const stored = await environment.put(resultSlot(step.as), JSON.stringify(result), { kind: 'selection' });
610
+ bindings.set(step.as, { slot: resultSlot(step.as) });
611
+ report.push({ op: step.op, as: step.as, size: stored.size });
612
+ continue;
613
+ }
614
+ if (step.op === 'map') {
615
+ if (client === null && options.subcall === undefined) {
616
+ return failure(step, { error: 'map needs a client — this runner was built without one' });
617
+ }
618
+ const members = await membersOf(target);
619
+ const budgeted = members.slice(0, Math.max(0, maxSubcalls - subcalls));
620
+ const skipped = members.length - budgeted.length;
621
+ let spent = null;
622
+ let results;
623
+ try {
624
+ // the whole of §3's "fan-out is parallel" is this call: the
625
+ // paper states its own sub-calls are sequential and that "RLMs
626
+ // without asynchronous LM calls are slow", and doing the fan-out
627
+ // in the harness instead of inside an evaluator is what makes
628
+ // concurrency available at all. `concurrency` of 1 is the
629
+ // sequential mode the benchmark compares against — one
630
+ // implementation, so the comparison is of scheduling and
631
+ // nothing else
632
+ results = await mapConcurrent(budgeted, concurrency, (name, index) => {
633
+ // BEFORE the call, at every depth: an exhausted budget
634
+ // refuses rather than overruns, and the pieces it did not
635
+ // reach are recorded rather than silently missing
636
+ const reason = account?.stop() ?? null;
637
+ if (reason !== null) {
638
+ spent = reason;
639
+ return Promise.resolve({ slot: name, error: `stopped: ${reason}` });
640
+ }
641
+ return (options.subcall ?? subcall)(name, step.prompt, signal, index);
642
+ }, { signal });
643
+ }
644
+ catch (err) {
645
+ // the only way out of the map is an abort: a sub-call's own
646
+ // failure is a value (§3), so a throw here is the run being
647
+ // cancelled — the sub-calls that were in flight have settled
648
+ // by now, and the partial work is kept
649
+ if (!Boolean(signal?.aborted))
650
+ throw err;
651
+ stopped = 'aborted';
652
+ break;
653
+ }
654
+ subcalls += budgeted.length;
655
+ for (let i = 0; i < results.length; i++) {
656
+ if (results[i].error !== undefined)
657
+ failed++;
658
+ // Recursive failures remain distinguishable from an ordinary null
659
+ // reply while satisfying the same envelope as successful leaves.
660
+ if (options.recursive && results[i].error !== undefined && !Object.hasOwn(results[i], 'value'))
661
+ results[i] = { ...results[i], value: null };
662
+ await environment.put(`${mapFamily(step.as)}${i}`, JSON.stringify(results[i]), { kind: 'selection' });
663
+ }
664
+ bindings.set(step.as, { family: mapFamily(step.as), count: results.length });
665
+ if (spent !== null)
666
+ stopped = spent;
667
+ report.push({
668
+ op: step.op,
669
+ as: step.as,
670
+ subcalls: budgeted.length,
671
+ failed: results.filter((r) => r.error !== undefined).length,
672
+ ...(spent === null ? {} : { stopped: spent }),
673
+ // never silently: a capped map that said nothing would read as
674
+ // a map over everything
675
+ ...(skipped > 0
676
+ ? { skipped, note: `maxSubcalls ${maxSubcalls} reached — ${skipped} piece(s) not visited` }
677
+ : {}),
678
+ concurrency,
679
+ });
680
+ // a spent budget ends the run here rather than reducing over a
681
+ // map it knows is incomplete: the pieces that DID answer are in
682
+ // their slots, which is what makes a stopped run resumable
683
+ // instead of merely failed (§2)
684
+ if (spent !== null)
685
+ break;
686
+ continue;
687
+ }
688
+ if (step.op === 'reduce') {
689
+ const members = await membersOf(target);
690
+ const collected = [];
691
+ let chars = 0;
692
+ for (const name of members) {
693
+ const text = String(await environment.ledger.readSlot(name) ?? 'null');
694
+ chars += text.length;
695
+ if (chars > maxReduceChars) {
696
+ return failure(step, {
697
+ error: `the reduce input passed ${maxReduceChars} characters at ${name} —`
698
+ + ' a map that returns its input is not a reduction; narrow the map prompt',
699
+ });
700
+ }
701
+ try {
702
+ const item = JSON.parse(text);
703
+ if (options.recursive && Array.isArray(item.items))
704
+ collected.push(...item.items);
705
+ else
706
+ collected.push(item);
707
+ }
708
+ catch {
709
+ collected.push({ slot: name, error: 'the stored result is not JSON' });
710
+ }
711
+ }
712
+ const stored = await store(step, collected);
713
+ if (stored.error !== undefined)
714
+ return failure(step, stored);
715
+ report.push({ op: step.op, as: step.as, over: members.length, size: stored.size });
716
+ continue;
717
+ }
779
718
  }
780
- const stored = await store(step, collected);
781
- if (stored.error !== undefined) return failure(step, stored);
782
- report.push({ op: step.op, as: step.as, over: members.length, size: stored.size });
783
- continue;
784
- }
785
- }
786
-
787
- /** @param {any} step @param {any} result @returns {ProgramRunResult} */
788
- function failure(step, result) {
789
- return {
790
- ok: false,
791
- answer: null,
792
- ran: report.length,
793
- steps: report,
794
- subcalls,
795
- failed,
796
- concurrency,
797
- ...(stopped === null ? {} : { stopped }),
798
- error: result.error,
799
- errors: [{ code: result.code ?? 'AI0200', docPath: `/steps/${step.index}`, message: result.error }],
800
- ms: Date.now() - started,
801
- };
719
+ /** @param step @param result @returns */
720
+ function failure(step, result) {
721
+ return {
722
+ ok: false,
723
+ answer: null,
724
+ ran: report.length,
725
+ steps: report,
726
+ subcalls,
727
+ failed,
728
+ concurrency,
729
+ ...(stopped === null ? {} : { stopped }),
730
+ error: result.error,
731
+ errors: [{ code: result.code ?? 'AI0200', docPath: `/steps/${step.index}`, message: result.error }],
732
+ ms: Date.now() - started,
733
+ };
734
+ }
735
+ const answered = stopped === null
736
+ ? await environment.read(addressOf(plan.answer.from), { chars: plan.answer.chars })
737
+ : { error: `the run was ${stopped}` };
738
+ const metrics = {
739
+ steps: report,
740
+ ran: report.length,
741
+ subcalls,
742
+ failed,
743
+ concurrency,
744
+ ms: Date.now() - started,
745
+ };
746
+ if (answered.error !== undefined)
747
+ return {
748
+ ...metrics, ok: false, answer: null, error: answered.error,
749
+ ...(stopped === null ? {} : { stopped }),
750
+ };
751
+ return {
752
+ ...metrics, ok: true,
753
+ // the one place content comes back, and it is the step the program
754
+ // asked for by name
755
+ answer: {
756
+ slot: answered.name, size: answered.size, text: answered.text,
757
+ truncated: answered.text.length < answered.size
758
+ },
759
+ };
802
760
  }
803
-
804
- const answered = stopped === null
805
- ? await environment.read(addressOf(plan.answer.from), { chars: plan.answer.chars })
806
- : { error: `the run was ${stopped}` };
807
-
808
- const metrics = {
809
- steps: report,
810
- ran: report.length,
811
- subcalls,
812
- failed,
813
- concurrency,
814
- ms: Date.now() - started,
815
- };
816
- if (answered.error !== undefined) return {
817
- ...metrics, ok: false, answer: null, error: answered.error,
818
- ...(stopped === null ? {} : { stopped }),
819
- };
820
- return {
821
- ...metrics, ok: true,
822
- // the one place content comes back, and it is the step the program
823
- // asked for by name
824
- answer: { slot: answered.name, size: answered.size, text: answered.text,
825
- truncated: answered.text.length < answered.size },
826
- };
827
- }
828
-
829
- // `run` only: a `compile` here would be a second, WEAKER compile than
830
- // the one `run` does (it could not resolve names against the
831
- // environment), and callers who want to check a document without
832
- // running it have `compileProgram` and `programGate` directly
833
- return { run };
761
+ // `run` only: a `compile` here would be a second, WEAKER compile than
762
+ // the one `run` does (it could not resolve names against the
763
+ // environment), and callers who want to check a document without
764
+ // running it have `compileProgram` and `programGate` directly
765
+ return { run };
834
766
  }
835
-
836
767
  //#endregion
837
-
838
768
  //#region authoring
839
-
840
769
  /**
841
770
  * The worked example. Field notes: prose describes the shape, an example
842
771
  * FIXES it — a small model told in words to plan over slots still tends
843
772
  * to emit one step that does everything.
844
773
  *
845
- * Its query is a real one and it COMPILES; `test/ai/program.test.js`
774
+ * Its query is a real one and it COMPILES; `test/agents/program.test.ts`
846
775
  * asserts that. An example carrying a query the engine rejects would be
847
776
  * teaching the failure it is meant to prevent, and the model would have
848
777
  * copied it before the gate ever saw it.
849
778
  */
850
779
  const EXAMPLE = {
851
- steps: [
852
- { op: 'chunk', from: 'corpus', as: 'pieces', strategy: 'line', size: 2000 },
853
- { op: 'map', from: 'pieces', as: 'found', prompt: 'Return the record in this piece as {"id":…,"value":…}.' },
854
- { op: 'reduce', from: 'found', as: 'summary', query: { $for: { r: '$[*].value' }, $return: '$r.value' } },
855
- { op: 'answer', from: 'summary' },
856
- ],
780
+ steps: [
781
+ { op: 'chunk', from: 'corpus', as: 'pieces', strategy: 'line', size: 2000 },
782
+ { op: 'map', from: 'pieces', as: 'found', prompt: 'Return the record in this piece as {"id":…,"value":…}.' },
783
+ { op: 'reduce', from: 'found', as: 'summary', query: { $for: { r: '$[*].value' }, $return: '$r.value' } },
784
+ { op: 'answer', from: 'summary' },
785
+ ],
857
786
  };
858
-
859
787
  /** The example, so a test can compile it. Exported for exactly that:
860
788
  * the prompt's correctness is a property worth a gate. */
861
789
  export const PROGRAM_EXAMPLE = EXAMPLE;
862
-
863
790
  /**
864
791
  * Author a program with a model, gated on the compiler.
865
792
  *
@@ -869,72 +796,70 @@ export const PROGRAM_EXAMPLE = EXAMPLE;
869
796
  * decoding, the compile gate constrains meaning, and a rejected
870
797
  * candidate goes back with its code and its pointer.
871
798
  *
872
- * @param {{ client: any, environment: any, compileQuery?: any,
873
- * recursive?: boolean, analyzeQuery?: any, annotateTypes?: any,
874
- * selectModel?: any, limits?: any, onRoute?: any, depth?: number, account?: any,
875
- * createStructuredOutput: (options: any) => { generate: Function },
876
- * querySchema?: any, maxRepairs?: number, system?: string }} options
877
799
  * - `createStructuredOutput` is injected rather than imported so a
878
800
  * caller can wrap it (a probe counting attempts, a cache); the
879
801
  * package's own is the obvious argument.
880
802
  * - `querySchema` is the published query grammar. Given, `select` and
881
803
  * `reduce` are shape-constrained too and it is registered as a
882
804
  * `$ref`; absent, the compile gate carries it alone (D3).
883
- * @returns {{ author: (question: string, hooks?: { signal?: AbortSignal }) => Promise<any> }}
884
805
  */
885
806
  export function createProgramAuthor(options) {
886
- const { environment, createStructuredOutput: structured } = options;
887
- const compileQuery = options.compileQuery ?? null;
888
- const querySchema = options.querySchema ?? null;
889
- const schema = programSchema(querySchema === null ? {} : { queryRef: querySchema.$id });
890
-
891
- async function author(question, hooks = {}) {
892
- const digest = await environment.digest();
893
- const known = (await environment.ledger.listSlots()).map((s) => s.name);
894
- const generate = structured({
895
- client: createRoutedClient(options, { purpose: 'author', grammar: 'program', depth: options.depth ?? 0 }),
896
- schema,
897
- name: 'jaren_program',
898
- strict: false,
899
- ...(querySchema === null ? {} : { refs: [querySchema] }),
900
- gate: programGate({ compileQuery, known, recursive: options.recursive,
901
- analyzeQuery: options.analyzeQuery, annotateTypes: options.annotateTypes }),
902
- maxRepairs: options.maxRepairs ?? 2,
903
- });
904
-
905
- const result = await generate.generate([
906
- {
907
- role: 'system',
908
- content: options.system ?? 'You plan work over a corpus you cannot see.'
909
- + ' You are given only the NAMES and sizes of the slots that hold it.'
910
- + ' Write a program whose steps name those slots. Never paste content into a step.'
911
- + ' Use map to ask a question of every piece — it is the only step that reads text —'
912
- + ' and reduce to combine what the map found. Here is a program in the right shape:\n'
913
- + JSON.stringify(options.recursive ? { steps: EXAMPLE.steps.map((step) => step.op === 'reduce'
914
- ? { ...step, query: ['$[*]'],
915
- outputSchema: { type: 'array', items: { type: 'object',
916
- properties: { slot: { type: 'string' }, value: {} }, required: ['slot', 'value'] } } }
917
- : step) } : EXAMPLE)
918
- + (options.recursive ? '\nEach map result wraps the parsed leaf reply in {slot:string,value:any}. '
919
- + 'Failed map entries have value:null and an error diagnostic; preserve both, and distinguish them from a successful null reply. '
920
- + 'The worked reducer preserves every envelope, including null leaf values. An array constructor [expr] collects a sequence into one array; '
921
- + 'a bare wildcard in an object member fails when it produces multiple items. '
922
- + 'Recursive reduce MUST return {slot:string,value:any}, with value matching the leaf reply, or a sequence of these envelopes. '
923
- + 'Use min/max only for a sequence of numbers or strings, never to combine general facts. '
924
- + 'Declare outputSchema on reduce when inference is unavailable. A final answer preview may be truncated; inspect its truncated flag.' : ''),
925
- },
926
- {
927
- role: 'user',
928
- content: `The environment holds:\n${JSON.stringify(digest.slots)}\n`
929
- + `(${digest.total} slot(s), ${digest.size} characters in total)\n\n`
930
- + `Question: ${question}`,
931
- },
932
- ], { signal: hooks.signal });
933
-
934
- return result;
935
- }
936
-
937
- return { author };
807
+ const { environment, createStructuredOutput: structured } = options;
808
+ const compileQuery = options.compileQuery ?? null;
809
+ const querySchema = options.querySchema ?? null;
810
+ const schema = programSchema(querySchema === null ? {} : { queryRef: querySchema.$id });
811
+ async function author(question, hooks = {}) {
812
+ const digest = await environment.digest();
813
+ const known = (await environment.ledger.listSlots()).map((s) => s.name);
814
+ const generate = structured({
815
+ client: createRoutedClient(options, { purpose: 'author', grammar: 'program', depth: options.depth ?? 0 }),
816
+ schema,
817
+ name: 'jaren_program',
818
+ strict: false,
819
+ ...(querySchema === null ? {} : { refs: [querySchema] }),
820
+ gate: programGate({
821
+ compileQuery, known, recursive: options.recursive,
822
+ analyzeQuery: options.analyzeQuery, annotateTypes: options.annotateTypes
823
+ }),
824
+ maxRepairs: options.maxRepairs ?? 2,
825
+ });
826
+ const result = await generate.generate([
827
+ {
828
+ role: 'system',
829
+ content: options.system ?? 'You plan work over a corpus you cannot see.'
830
+ + ' You are given only the NAMES and sizes of the slots that hold it.'
831
+ + ' Write a program whose steps name those slots. Never paste content into a step.'
832
+ + ' Use map to ask a question of every piece — it is the only step that reads text —'
833
+ + ' and reduce to combine what the map found. Here is a program in the right shape:\n'
834
+ + JSON.stringify(options.recursive ? {
835
+ steps: EXAMPLE.steps.map((step) => step.op === 'reduce'
836
+ ? {
837
+ ...step, query: ['$[*]'],
838
+ outputSchema: {
839
+ type: 'array', items: {
840
+ type: 'object',
841
+ properties: { slot: { type: 'string' }, value: {} }, required: ['slot', 'value']
842
+ }
843
+ }
844
+ }
845
+ : step)
846
+ } : EXAMPLE)
847
+ + (options.recursive ? '\nEach map result wraps the parsed leaf reply in {slot:string,value:any}. '
848
+ + 'Failed map entries have value:null and an error diagnostic; preserve both, and distinguish them from a successful null reply. '
849
+ + 'The worked reducer preserves every envelope, including null leaf values. An array constructor [expr] collects a sequence into one array; '
850
+ + 'a bare wildcard in an object member fails when it produces multiple items. '
851
+ + 'Recursive reduce MUST return {slot:string,value:any}, with value matching the leaf reply, or a sequence of these envelopes. '
852
+ + 'Use min/max only for a sequence of numbers or strings, never to combine general facts. '
853
+ + 'Declare outputSchema on reduce when inference is unavailable. A final answer preview may be truncated; inspect its truncated flag.' : ''),
854
+ },
855
+ {
856
+ role: 'user',
857
+ content: `The environment holds:\n${JSON.stringify(digest.slots)}\n`
858
+ + `(${digest.total} slot(s), ${digest.size} characters in total)\n\n`
859
+ + `Question: ${question}`,
860
+ },
861
+ ], { signal: hooks.signal });
862
+ return result;
863
+ }
864
+ return { author };
938
865
  }
939
-
940
- //#endregion