@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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +854 -0
- package/package.json +85 -0
- package/src/agent.d.ts +160 -0
- package/src/agent.js +1021 -0
- package/src/index.d.ts +11 -0
- package/src/index.js +13 -0
- package/src/program-result.d.ts +111 -0
- package/src/program-result.js +48 -0
- package/src/program-session.d.ts +48 -0
- package/src/program-session.js +121 -0
- package/src/program-shape.d.ts +21 -0
- package/src/program-shape.js +53 -0
- package/src/program.d.ts +244 -0
- package/src/program.js +940 -0
- package/src/recursive.d.ts +148 -0
- package/src/recursive.js +384 -0
- package/src/refine.d.ts +58 -0
- package/src/refine.js +599 -0
- package/src/schemas/program.d.ts +82 -0
- package/src/schemas/program.js +205 -0
- package/src/toolbox.d.ts +55 -0
- package/src/toolbox.js +178 -0
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
|