@tangleai/agents 0.21.1

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 ADDED
@@ -0,0 +1,940 @@
1
+ //@ts-check
2
+ /**
3
+ * The action language: compile it, then run it.
4
+ *
5
+ * HORIZON_06 put the corpus outside the context and gave the root a
6
+ * digest instead. That fixed what the model *sees*; this file fixes what
7
+ * it can *do*. The model authors a small program naming slots and
8
+ * operations, the program is compiled before anything runs, and the
9
+ * harness executes it — fanning model sub-calls out over the pieces in
10
+ * parallel and writing every result back to a slot.
11
+ *
12
+ * Why this closes the pairwise question. A relation over every record
13
+ * needs every record, and no summariser and no `recall` can put forty
14
+ * rounds into a budget they were cut to fit. A program does not have to:
15
+ * `map` visits all forty pieces, each sub-call reads one of them, and
16
+ * `reduce` computes over the forty small results. The root never sees
17
+ * any of it — it sees a plan going out and one slot's metadata coming
18
+ * back — so the request stays the same size whether the corpus is ten
19
+ * kilobytes or ten megabytes.
20
+ *
21
+ * Four rules hold without exception:
22
+ *
23
+ * - **A program that does not compile never runs.** `run` compiles
24
+ * first and returns the compile errors; no step has executed and no
25
+ * slot has been written when it does. The errors are coded and
26
+ * `docPath`'d into the program document, which is the error class
27
+ * this package's field notes say small models actually repair.
28
+ * - **`map` is the only step that calls a model** — so `maxSubcalls`
29
+ * and `maxConcurrentSubcalls` are the whole cost model, and one place
30
+ * threads the `AbortSignal`.
31
+ * - **A sub-call failure is a result, not a crash.** It lands as
32
+ * `{ error }` in its own result slot and the map completes, exactly
33
+ * as the toolbox never throws for content-level problems. A map whose
34
+ * sub-calls all failed is a finished map with forty recorded errors.
35
+ * - **Nothing bulk comes back.** Every step reports metadata; the one
36
+ * place content returns to the caller is the final `answer` step, and
37
+ * it is capped.
38
+ *
39
+ * **The decision this file exists to record** (asked once per reader, so
40
+ * it is answered here): why not register an async `$llm` operator into
41
+ * the JSLT registry and let a stylesheet call a model inline? Because
42
+ * `@jarenjs/core`'s operators are pure synchronous functions and the
43
+ * query/JSLT evaluators are synchronous by construction. Making them
44
+ * async to accommodate one caller would change an engine every other
45
+ * package in the suite depends on — a `queryJson` that returned a
46
+ * promise would break `@jarenjs/db`'s pushdown, `@jarenjs/md`'s
47
+ * directives and `@jarenjs/app`'s state derivation, all to save this
48
+ * package a `map` step. So the division is fixed: **the program selects
49
+ * (pure, synchronous, compiled) and the harness awaits (async, bounded,
50
+ * cancellable).** `map` is the seam between the two halves, and it is
51
+ * the only one.
52
+ */
53
+
54
+ import { CodedError } from '@jarenjs/core/errors';
55
+ import { excerpt } from '@jarenjs/core/chunk';
56
+ import { mapConcurrent } from '@jarenjs/core/async';
57
+ import { JarenValidator } from '@jarenjs/validate';
58
+
59
+ import { MAX_PROGRAM_CHARS, NAME_PATTERN, PROGRAM_SCHEMA, programSchema } from './schemas/program.js';
60
+ import { checkOutcome } from '@jarenjs/core/check';
61
+ import { unfence } from '@tangleai/models/structured';
62
+ import { recursiveShape, recursiveSchema, recursiveItems } from './program-shape.js';
63
+ import { createRoutedClient } from '@tangleai/models/routing';
64
+
65
+ export { readProgramAnswer } from './program-result.js';
66
+ /** @typedef {import('./program-result.js').ProgramRunResult} ProgramRunResult */
67
+
68
+ /** How much of one piece a sub-call is shown. The sub-call is the only
69
+ * place content reaches a model at all, and it sees ONE piece — a cap
70
+ * here is the difference between a bounded fan-out and the corpus
71
+ * arriving in a different envelope. */
72
+ const SUBCALL_CHARS = 8000;
73
+
74
+ /** Sub-calls one run may make, whatever the corpus. A hard ceiling, not
75
+ * a hint: depth × fan-out is multiplicative and a runaway map is a bill. */
76
+ const MAX_SUBCALLS = 64;
77
+
78
+ /** Sub-calls in flight at once. Four is the paper's limitation fixed —
79
+ * it reports its own sub-calls are sequential and slow — and low enough
80
+ * that a free-tier provider does not answer with 429s. */
81
+ const MAX_CONCURRENT = 4;
82
+
83
+ /** The characters a reduce may assemble from its map's results. The
84
+ * reduction is small BY CONSTRUCTION (that is what a map is for), so
85
+ * this cap is a tripwire on a program that mapped identity over a
86
+ * corpus, not a working limit. */
87
+ const MAX_REDUCE_CHARS = 200000;
88
+
89
+ /** How much of the answer slot the final step returns unasked. */
90
+ const ANSWER_CHARS = 2000;
91
+
92
+ /** How much of a failed reply is kept beside its error. */
93
+ const RAW_EXCERPT = 200;
94
+
95
+ /** The namespace a run's own results live under, kept apart from the
96
+ * corpus so a digest can tell working notes from the thing being worked
97
+ * on — and so a re-run overwrites rather than accumulating. */
98
+ const RESULT_PREFIX = 'program/';
99
+
100
+ const NAME_RE = new RegExp(NAME_PATTERN);
101
+
102
+ /**
103
+ * A program that will not compile.
104
+ *
105
+ * Thrown, unlike `refine.js`'s AI01xx records, because this is a
106
+ * COMPILER and the suite's compilers throw coded errors with a
107
+ * `docPath` — which is what makes the documented two-line gate adapter
108
+ * (`try { compile(doc) } catch (e) { … }`) work here exactly as it does
109
+ * for a query, a stylesheet or a flow machine. {@link programGate} is
110
+ * that adapter, so a caller never writes it.
111
+ *
112
+ * The codes:
113
+ *
114
+ * AI0200 — the document is not a program
115
+ * AI0201 — a step reads a name nothing produced
116
+ * AI0202 — a name is bound twice
117
+ * AI0203 — the last step is not `answer`, or there is more than one
118
+ * AI0204 — a `reduce` reads something that is not a `map`
119
+ * AI0205 — the program is longer than its cap
120
+ * AI0206 — a step needs the query seam and none is wired
121
+ * AI0207 — a step reading ONE slot was given a family of pieces
122
+ * AI0208 — a recursive result has an incompatible or unknown envelope
123
+ * AI0209 — a runtime result violates its declared recursive shape
124
+ *
125
+ * A query that does not compile keeps the QUERY engine's own code
126
+ * (`JQ0002`, …) and its pointer is rebased onto the program document,
127
+ * so the model is told which step and which member — the same posture
128
+ * `refine.js` takes with the patch engine's codes.
129
+ */
130
+ 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
+ }
139
+ }
140
+
141
+ /**
142
+ * One error in the shape a repair prompt carries. A rebased query error
143
+ * keeps its engine code and gains the step it came from.
144
+ * @param {any} err
145
+ * @returns {{ code: string, docPath: string, message: string }}
146
+ */
147
+ function errorRecord(err) {
148
+ return {
149
+ code: err?.code ?? 'AI0200',
150
+ docPath: err?.docPath ?? '',
151
+ message: err?.reason ?? err?.message ?? String(err),
152
+ };
153
+ }
154
+
155
+ //#region the compiler
156
+
157
+ /**
158
+ * Compile a program document.
159
+ *
160
+ * Two stages, like every compiler in the suite: this one resolves names
161
+ * and compiles the embedded queries once, and the runner executes the
162
+ * result. Nothing here reads a slot's content and nothing here calls a
163
+ * model, so a compile is cheap enough to run on every authored candidate
164
+ * — which is exactly what makes it usable as a decoding gate.
165
+ *
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]
169
+ * - `compileQuery` is the D3 seam. Absent, a program using `select` or
170
+ * `reduce` is refused with AI0206 rather than half-compiled.
171
+ * - `known` is what the environment already holds. Given, a `from`
172
+ * that is neither a binding nor a known slot is AI0201 — the
173
+ * "transition to an undeclared state" check, which is the whole
174
+ * reason a compile gate catches what a schema cannot. Absent, only
175
+ * bindings are resolved: nothing else can be checked without an
176
+ * 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 }}
179
+ * @throws {ProgramError}
180
+ */
181
+ 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`);
241
+ }
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`);
299
+ }
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;
321
+ }
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
+ };
344
+ }
345
+
346
+ /**
347
+ * The compile gate, ready for `createStructuredOutput({ gate })`.
348
+ *
349
+ * One implementation of "does this program compile", used by the
350
+ * authoring path and by the runner, so a program that authored cleanly
351
+ * 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[] }}
354
+ */
355
+ 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
+ };
365
+ }
366
+
367
+ //#endregion
368
+
369
+ //#region the runner
370
+
371
+ /** The address a step's result is stored under, derived from its binding. */
372
+ const resultSlot = (as) => `${RESULT_PREFIX}${as}`;
373
+
374
+ /** The family a map's per-piece results live under. */
375
+ const mapFamily = (as) => `${RESULT_PREFIX}${as}/`;
376
+
377
+ /**
378
+ * Create a runner over an environment.
379
+ *
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
+ * - `client` is only needed by `map`; a program without one is a
392
+ * perfectly good program (chunk / grep / select / stat / answer are
393
+ * model-free), and running a `map` without a client is a stated
394
+ * refusal rather than a crash.
395
+ * - `sequential` runs sub-calls one at a time. It exists so the
396
+ * benchmark can publish parallel against sequential wall-clock with
397
+ * the same code path on both sides.
398
+ * - `subcall` REPLACES what one piece of a map is worth. The default
399
+ * asks the model about it; a recursive run passes a function that
400
+ * spawns a child agent over that piece instead. This is a seam
401
+ * rather than a second runner because the bound, the ordering, the
402
+ * abort and the error capture must be identical at every depth —
403
+ * one fan-out implementation, two things to fan out over.
404
+ * - `account` is a budget shared with everything else in the run,
405
+ * including other depths. Checked before each sub-call and charged
406
+ * by it, so a tree cannot outspend the sum of its branches.
407
+ * @returns {{ run: (doc: any, hooks?: { signal?: AbortSignal }) => Promise<ProgramRunResult> }}
408
+ */
409
+ 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)] };
570
+ }
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
+ /**
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
599
+ */
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;
617
+ }
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)); }
663
+ catch (err) {
664
+ return failure(step,
665
+ { error: `slot '${name}' is not JSON: ${/** @type {Error} */ (err).message}` });
666
+ }
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' });
688
+ }
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;
696
+ 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 });
716
+ }
717
+ 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;
725
+ }
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' });
735
+ }
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' }); }
779
+ }
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
+ };
802
+ }
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 };
834
+ }
835
+
836
+ //#endregion
837
+
838
+ //#region authoring
839
+
840
+ /**
841
+ * The worked example. Field notes: prose describes the shape, an example
842
+ * FIXES it — a small model told in words to plan over slots still tends
843
+ * to emit one step that does everything.
844
+ *
845
+ * Its query is a real one and it COMPILES; `test/ai/program.test.js`
846
+ * asserts that. An example carrying a query the engine rejects would be
847
+ * teaching the failure it is meant to prevent, and the model would have
848
+ * copied it before the gate ever saw it.
849
+ */
850
+ 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
+ ],
857
+ };
858
+
859
+ /** The example, so a test can compile it. Exported for exactly that:
860
+ * the prompt's correctness is a property worth a gate. */
861
+ export const PROGRAM_EXAMPLE = EXAMPLE;
862
+
863
+ /**
864
+ * Author a program with a model, gated on the compiler.
865
+ *
866
+ * The root sees the environment's DIGEST and the question — never a
867
+ * slot's content (D2) — so the authoring request is the same size for a
868
+ * ten-kilobyte corpus and a ten-megabyte one. The schema constrains
869
+ * decoding, the compile gate constrains meaning, and a rejected
870
+ * candidate goes back with its code and its pointer.
871
+ *
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
+ * - `createStructuredOutput` is injected rather than imported so a
878
+ * caller can wrap it (a probe counting attempts, a cache); the
879
+ * package's own is the obvious argument.
880
+ * - `querySchema` is the published query grammar. Given, `select` and
881
+ * `reduce` are shape-constrained too and it is registered as a
882
+ * `$ref`; absent, the compile gate carries it alone (D3).
883
+ * @returns {{ author: (question: string, hooks?: { signal?: AbortSignal }) => Promise<any> }}
884
+ */
885
+ 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 };
938
+ }
939
+
940
+ //#endregion