@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/refine.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Refinement: how an agent's durable state improves without anyone
|
|
4
|
+
* rewriting it.
|
|
5
|
+
*
|
|
6
|
+
* After a run, the model is asked what it learned. The answer is not
|
|
7
|
+
* prose and it is not a new system prompt — it is an RFC 6902 JSON Patch
|
|
8
|
+
* over the ledger's supplemental state, generated through
|
|
9
|
+
* `createStructuredOutput` and put through four stages before any of it
|
|
10
|
+
* lands:
|
|
11
|
+
*
|
|
12
|
+
* 1. **shape** — the patch validates against the constrained schema
|
|
13
|
+
* (`schemas/patch.js`): three verbs, a path pattern that matches
|
|
14
|
+
* only the supplemental subtree, a cap on the number of operations.
|
|
15
|
+
* 2. **semantics** — the patch is applied to a COPY of the state
|
|
16
|
+
* through the injected patch engine. An operation that cannot apply
|
|
17
|
+
* is a compile-style error with a pointer into the patch document,
|
|
18
|
+
* which is exactly the error class this package's field notes say
|
|
19
|
+
* small models repair well.
|
|
20
|
+
* 3. **legality** — every record the patched document would store is
|
|
21
|
+
* validated against the LEDGER's own schemas. A memory without
|
|
22
|
+
* evidence dies here, before anything is written, so the model can
|
|
23
|
+
* be told why and try again.
|
|
24
|
+
* 4. **commit** — a snapshot is taken, then the writes go through the
|
|
25
|
+
* ledger's own API. Any rejection rolls the whole thing back: there
|
|
26
|
+
* is no half-applied refinement.
|
|
27
|
+
*
|
|
28
|
+
* Why a patch rather than a rewrite: a rewrite is unreviewable and
|
|
29
|
+
* unbounded, and a model asked to restate its memories will drift them.
|
|
30
|
+
* A patch is small, auditable, and undone by the snapshot from the
|
|
31
|
+
* ledger. Two things follow from that and are worth saying out loud:
|
|
32
|
+
*
|
|
33
|
+
* - **The base system prompt is not a patch target.** Not "should not
|
|
34
|
+
* be" — it is not IN the document a patch is applied to, and no path
|
|
35
|
+
* that could reach it matches the schema's pattern. There is no
|
|
36
|
+
* operation a model can write that edits its own instructions.
|
|
37
|
+
* - **Every stored memory carries evidence**, because the ledger
|
|
38
|
+
* rejects one that does not. That is the mechanism by which
|
|
39
|
+
* "evidence-backed updates" is enforced rather than hoped for.
|
|
40
|
+
*
|
|
41
|
+
* The patch engine is INJECTED (`applyPatch`), like every other heavy
|
|
42
|
+
* thing this package touches: `@jarenjs/json` ships an RFC 6902 engine
|
|
43
|
+
* and this package must not import it. With the seam empty, refinement
|
|
44
|
+
* declines with a stated reason and the rest of the ledger is unaffected
|
|
45
|
+
* — the same degrade-to-nothing posture as the retrieval seam.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
49
|
+
|
|
50
|
+
import { checkOutcome } from '@jarenjs/core/check';
|
|
51
|
+
import { createGuardedRefiner } from '@jarenjs/core/guarded';
|
|
52
|
+
import { createStructuredOutput } from '@tangleai/models/structured';
|
|
53
|
+
import { REFINEMENT_PATCH_SCHEMA, refinementPatchSchema } from '@tangleai/context/schemas/patch';
|
|
54
|
+
import { excerpt, truncate } from '@jarenjs/core/chunk';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The id stood in while a proposal is validated. A proposal never
|
|
58
|
+
* carries an id — identity is the ledger's to mint, or a model could
|
|
59
|
+
* overwrite a record it never read — so the record validated at stage 3
|
|
60
|
+
* is the record that will be stored with the id filled in at stage 4.
|
|
61
|
+
* The placeholder is the one member that differs, and it is a legal id
|
|
62
|
+
* (non-empty string), so it exercises the same schema the real one will.
|
|
63
|
+
*/
|
|
64
|
+
const PENDING_ID = '(minted on commit)';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The members a proposal may leave out and the ledger fills in. They
|
|
68
|
+
* mirror `addMemory`/`addSkill`, and they are here because stage 3 has
|
|
69
|
+
* to validate the record that WILL be stored rather than the shorter one
|
|
70
|
+
* that was proposed — a memory with no `tags` is storable, and a gate
|
|
71
|
+
* that rejected it would be rejecting the ledger's own default.
|
|
72
|
+
*
|
|
73
|
+
* The two copies cannot drift silently: the ledger validates again on
|
|
74
|
+
* the way in, so a divergence surfaces as a rolled-back commit naming
|
|
75
|
+
* the member, not as a record that should never have been written.
|
|
76
|
+
*/
|
|
77
|
+
const RECORD_DEFAULTS = { memory: { tags: [] }, skill: { tools: [] } };
|
|
78
|
+
|
|
79
|
+
/** How much of a run's trajectory travels in the proposal prompt. */
|
|
80
|
+
const TRAJECTORY_CHARS = 4000;
|
|
81
|
+
|
|
82
|
+
/** The per-line cap inside that block. */
|
|
83
|
+
const LINE_CHARS = 200;
|
|
84
|
+
|
|
85
|
+
/** @param {any} value */
|
|
86
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
87
|
+
|
|
88
|
+
/** Structural equality over JSON values, by canonical-enough comparison. */
|
|
89
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One error record, in the shape the repair prompt carries: a code, a
|
|
93
|
+
* pointer and a reason. `docPath` points into the PATCH document
|
|
94
|
+
* wherever the failure can be attributed to one operation, because
|
|
95
|
+
* "which operation was wrong" is the whole difference between a repair
|
|
96
|
+
* round that converges and one that flails.
|
|
97
|
+
*
|
|
98
|
+
* The codes this module raises, all of them records rather than throws
|
|
99
|
+
* (`errors.js` reserves `AiError` for transport and misuse; a rejected
|
|
100
|
+
* proposal is neither — it is something a model reads and fixes):
|
|
101
|
+
*
|
|
102
|
+
* AI0100 — a supplemental record is not an object
|
|
103
|
+
* AI0101 — a proposal chose its own id
|
|
104
|
+
* AI0102 — the record is not storable (the ledger's own validation
|
|
105
|
+
* keyword travels in place of this wherever it has one)
|
|
106
|
+
* AI0103 — progress proposed with no active goal
|
|
107
|
+
* AI0104 — the goal itself was changed, not just its progress
|
|
108
|
+
* AI0105 — progress is not append-only
|
|
109
|
+
* AI0106 — the patched state is not an object
|
|
110
|
+
*
|
|
111
|
+
* A failure inside the injected patch engine keeps THAT engine's code
|
|
112
|
+
* (`JP0004`, `JP2001`, …) rather than being renumbered here: the pointer
|
|
113
|
+
* and the code are what the model repairs from, and translating them
|
|
114
|
+
* would lose the only precise thing about them.
|
|
115
|
+
* @param {string} code
|
|
116
|
+
* @param {string} message
|
|
117
|
+
* @param {string} docPath
|
|
118
|
+
*/
|
|
119
|
+
const problem = (code, message, docPath) => ({ code, docPath, message });
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The coded, pointered form of whatever the injected patch engine threw.
|
|
123
|
+
* `@jarenjs/json` raises `JsonPatchCompileError` / `JsonPatchRuntimeError`
|
|
124
|
+
* with a `code`, a `docPath` into the patch and (at runtime) a
|
|
125
|
+
* `dataPath` into the document; a different engine may raise anything.
|
|
126
|
+
* Both end up as one record the model can act on.
|
|
127
|
+
* @param {any} error
|
|
128
|
+
*/
|
|
129
|
+
function patchFailure(error) {
|
|
130
|
+
const code = typeof error?.code === 'string' ? error.code : 'PATCH';
|
|
131
|
+
const docPath = typeof error?.docPath === 'string' ? error.docPath : '';
|
|
132
|
+
const reason = error?.reason ?? error?.message ?? String(error);
|
|
133
|
+
const at = typeof error?.dataPath === 'string' && error.dataPath !== ''
|
|
134
|
+
? ` (target '${error.dataPath}' in the supplemental state)`
|
|
135
|
+
: '';
|
|
136
|
+
return problem(code, `the patch does not apply: ${reason}${at}`, docPath);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The run, as the few hundred characters worth putting in front of the
|
|
141
|
+
* model. Tool steps are preferred over wire messages: the steps ARE the
|
|
142
|
+
* evidence a memory would cite, and a transcript's assistant turns are
|
|
143
|
+
* mostly the model reading its own prose back.
|
|
144
|
+
* @param {any} trajectory - a `send` result, its `steps`, or wire messages
|
|
145
|
+
* @param {number} max
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function describeTrajectory(trajectory, max = TRAJECTORY_CHARS) {
|
|
149
|
+
const steps = Array.isArray(trajectory?.steps) ? trajectory.steps : null;
|
|
150
|
+
const messages = Array.isArray(trajectory)
|
|
151
|
+
? trajectory
|
|
152
|
+
: (Array.isArray(trajectory?.messages) ? trajectory.messages : null);
|
|
153
|
+
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
const lines = [];
|
|
156
|
+
if (steps !== null && steps.length > 0) {
|
|
157
|
+
for (const step of steps) {
|
|
158
|
+
lines.push(`- ${step.name}(${excerpt(step.arguments, 80)}) → `
|
|
159
|
+
+ `${excerpt(JSON.stringify(step.result ?? null), LINE_CHARS)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (messages !== null) {
|
|
163
|
+
for (const message of messages) {
|
|
164
|
+
if (message?.role === 'system') continue;
|
|
165
|
+
if (steps !== null && steps.length > 0 && message?.role === 'tool') continue;
|
|
166
|
+
const text = excerpt(message?.content, LINE_CHARS);
|
|
167
|
+
if (text !== '') lines.push(`- ${message.role}: ${text}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return truncate(lines.join('\n'), max);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create a refiner over a ledger.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ client: any, ledger: any,
|
|
177
|
+
* applyPatch?: ((document: any, patch: any[]) => any) | null,
|
|
178
|
+
* maxOps?: number, maxRepairs?: number, validator?: any,
|
|
179
|
+
* now?: () => string, trajectoryChars?: number,
|
|
180
|
+
* instructions?: string, deduplicate?: 'exact-evidence' }} options
|
|
181
|
+
* - `applyPatch` is the RFC 6902 seam: `(document, patch) => document`,
|
|
182
|
+
* normally `(doc, patch) => applyJSONPatch(doc, patch)` from
|
|
183
|
+
* `@jarenjs/json`. Absent, `refine`/`commit` decline with a stated
|
|
184
|
+
* reason rather than half-working.
|
|
185
|
+
* - `maxOps` caps the operations one refinement may propose (default
|
|
186
|
+
* 6, from the schema).
|
|
187
|
+
* - `maxRepairs` is how many failed rounds go back to the model with
|
|
188
|
+
* the errors before it declines (default 1).
|
|
189
|
+
* - `instructions` replaces the proposal prompt's task description
|
|
190
|
+
* (the state, the rules and the trajectory are always appended).
|
|
191
|
+
* - `now` returns an RFC 3339 timestamp, injected for deterministic
|
|
192
|
+
* tests exactly as the ledger injects its clock.
|
|
193
|
+
* - `deduplicate: 'exact-evidence'` skips new memories with byte-identical
|
|
194
|
+
* text and evidence and the same tags. Existing records are never merged
|
|
195
|
+
* or removed by this option; case, whitespace and independent citations
|
|
196
|
+
* remain distinct. The result reports every skipped proposal in `deduplicated`.
|
|
197
|
+
* @returns {{ state: () => Promise<any>,
|
|
198
|
+
* commit: (patch: any[]) => Promise<any>,
|
|
199
|
+
* refine: (trajectory: any, hooks?: { signal?: AbortSignal }) => Promise<any>,
|
|
200
|
+
* patchSchema: any }}
|
|
201
|
+
*/
|
|
202
|
+
export function createRefiner(options) {
|
|
203
|
+
const { client, ledger } = options;
|
|
204
|
+
if (options.deduplicate !== undefined && options.deduplicate !== 'exact-evidence')
|
|
205
|
+
throw new TypeError('deduplicate must be exact-evidence or omitted');
|
|
206
|
+
if (ledger === null || typeof ledger?.snapshot !== 'function') {
|
|
207
|
+
throw new TypeError('createRefiner needs a ledger (createLedger())');
|
|
208
|
+
}
|
|
209
|
+
const applyPatch = typeof options.applyPatch === 'function' ? options.applyPatch : null;
|
|
210
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
211
|
+
const maxRepairs = options.maxRepairs ?? 1;
|
|
212
|
+
const trajectoryChars = options.trajectoryChars ?? TRAJECTORY_CHARS;
|
|
213
|
+
const patchSchema = options.maxOps === undefined
|
|
214
|
+
? REFINEMENT_PATCH_SCHEMA
|
|
215
|
+
: refinementPatchSchema({ maxOps: options.maxOps });
|
|
216
|
+
// the shape check, compiled once — the same schema the generator
|
|
217
|
+
// constrains decoding with, so a hand-written patch handed to
|
|
218
|
+
// `commit` is held to exactly what a generated one is held to
|
|
219
|
+
const compiled = new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' })
|
|
220
|
+
.compile(patchSchema);
|
|
221
|
+
const checkShape = (patch) => {
|
|
222
|
+
const full = checkOutcome(compiled(patch));
|
|
223
|
+
return full.valid && options.validator ? checkOutcome(options.validator(patch)) : full;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const noEngine = {
|
|
227
|
+
error: 'refinement needs the applyPatch seam — inject '
|
|
228
|
+
+ '(doc, patch) => applyJSONPatch(doc, patch) from @jarenjs/json',
|
|
229
|
+
};
|
|
230
|
+
// One refiner serializes state-read, generation and commit together. Hosts
|
|
231
|
+
// sharing a ledger across refiners or other writers still own that coordination.
|
|
232
|
+
let pending = Promise.resolve();
|
|
233
|
+
const serial = (operation) => {
|
|
234
|
+
const result = pending.then(operation);
|
|
235
|
+
pending = result.then(() => undefined, () => undefined);
|
|
236
|
+
return result;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The supplemental state, as the document a patch is applied to. It is
|
|
241
|
+
* the ledger's own records verbatim — nothing here is a projection
|
|
242
|
+
* that would have to be mapped back — and it contains the goal, the
|
|
243
|
+
* memories and the skills and NOTHING else. The base system prompt is
|
|
244
|
+
* absent by construction, which is the first half of why it cannot be
|
|
245
|
+
* patched (the path pattern is the second).
|
|
246
|
+
*/
|
|
247
|
+
async function state() {
|
|
248
|
+
return {
|
|
249
|
+
goal: await ledger.getGoal(),
|
|
250
|
+
memories: await ledger.listMemories(),
|
|
251
|
+
skills: await ledger.listSkills(),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Stage 3, for one kind: the writes a patched document implies, with
|
|
257
|
+
* every record completed and validated. Identity is the `id`, which a
|
|
258
|
+
* proposal never carries — so an entry without one is a new record and
|
|
259
|
+
* a stored record missing from the next document was removed. A
|
|
260
|
+
* `replace` therefore reads as "remove that one, store this one",
|
|
261
|
+
* which is what revising a memory actually is: a different claim, with
|
|
262
|
+
* different evidence, made at a different time.
|
|
263
|
+
* @param {'memory'|'skill'} kind
|
|
264
|
+
* @param {any[]} previous
|
|
265
|
+
* @param {any[]} next
|
|
266
|
+
* @param {string} field - the document member, for the error pointer
|
|
267
|
+
*/
|
|
268
|
+
function planKind(kind, previous, next, field) {
|
|
269
|
+
/** @type {any[]} */
|
|
270
|
+
const errors = [];
|
|
271
|
+
/** @type {any[]} */
|
|
272
|
+
const add = [];
|
|
273
|
+
const known = new Map(previous.map((record) => [record.id, record]));
|
|
274
|
+
const kept = new Set();
|
|
275
|
+
const deduplicated = [];
|
|
276
|
+
// Only records surviving unchanged are witnesses; matching one scheduled
|
|
277
|
+
// for removal would discard both copies of the evidence.
|
|
278
|
+
const witnesses = next.flatMap((record, index) => known.has(record?.id)
|
|
279
|
+
&& sameJson(record, known.get(record.id)) ? [{ record, path: `/${field}/${index}` }] : []);
|
|
280
|
+
|
|
281
|
+
next.forEach((entry, index) => {
|
|
282
|
+
const at = `/${field}/${index}`;
|
|
283
|
+
if (!isRecord(entry)) {
|
|
284
|
+
errors.push(problem('AI0100', `a ${kind} must be an object`, at));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (typeof entry.id === 'string' && known.has(entry.id)) {
|
|
288
|
+
kept.add(entry.id);
|
|
289
|
+
// unchanged records are not rewritten: a refinement that touched
|
|
290
|
+
// nothing must leave the timestamps it did not touch alone
|
|
291
|
+
if (sameJson(entry, known.get(entry.id))) return;
|
|
292
|
+
}
|
|
293
|
+
else if (entry.id !== undefined) {
|
|
294
|
+
errors.push(problem('AI0101',
|
|
295
|
+
`a ${kind} may not choose its own id ('${entry.id}') — omit it and the ledger mints one`,
|
|
296
|
+
at));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const record = { ...RECORD_DEFAULTS[kind], ...entry, at: entry.at ?? now() };
|
|
300
|
+
const rejected = ledger.validate(kind, { ...record, id: record.id ?? PENDING_ID });
|
|
301
|
+
if (rejected !== null) {
|
|
302
|
+
for (const error of rejected.errors ?? []) {
|
|
303
|
+
errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword,
|
|
304
|
+
`the ${kind} is not storable: ${error.message}`,
|
|
305
|
+
`${at}${error.instancePath ?? ''}`));
|
|
306
|
+
}
|
|
307
|
+
if ((rejected.errors ?? []).length === 0)
|
|
308
|
+
errors.push(problem('AI0102', `the ${kind} is not storable`, at));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (kind === 'memory' && options.deduplicate === 'exact-evidence' && entry.id === undefined
|
|
312
|
+
&& Object.keys(entry).every((key) => key === 'text' || key === 'evidence' || key === 'tags')) {
|
|
313
|
+
const match = witnesses.find(({ record: held }) => held.text === record.text && sameJson(held.evidence, record.evidence)
|
|
314
|
+
&& sameJson([...held.tags].sort(), [...record.tags].sort()));
|
|
315
|
+
if (match) {
|
|
316
|
+
deduplicated.push({ path: at, retainedPath: match.path,
|
|
317
|
+
...(match.record.id === undefined ? {} : { retainedId: match.record.id }) });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
add.push(record);
|
|
322
|
+
witnesses.push({ record, path: at });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const remove = previous.filter((record) => !kept.has(record.id)).map((record) => record.id);
|
|
326
|
+
return { add, remove, errors, deduplicated };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The progress the patched document appends. Append-only is checked
|
|
331
|
+
* rather than assumed: the path pattern cannot express a rewrite, and
|
|
332
|
+
* this is the assertion that says so even if a caller hands `commit` a
|
|
333
|
+
* patch that never met the pattern.
|
|
334
|
+
* @param {any} previous - the stored goal, or null
|
|
335
|
+
* @param {any} next - the patched goal
|
|
336
|
+
*/
|
|
337
|
+
function planProgress(previous, next) {
|
|
338
|
+
/** @type {any[]} */
|
|
339
|
+
const errors = [];
|
|
340
|
+
const before = previous?.progress ?? [];
|
|
341
|
+
const after = next?.progress ?? [];
|
|
342
|
+
if (previous === null && (next !== null || after.length > 0)) {
|
|
343
|
+
errors.push(problem('AI0103',
|
|
344
|
+
'there is no active goal to record progress against — call setGoal first', '/goal'));
|
|
345
|
+
return { append: [], errors };
|
|
346
|
+
}
|
|
347
|
+
if (next !== null && next !== undefined && !sameJson(
|
|
348
|
+
{ ...next, progress: before }, { ...previous, progress: before })) {
|
|
349
|
+
errors.push(problem('AI0104',
|
|
350
|
+
'a refinement may not change the goal itself — only append to its progress', '/goal'));
|
|
351
|
+
return { append: [], errors };
|
|
352
|
+
}
|
|
353
|
+
if (after.length < before.length
|
|
354
|
+
|| !sameJson(after.slice(0, before.length), before)) {
|
|
355
|
+
errors.push(problem('AI0105',
|
|
356
|
+
'progress is append-only — earlier entries may not be edited or removed',
|
|
357
|
+
'/goal/progress'));
|
|
358
|
+
return { append: [], errors };
|
|
359
|
+
}
|
|
360
|
+
const append = after.slice(before.length).map((entry) => (isRecord(entry)
|
|
361
|
+
? { ...entry, at: entry.at ?? now() }
|
|
362
|
+
: entry));
|
|
363
|
+
if (append.length > 0) {
|
|
364
|
+
// validated as a WHOLE goal, because that is the record the ledger
|
|
365
|
+
// stores: an entry legal on its own but illegal in place would
|
|
366
|
+
// otherwise pass here and be rejected at commit
|
|
367
|
+
const rejected = ledger.validate('goal', { ...previous, progress: [...before, ...append] });
|
|
368
|
+
if (rejected !== null) {
|
|
369
|
+
for (const error of rejected.errors ?? []) {
|
|
370
|
+
errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword,
|
|
371
|
+
`the progress entry is not storable: ${error.message}`,
|
|
372
|
+
`/goal${error.instancePath ?? ''}`));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { append, errors };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Stages 1 to 3 over a copy — everything that can be known without
|
|
381
|
+
* writing anything. This is what the generator's `gate` runs, so a
|
|
382
|
+
* proposal that cannot land comes back to the model with pointers
|
|
383
|
+
* instead of landing half-way and being rolled back.
|
|
384
|
+
* @param {any} document - the state as it stands
|
|
385
|
+
* @param {any[]} patch
|
|
386
|
+
* @returns {{ valid: boolean, errors: any[], plan?: any, next?: any }}
|
|
387
|
+
*/
|
|
388
|
+
function dryRun(document, patch) {
|
|
389
|
+
return guarded.prepare(document, patch);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const guarded = createGuardedRefiner({
|
|
393
|
+
read: state,
|
|
394
|
+
validateProposal: checkShape,
|
|
395
|
+
apply: (document, patch) => applyPatch(document, patch),
|
|
396
|
+
applyFailure: patchFailure,
|
|
397
|
+
validateCandidate: (next) => isRecord(next)
|
|
398
|
+
? { valid: true, errors: [] }
|
|
399
|
+
: { valid: false, errors: [problem('AI0106', 'the patched state is not an object', '')] },
|
|
400
|
+
planCommit: (next, document) => {
|
|
401
|
+
const memories = planKind('memory', document.memories, next.memories ?? [], 'memories');
|
|
402
|
+
const skills = planKind('skill', document.skills, next.skills ?? [], 'skills');
|
|
403
|
+
const progress = planProgress(document.goal, next.goal ?? null);
|
|
404
|
+
const errors = [...memories.errors, ...skills.errors, ...progress.errors];
|
|
405
|
+
return { valid: errors.length === 0, errors, plan: { memories, skills, progress } };
|
|
406
|
+
},
|
|
407
|
+
snapshot: () => ledger.snapshot(),
|
|
408
|
+
restore: (token) => ledger.rollback(token),
|
|
409
|
+
commit: async (plan) => {
|
|
410
|
+
const written = { memories: [], skills: [], progress: [] };
|
|
411
|
+
const accept = (outcome, what) => {
|
|
412
|
+
if (outcome?.error !== undefined) throw Object.assign(new Error(`${what}: ${outcome.error}`), { outcome, what });
|
|
413
|
+
};
|
|
414
|
+
for (const record of plan.memories.add) {
|
|
415
|
+
accept(await ledger.addMemory(record), 'a memory could not be stored');
|
|
416
|
+
written.memories.push(record);
|
|
417
|
+
}
|
|
418
|
+
for (const record of plan.skills.add) {
|
|
419
|
+
accept(await ledger.addSkill(record), 'a skill could not be stored');
|
|
420
|
+
written.skills.push(record);
|
|
421
|
+
}
|
|
422
|
+
for (const id of plan.memories.remove) await ledger.deleteMemory(id);
|
|
423
|
+
for (const id of plan.skills.remove) await ledger.deleteSkill(id);
|
|
424
|
+
for (const entry of plan.progress.append) {
|
|
425
|
+
accept(await ledger.recordProgress(entry), 'a progress entry could not be recorded');
|
|
426
|
+
written.progress.push(entry);
|
|
427
|
+
}
|
|
428
|
+
return written;
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
/** Whether a plan would write anything at all. */
|
|
433
|
+
const empty = (plan) => plan.memories.add.length === 0 && plan.memories.remove.length === 0
|
|
434
|
+
&& plan.skills.add.length === 0 && plan.skills.remove.length === 0
|
|
435
|
+
&& plan.progress.append.length === 0;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Stage 4: snapshot, then write through the ledger's own API — never
|
|
439
|
+
* into storage, so every record passes the same validation a
|
|
440
|
+
* hand-written one does. A rejection anywhere rolls the whole
|
|
441
|
+
* refinement back, so the state after a failed commit is the state
|
|
442
|
+
* before it, byte for byte.
|
|
443
|
+
* @param {any} document
|
|
444
|
+
* @param {any[]} patch
|
|
445
|
+
*/
|
|
446
|
+
async function commitAgainst(document, patch) {
|
|
447
|
+
const dry = dryRun(document, patch);
|
|
448
|
+
if (!dry.valid) {
|
|
449
|
+
return { error: 'the refinement was rejected', errors: dry.errors, patchSchema };
|
|
450
|
+
}
|
|
451
|
+
const plan = dry.plan;
|
|
452
|
+
if (empty(plan)) {
|
|
453
|
+
return { ok: true, patch, snapshot: null, memories: [], skills: [], progress: [],
|
|
454
|
+
...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {}) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (ledger.concurrency === 'atomic' && typeof ledger.transaction === 'function') {
|
|
458
|
+
try {
|
|
459
|
+
return await ledger.transaction(document, (scoped) =>
|
|
460
|
+
createRefiner({ ...options, ledger: scoped }).commit(patch));
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (error?.outcome) return { ...error.outcome, snapshot: null };
|
|
464
|
+
return { error: `the refinement was rolled back after a storage failure: ${error instanceof Error ? error.message : String(error)}`,
|
|
465
|
+
snapshot: null, patchSchema };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const outcome = await guarded.commitPrepared(document, dry);
|
|
470
|
+
const token = outcome.snapshot;
|
|
471
|
+
if (!outcome.ok) {
|
|
472
|
+
if (outcome.stage === 'snapshot') return { error: 'the refinement could not take a snapshot; nothing was written',
|
|
473
|
+
cause: outcome.cause, errors: outcome.errors, patchSchema };
|
|
474
|
+
if (outcome.restoreError !== undefined) return {
|
|
475
|
+
error: 'the refinement failed and rollback failed; storage requires recovery',
|
|
476
|
+
snapshot: token, patchSchema, cause: outcome.cause, restoreError: outcome.restoreError,
|
|
477
|
+
};
|
|
478
|
+
if (outcome.cause?.outcome) return {
|
|
479
|
+
error: `the refinement was rolled back: ${outcome.cause.what} — ${outcome.cause.outcome.error}`,
|
|
480
|
+
errors: outcome.cause.outcome.errors ?? [], patchSchema, snapshot: token,
|
|
481
|
+
};
|
|
482
|
+
return { error: `the refinement was rolled back after a storage failure: ${outcome.cause instanceof Error ? outcome.cause.message : String(outcome.cause)}`,
|
|
483
|
+
snapshot: token, patchSchema };
|
|
484
|
+
}
|
|
485
|
+
const written = outcome.value;
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
patch,
|
|
490
|
+
// the token is returned, not kept: rolling a refinement back is the
|
|
491
|
+
// host's call to make, and a snapshot nobody was told about is a
|
|
492
|
+
// reversal nobody can perform
|
|
493
|
+
snapshot: token,
|
|
494
|
+
removed: { memories: plan.memories.remove, skills: plan.skills.remove },
|
|
495
|
+
...written,
|
|
496
|
+
...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {}),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Apply a patch that already exists — the same four stages a generated
|
|
502
|
+
* one goes through, exposed because a host (or a test) writing the
|
|
503
|
+
* patch itself must not get a weaker gate than a model does.
|
|
504
|
+
* @param {any[]} patch
|
|
505
|
+
*/
|
|
506
|
+
async function commit(patch) {
|
|
507
|
+
if (applyPatch === null) return { ...noEngine, patchSchema };
|
|
508
|
+
return serial(async () => commitAgainst(await state(), patch));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The proposal prompt. The state is shown in full and the rules are
|
|
513
|
+
* stated in the imperative, because the field notes are clear that
|
|
514
|
+
* breadth and posture belong in the prompt while correctness belongs
|
|
515
|
+
* in the gate.
|
|
516
|
+
* @param {any} document
|
|
517
|
+
* @param {any} trajectory
|
|
518
|
+
*/
|
|
519
|
+
function prompt(document, trajectory) {
|
|
520
|
+
const task = options.instructions ?? [
|
|
521
|
+
'You have just finished a run. Propose what should be remembered from it, as an RFC 6902',
|
|
522
|
+
'JSON Patch over your supplemental state.',
|
|
523
|
+
'',
|
|
524
|
+
'Rules:',
|
|
525
|
+
'- Every memory and every progress entry MUST carry `evidence`: what in the run below',
|
|
526
|
+
' establishes it. Quote the tool result or name the fact. Never write a memory you cannot',
|
|
527
|
+
' cite — an empty patch is a better answer than an invented one.',
|
|
528
|
+
'- Store what will still be true and still be useful next time. Not what happened; what it',
|
|
529
|
+
' means.',
|
|
530
|
+
'- Do not restate a memory that is already stored, and do not add a near-duplicate of one.',
|
|
531
|
+
'- Append with /memories/-, /skills/- and /goal/progress/-. Replace or remove an existing',
|
|
532
|
+
' record by its index. Nothing else is addressable.',
|
|
533
|
+
'',
|
|
534
|
+
// Examples help authoring; the schema enforces the path/value relationship.
|
|
535
|
+
'Each path takes its own shape:',
|
|
536
|
+
' /memories/- {"text": …, "evidence": …, "tags": [ … ]}',
|
|
537
|
+
' /skills/- {"name": …, "when": …, "instructions": …, "tools": [ … ]}',
|
|
538
|
+
' /goal/progress/- {"note": …, "evidence": …} <- note, NOT text',
|
|
539
|
+
'',
|
|
540
|
+
'Example of a well-formed refinement:',
|
|
541
|
+
'[{"op":"add","path":"/memories/-","value":{"text":"…","evidence":"…","tags":["…"]}},'
|
|
542
|
+
+ '{"op":"add","path":"/goal/progress/-","value":{"note":"…","evidence":"…"}}]',
|
|
543
|
+
].join('\n');
|
|
544
|
+
return [
|
|
545
|
+
task,
|
|
546
|
+
'',
|
|
547
|
+
'Your supplemental state right now:',
|
|
548
|
+
JSON.stringify(document, null, 1),
|
|
549
|
+
'',
|
|
550
|
+
'The run:',
|
|
551
|
+
describeTrajectory(trajectory, trajectoryChars),
|
|
552
|
+
].join('\n');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Propose and apply a refinement for one run.
|
|
557
|
+
*
|
|
558
|
+
* The gate runs INSIDE generation (as `createStructuredOutput`'s
|
|
559
|
+
* `gate`), so a patch that does not apply or would store an
|
|
560
|
+
* unevidenced memory comes back to the model with its pointers for a
|
|
561
|
+
* bounded repair. What survives generation is committed against the
|
|
562
|
+
* same state the gate ran over.
|
|
563
|
+
* @param {any} trajectory - a `send` result, its `steps`, or messages
|
|
564
|
+
* @param {{ signal?: AbortSignal }} [hooks]
|
|
565
|
+
*/
|
|
566
|
+
async function refineAgainst(trajectory, hooks = {}) {
|
|
567
|
+
if (applyPatch === null) return { ...noEngine, patchSchema };
|
|
568
|
+
// read ONCE: the gate has to be synchronous (a compiled check is),
|
|
569
|
+
// and a gate that re-read the ledger between attempts would be
|
|
570
|
+
// judging each attempt against a different state
|
|
571
|
+
const document = await state();
|
|
572
|
+
const generator = createStructuredOutput({
|
|
573
|
+
client,
|
|
574
|
+
schema: patchSchema,
|
|
575
|
+
name: 'refinement',
|
|
576
|
+
// a `remove` carries no `value`, so `value` is optional, and
|
|
577
|
+
// OpenAI's strict mode requires every declared property to be
|
|
578
|
+
// required. The local check is the authority either way.
|
|
579
|
+
strict: false,
|
|
580
|
+
gate: (patch) => dryRun(document, patch),
|
|
581
|
+
maxRepairs,
|
|
582
|
+
});
|
|
583
|
+
const result = await generator.generate([{ role: 'user', content: prompt(document, trajectory) }],
|
|
584
|
+
hooks);
|
|
585
|
+
if (result.errors !== undefined) {
|
|
586
|
+
return {
|
|
587
|
+
error: 'the model did not produce an applicable refinement',
|
|
588
|
+
errors: result.errors,
|
|
589
|
+
attempts: result.attempts,
|
|
590
|
+
raw: result.raw,
|
|
591
|
+
patchSchema,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { ...await commitAgainst(document, result.value), attempts: result.attempts };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const refine = (trajectory, hooks = {}) => serial(() => refineAgainst(trajectory, hooks));
|
|
598
|
+
return { state, commit, refine, patchSchema };
|
|
599
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The program schema.
|
|
3
|
+
*
|
|
4
|
+
* `queryRef` is the `$id` of an injected query grammar
|
|
5
|
+
* (`@jarenjs/json/schemas/jaren-query.schema.json`, or its LLM-profile
|
|
6
|
+
* twin — the twin is the better choice for constrained decoding, which
|
|
7
|
+
* is what it was derived for). Given one, `select` and `reduce` are
|
|
8
|
+
* shape-constrained as well as compile-gated, and the caller must pass
|
|
9
|
+
* the same grammar to `createStructuredOutput` as a `ref` so the
|
|
10
|
+
* validator can resolve it. Given none, `query` accepts any JSON value
|
|
11
|
+
* and the compile gate carries the whole weight.
|
|
12
|
+
*
|
|
13
|
+
* @param {{ queryRef?: string, maxSteps?: number }} [options]
|
|
14
|
+
* @returns {any} a JSON Schema document
|
|
15
|
+
*/
|
|
16
|
+
export function programSchema(options?: {
|
|
17
|
+
queryRef?: string;
|
|
18
|
+
maxSteps?: number;
|
|
19
|
+
}): any;
|
|
20
|
+
/**
|
|
21
|
+
* The action language: what a model may say about the environment.
|
|
22
|
+
*
|
|
23
|
+
* The RLM paper's root model writes Python and an interpreter runs it.
|
|
24
|
+
* This package has no interpreter and will never have one — `eval` and
|
|
25
|
+
* `new Function` are forbidden by the house rules and a browser tab is
|
|
26
|
+
* the wrong place for a sandbox — so the model authors a **document**
|
|
27
|
+
* instead, and the document is put through the same two gates every
|
|
28
|
+
* generated Jaren program goes through: a schema that constrains
|
|
29
|
+
* decoding (the shape) and a compiler that runs before anything else
|
|
30
|
+
* does (the semantics).
|
|
31
|
+
*
|
|
32
|
+
* Read the two rules that shaped every line of this file:
|
|
33
|
+
*
|
|
34
|
+
* - **A step names slots; it never carries content** (D2). Every member
|
|
35
|
+
* below is an operation name, a binding name, a slot reference, a
|
|
36
|
+
* bounded instruction or a query document. There is no member a
|
|
37
|
+
* corpus can be poured into, and none can be added later without
|
|
38
|
+
* failing `test/ai/program.test.js` — the schema is walked and every
|
|
39
|
+
* string member must declare a `maxLength`. That is what makes "the
|
|
40
|
+
* program is constant-size whatever the corpus" a property of the
|
|
41
|
+
* grammar rather than a promise about how it will be used.
|
|
42
|
+
* - **`map` is the only step that calls a model.** One construct, one
|
|
43
|
+
* concurrency bound, one place to count spend. A grammar with
|
|
44
|
+
* sub-calls sprinkled through it cannot be bounded, and a runner over
|
|
45
|
+
* such a grammar cannot state what a program will cost before it runs
|
|
46
|
+
* it.
|
|
47
|
+
*
|
|
48
|
+
* Three shape decisions exist for the weak tier specifically (D8), and
|
|
49
|
+
* each one trades expressiveness for a decision the model does not have
|
|
50
|
+
* to make:
|
|
51
|
+
*
|
|
52
|
+
* - **Every step reads `from` and writes `as`.** Not `slot`/`in`/`over`
|
|
53
|
+
* per operation: one input member and one output member across the
|
|
54
|
+
* whole language, so the model picks the *operation* and never the
|
|
55
|
+
* spelling of its argument.
|
|
56
|
+
* - **Bindings are program-local names, not addresses.** The program
|
|
57
|
+
* says `as: "pieces"`; the runner resolves that to whatever the
|
|
58
|
+
* environment's derived addressing produced (HORIZON_06 owns
|
|
59
|
+
* addresses; a model that could write one could name a slot that
|
|
60
|
+
* cannot exist).
|
|
61
|
+
* - **`query` is left open unless a grammar is injected.** The seam is
|
|
62
|
+
* D3: with `@jarenjs/json`'s query grammar passed as a `ref` the
|
|
63
|
+
* shape is constrained too; without it the schema accepts any JSON
|
|
64
|
+
* value here and the compile gate is what refuses a bad one. A schema
|
|
65
|
+
* that hard-`$ref`'d a grammar this package may not import would make
|
|
66
|
+
* the whole language unusable with the seam empty.
|
|
67
|
+
*/
|
|
68
|
+
/** Steps one program may have. A plan longer than this is a program
|
|
69
|
+
* that should have been two runs; it is also past the length a small
|
|
70
|
+
* model keeps coherent. */
|
|
71
|
+
export const MAX_STEPS: 12;
|
|
72
|
+
/** The whole document's character cap, enforced by the compiler. This
|
|
73
|
+
* is the constant in "constant-size root request": the program is one
|
|
74
|
+
* more thing the root carries, and it must not grow with the corpus. */
|
|
75
|
+
export const MAX_PROGRAM_CHARS: 4000;
|
|
76
|
+
/** A binding name: short, lowercase, unmistakable in an error message. */
|
|
77
|
+
export const NAME_PATTERN: "^[a-z][a-z0-9_]{0,31}$";
|
|
78
|
+
/** The operations a program may name, in the order a plan uses them. */
|
|
79
|
+
export const PROGRAM_OPS: string[];
|
|
80
|
+
/** The program schema with the query seam empty — what a caller with no
|
|
81
|
+
* grammar injected authors against. */
|
|
82
|
+
export const PROGRAM_SCHEMA: any;
|