@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/README.md
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
# @tangleai/agents
|
|
2
|
+
|
|
3
|
+
Validated tools, bounded agents, action programs, recursive execution and guarded refinement.
|
|
4
|
+
|
|
5
|
+
This package keeps its JS/JSDoc implementation and deterministic tests. Inject
|
|
6
|
+
fetch, storage and compiler services at the existing seams. The public source
|
|
7
|
+
exports and emitted npm JavaScript share one implementation.
|
|
8
|
+
|
|
9
|
+
## Public entries
|
|
10
|
+
|
|
11
|
+
- `@tangleai/agents`
|
|
12
|
+
- `@tangleai/agents/toolbox`
|
|
13
|
+
- `@tangleai/agents/agent`
|
|
14
|
+
- `@tangleai/agents/program`
|
|
15
|
+
- `@tangleai/agents/recursive`
|
|
16
|
+
- `@tangleai/agents/refine`
|
|
17
|
+
- `@tangleai/agents/schemas/program`
|
|
18
|
+
- `@tangleai/agents/program-result`
|
|
19
|
+
- `@tangleai/agents/program-session`
|
|
20
|
+
- `@tangleai/agents/package.json`
|
|
21
|
+
|
|
22
|
+
See [ownership and verification](../../docs/JAREN_AI_MIGRATION.md) for source
|
|
23
|
+
provenance, installation mode, unchanged serialized identities and qualification.
|
|
24
|
+
|
|
25
|
+
## The toolbox
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { createToolbox, registerModelContext } from '@tangleai/agents/toolbox';
|
|
29
|
+
|
|
30
|
+
const toolbox = createToolbox();
|
|
31
|
+
toolbox.add({
|
|
32
|
+
name: 'lookup_order',
|
|
33
|
+
description: 'Look up one order by id.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: { id: { type: 'string', minLength: 1 } },
|
|
37
|
+
required: ['id'],
|
|
38
|
+
},
|
|
39
|
+
execute: ({ id }) => orders.get(id) ?? { error: `no order '${id}'` },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// the same tools, published to a browser-hosted agent (WebMCP):
|
|
43
|
+
const binding = registerModelContext(toolbox);
|
|
44
|
+
await binding.ready;
|
|
45
|
+
// On host teardown: await binding.dispose();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Every call is validated against the tool's schema by `@jarenjs/validate` before the tool
|
|
49
|
+
runs. `execute` never throws for content-level problems — unknown tool, invalid input, or
|
|
50
|
+
a throwing tool all come back as `{ error }` results the model can read and correct.
|
|
51
|
+
|
|
52
|
+
Weak models routinely JSON-*encode* a nested argument. Where the schema wants an object or
|
|
53
|
+
an array and a parseable JSON string arrived, the toolbox parses it and validates the
|
|
54
|
+
parsed value, so the tool sees what the model meant instead of a type error. A rejected
|
|
55
|
+
call answers `{ error, errors, inputSchema }` — up to eight validation errors as
|
|
56
|
+
`{ instancePath, keyword, message }`, plus the tool's own schema to re-read — and adds a
|
|
57
|
+
named `hint` when a property that wanted structure arrived as JSON text that does not
|
|
58
|
+
parse, naming the offending properties.
|
|
59
|
+
|
|
60
|
+
### The geo toolbox — spatial answers about data the model was given
|
|
61
|
+
|
|
62
|
+
`createGeoToolbox` is a closed set of seven tools, each one call into `@jarenjs/core/geo`
|
|
63
|
+
and each guarded by the shipped GeoJSON meta-schema **by reference**:
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
import { createGeoToolbox } from '@tangleai/jaren/geo-tools';
|
|
67
|
+
import { registerModelContext } from '@tangleai/agents/toolbox';
|
|
68
|
+
import geojson from '@jarenjs/json/schemas/geojson.schema.json' with { type: 'json' };
|
|
69
|
+
|
|
70
|
+
const geo = createGeoToolbox({ geojson }); // the artifact is injected — this package depends on no engine
|
|
71
|
+
geo.execute('geo_distance', { a: [4.9041, 52.3676], b: { type: 'Point', coordinates: [2.3522, 48.8566] } });
|
|
72
|
+
// → { metres: 429861.98… }
|
|
73
|
+
geo.execute('geo_neighbours', { cell: 'u173zt' }); // → { cells: [ …nine cells, reading order… ] }
|
|
74
|
+
const binding = registerModelContext(geo); // the same seven over WebMCP
|
|
75
|
+
await binding.ready; // dispose when the host leaves
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| tool | input | returns |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `geo_distance` | two GeoJSON values (or bare positions) | `{ metres }`, geodesic, between representative positions |
|
|
81
|
+
| `geo_within` | a value and an area | `{ within }` — only a polygon has an inside |
|
|
82
|
+
| `geo_bbox` | a value | `{ bbox: [w, s, e, n] }` |
|
|
83
|
+
| `geo_geohash` | a value, `precision` 1–12 (default 9) | `{ cell }` — a bucket, the description says so |
|
|
84
|
+
| `geo_neighbours` | a cell | `{ cells }` — the nine-cell **proximity** probe |
|
|
85
|
+
| `geo_parse_wkt` / `geo_to_wkt` | text ↔ value | the conversion, both ways |
|
|
86
|
+
|
|
87
|
+
Every `inputSchema` `$ref`s `https://jarenjs.dev/schemas/geojson` (and its `position`
|
|
88
|
+
definition) rather than restating a geometry shape, so a longitude of `200` is refused by
|
|
89
|
+
the validator at `/a/coordinates/0` with the schema to re-read — before the tool runs, and
|
|
90
|
+
not by a `try`/`catch` (the module has none). A value with no positions is a content
|
|
91
|
+
refusal the model can read (`{ error }`). And there is **no overlay**: a request for
|
|
92
|
+
`geo_union`, `geo_intersection`, `geo_difference`, `geo_buffer` or their kin is answered
|
|
93
|
+
with a refusal naming why — a half-correct clipper is worse than none, and JSTS or Turf do
|
|
94
|
+
that work — never an approximation.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## The agent loop
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import { createAgent } from '@tangleai/agents/agent';
|
|
101
|
+
|
|
102
|
+
const agent = createAgent({ client, toolbox, system: 'You are…', maxToolRounds: 5 });
|
|
103
|
+
const { message, messages, steps } = await agent.send(history, {
|
|
104
|
+
onDelta: (text) => ui.stream(text),
|
|
105
|
+
onToolCall: ({ name }) => ui.activity(name),
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The loop is bounded (`maxToolRounds`, default 5) and stops with a readable message instead
|
|
110
|
+
of spinning; oversized tool results are truncated (`maxToolResultChars`, default 8000) so
|
|
111
|
+
a local model's context is respected. `send` never mutates the history it receives — it
|
|
112
|
+
returns the complete new transcript, ready to persist and send back next turn.
|
|
113
|
+
|
|
114
|
+
**Long sessions fit a small context — for questions about one thing at a time.** That
|
|
115
|
+
qualification is load-bearing and the numbers below are why: compaction keeps a session
|
|
116
|
+
runnable and answerable one fact at a time, and a question that needs *every* fact at once
|
|
117
|
+
stops being answerable the moment anything is cut. `historyBudget` (characters — deterministic where
|
|
118
|
+
tokens are provider-private) compacts each request when the history outgrows it: the
|
|
119
|
+
system prompt, the first user message and the largest tail that fits always survive, and
|
|
120
|
+
the dropped middle becomes one synopsis message naming every dropped tool round. Cuts
|
|
121
|
+
happen only at tool-round boundaries, so `tool_calls`/`tool` pairing stays wire-legal —
|
|
122
|
+
always. The built-in synopsis is pure string work (no second model call; a single local
|
|
123
|
+
model runs unassisted); `compaction: (droppedRounds, addresses) => string` swaps in your
|
|
124
|
+
own writer. The returned transcript is always the full, uncompacted history.
|
|
125
|
+
|
|
126
|
+
On its own that is lossy, and worth being precise about, because the loss has a shape.
|
|
127
|
+
Each dropped tool call leaves one line whose result excerpt is capped at 60 characters, so
|
|
128
|
+
the synopsis remembers **that** `fetch_record` was called and returned a `REC0007` and
|
|
129
|
+
loses **what the record said**. Measured on <!--fact:horizon.measured-->2026-08-13, Node v22.22.2, 40 tool rounds<!--/fact--> of ~440-character results at a
|
|
130
|
+
6 000-character budget, with the fact behind the padding, the request keeps <!--fact:horizon.synopsisGap-->15 of 40 record ids and 7 of their 40 values<!--/fact--> (`npm run benchmark:long-horizon`). A model can see the label and answer confidently from
|
|
131
|
+
a record it no longer has. **Compaction alone is not the answer to a long session.**
|
|
132
|
+
|
|
133
|
+
### Compaction that moves instead of destroying
|
|
134
|
+
|
|
135
|
+
Give the agent a ledger and nothing leaves the request without a copy that can be named:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { createAgent } from '@tangleai/agents/agent';
|
|
139
|
+
import { createLedger } from '@tangleai/context/ledger';
|
|
140
|
+
|
|
141
|
+
const ledger = createLedger({ storage }); // storage is yours to inject
|
|
142
|
+
const agent = createAgent({ client, toolbox, historyBudget: 6000, ledger });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`createLedger()` takes no arguments and works in memory, so a static page degrades cleanly;
|
|
146
|
+
durability is a storage adapter the host injects — four async methods and nothing else:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
const storage = {
|
|
150
|
+
get: async (key) => …, // a JSON value, or undefined
|
|
151
|
+
set: async (key, value) => …, // value is a JSON value
|
|
152
|
+
delete: async (key) => …, // an absent key is not an error
|
|
153
|
+
keys: async (prefix) => […], // every key starting with prefix, sorted
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`keys()` really must be **sorted**: listings, the goal archive and a snapshot's entries are
|
|
158
|
+
read in key order, and the ledger's zero-padded sequences exist so that order is
|
|
159
|
+
chronological. One optional fifth method, `rank`, lets an adapter that can rank vectors
|
|
160
|
+
where they live answer `recall({ near })` without handing every record over (§A durable
|
|
161
|
+
ledger over `@jarenjs/db`).
|
|
162
|
+
|
|
163
|
+
Back it with `@jarenjs/db` over OPFS, with one `localStorage` slot, with a file, with a
|
|
164
|
+
server — or with nothing. The package gains no dependency either way, which is the whole
|
|
165
|
+
posture: storage stays injected and it degrades to in-memory and
|
|
166
|
+
schema-only. This site's assistant backs it with a single JSON slot
|
|
167
|
+
([`ledgerStore.js`](https://github.com/jklarenbeek/jarenjs/blob/main/packages/website/src/lib/ledgerStore.js)), which is all a browser session
|
|
168
|
+
needs. The ledger serializes its own writes. An adapter with `mutate` also
|
|
169
|
+
serializes other writers at storage; `ledger.concurrency` reports `atomic` or
|
|
170
|
+
`single-writer`. Four-method adapters require host coordination between writers.
|
|
171
|
+
The website uses Web Locks, reads fresh bytes inside the lock, and holds it across
|
|
172
|
+
the browser's localStorage publication boundary. Quota and lock failures are visible.
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
The ledger holds four kinds, and they differ in every dimension that matters — lifetime,
|
|
176
|
+
retrieval and who may write them:
|
|
177
|
+
|
|
178
|
+
| kind | what it is | how it is retrieved | written by |
|
|
179
|
+
|---|---|---|---|
|
|
180
|
+
| `goal` | the one active objective and its append-only progress | always in the prompt | the host (`setGoal`), a refinement (progress only) |
|
|
181
|
+
| `memory` | an evidenced fact worth carrying past this context | `recall({ tags, where, near, limit })` | the host, or a gated refinement |
|
|
182
|
+
| `skill` | a reusable recipe: when it applies, what to do | `recallSkills(…)` — the same query | the host, or a gated refinement |
|
|
183
|
+
| `slot` | addressable content too big to carry; metadata is separate from the bytes | by name (`recall` the tool) | the harness — never proposed by a model |
|
|
184
|
+
|
|
185
|
+
Retrieval is tag match plus recency by default. Inject `compileQuery`
|
|
186
|
+
(`compileJsonQuery` from `@jarenjs/json/query`) and a `where` predicate becomes a real
|
|
187
|
+
query document — the same document `@jarenjs/db` could push down to SQL. Without that seam
|
|
188
|
+
a `where` is **refused**, not ignored: a filter silently dropped answers the wrong question
|
|
189
|
+
with a straight face.
|
|
190
|
+
|
|
191
|
+
**Recall by meaning is the same shape of seam.** A memory or skill may carry an `embedding`
|
|
192
|
+
(plain `number[]` — never a typed array, because the storage boundary is JSON) together with
|
|
193
|
+
its identity, `embeddedBy: { model, dims }`; the two travel as a pair, the vector must be
|
|
194
|
+
exactly `dims` finite numbers, and an un-embedded record is exactly as valid as before.
|
|
195
|
+
Inject an embedder (§Embeddings) and `recall({ near })` ranks by cosine similarity through it:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import { createLedger } from '@tangleai/context/ledger';
|
|
199
|
+
import { createEmbeddingClient } from '@tangleai/models/embed';
|
|
200
|
+
|
|
201
|
+
const embedder = createEmbeddingClient({ provider: 'ollama', model: 'nomic-embed-text' });
|
|
202
|
+
const ledger = createLedger({ storage, embedder }); // embedOnWrite stays off
|
|
203
|
+
|
|
204
|
+
await ledger.addMemory({ text: 'The export uses CRLF line endings.', evidence: 'head export.csv' });
|
|
205
|
+
await ledger.embedMissing(); // → { embedded: 1, remaining: 0 }
|
|
206
|
+
const { memories, scores, skipped } = await ledger.recall({
|
|
207
|
+
near: 'line endings in the export', tags: ['csv'], limit: 5, minScore: 0.3,
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- **Refused without the seam.** `recall({ near })` on a ledger with no embedder answers
|
|
212
|
+
`{ error: 'recall: near needs the embedder seam — …' }`, exactly as a `where` refuses without
|
|
213
|
+
`compileQuery`. An absent capability refuses; it never degrades to a different answer.
|
|
214
|
+
- **Refused across identities.** Every candidate's `embeddedBy` is compared with the query
|
|
215
|
+
embedder's `{ model, dims }` before any arithmetic. Two models in the ledger, or a ledger
|
|
216
|
+
embedded by one model and queried through another, answer `{ error }` naming every identity
|
|
217
|
+
found — never the matching subset, because a silent subset is a silent wrong answer.
|
|
218
|
+
The comparison is `sameIdentity(a, b)` and the wording is `describeIdentity(identity)`,
|
|
219
|
+
both exported, because the rule is not the ledger's alone: a storage adapter that
|
|
220
|
+
ranks applies the same comparison where the records live, and so does a host with a
|
|
221
|
+
vector store of its own beside the ledger.
|
|
222
|
+
- **Skipped, reported.** The candidates are the records that pass `tags`/`where` AND carry a
|
|
223
|
+
vector; the ones that pass and carry none are counted in `skipped`, never scored (a fabricated
|
|
224
|
+
score poisons a ranking) and never hidden (a silent drop poisons trust). The result is
|
|
225
|
+
`{ memories, scores, skipped, via, ranking }` — `scores[i]` is `memories[i]`'s cosine, descending; equal
|
|
226
|
+
scores fall back to recency, then id, so the order is deterministic; `minScore` filters the
|
|
227
|
+
ranked list and `limit` caps what survives. `recallSkills({ near })` answers `{ skills, scores,
|
|
228
|
+
skipped, via, ranking }` the same way, a skill's meaning being its name, when and instructions together.
|
|
229
|
+
- **`embedMissing({ limit?, batch? })` is the explicit sweep** — every un-embedded memory and
|
|
230
|
+
skill, through `embed(texts[])` in batches, written inside the ledger's write chain, answering
|
|
231
|
+
`{ embedded, remaining }`. A positive fractional `batch` is floored to at least one.
|
|
232
|
+
A second run embeds zero and makes no seam call. A failing batch
|
|
233
|
+
ends the run with the error surfaced once: what was embedded before it is written, the rest
|
|
234
|
+
stays un-embedded and is counted in `remaining` — never a throw that loses the batch. A ledger
|
|
235
|
+
that already holds vectors under another identity is refused up front rather than turned into
|
|
236
|
+
the mixture `recall` would then refuse.
|
|
237
|
+
- **`embedOnWrite` is off by default**, because a write must not silently acquire a network
|
|
238
|
+
dependency. `createLedger({ embedder, embedOnWrite: true })` embeds a record that arrives
|
|
239
|
+
without a vector inside its own write; a seam failure then stores the record **un-embedded**
|
|
240
|
+
and reports it on the returned record as `embedError` (not part of the stored record) — one
|
|
241
|
+
bad network call never loses a memory, and `embedMissing()` closes the gap later.
|
|
242
|
+
- **No arithmetic lives here.** The cosine is `@jarenjs/core/vector`'s; the ledger calls it and
|
|
243
|
+
computes nothing. Ranked recall is an exact sweep by default — one adapter scan plus one cosine
|
|
244
|
+
per embedded record, reported as `via: 'sweep'` — which is the right tool for a ledger of
|
|
245
|
+
thousands and the wrong one for millions; the instrument below says what it costs, and an
|
|
246
|
+
adapter that declares `rank` (below) turns that scan into a k-nearest plan. The same question over a `@jarenjs/db`
|
|
247
|
+
collection is the query language's own k-nearest composition (QUERY-FORMAT §8.15) — and
|
|
248
|
+
over a `derive: 'vector'` column the store plans it as a cut the engine finishes, with
|
|
249
|
+
`explain()` naming the mode (its ARCHITECTURE, "The k-nearest plan").
|
|
250
|
+
- **Measured, whichever way it fell.** `benchmark/retrieval.js` scores the ranked path beside the
|
|
251
|
+
default over the same seeded corpus, through the deterministic reference embedder
|
|
252
|
+
(§Embeddings — lexical, so a mechanism score, not a model-quality claim): <!--fact:retrieval.ranked-->5.0% of questions at 10,000 memories through the hash-trigram-64 reference embedder (33.8% at 1,000), ahead of tag match and recency's 1.3%<!--/fact-->.
|
|
253
|
+
A real model's number is the host's to measure through the same instrument's `--live` tier.
|
|
254
|
+
|
|
255
|
+
The [labelled retrieval instrument](https://github.com/jklarenbeek/jarenjs/blob/main/benchmark/README.md#labelled-recall-and-repeated-refinement)
|
|
256
|
+
adds a checksum-pinned SciFact import, neutral host datasets, resumable vectors
|
|
257
|
+
and explicit dataset/embedding classes. It measures the real ledger using
|
|
258
|
+
standard fractional recall, MRR and nDCG; the historical synthetic row above
|
|
259
|
+
uses hit rate. Model claims remain tied to their dataset, provider and date.
|
|
260
|
+
|
|
261
|
+
Reference measurement: <!--fact:recall.reference-->baai/bge-m3 (1024 dimensions, openrouter, 2026-09-09): recall@10 0.783, MRR@10 0.608, nDCG@10 0.644 on 5183 SciFact documents and 300 test queries.<!--/fact-->
|
|
262
|
+
|
|
263
|
+
#### A durable ledger over `@jarenjs/db`
|
|
264
|
+
|
|
265
|
+
The adapter is four methods over one collection, and it needs nothing from this package —
|
|
266
|
+
the storage contract is the whole interface between a ledger and where it lives. Declare a
|
|
267
|
+
`derive: 'vector'` column over the records' embeddings and the same adapter can implement
|
|
268
|
+
the **optional fifth**, `rank`, so `recall({ near })` is answered by the store's k-nearest
|
|
269
|
+
plan instead of by reading every record back to be swept:
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
import { openStore } from '@jarenjs/db';
|
|
273
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One collection is the whole schema a ledger needs: the storage key,
|
|
277
|
+
* the JSON value, and — when records carry embeddings — one packed
|
|
278
|
+
* vector column derived from `value.embedding`. `value` is deliberately
|
|
279
|
+
* untyped: the ledger stores objects, strings and arrays under the same
|
|
280
|
+
* contract, and only the vector member has to be declared.
|
|
281
|
+
*/
|
|
282
|
+
const ledgerModel = (dims) => ({
|
|
283
|
+
$model: '0.1',
|
|
284
|
+
collections: {
|
|
285
|
+
slots: {
|
|
286
|
+
schema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
key: { type: 'string' },
|
|
290
|
+
value: { properties: { embedding: { type: 'array', items: { type: 'number' } } } },
|
|
291
|
+
},
|
|
292
|
+
required: ['key'],
|
|
293
|
+
},
|
|
294
|
+
key: '/key',
|
|
295
|
+
indexes: dims === undefined ? []
|
|
296
|
+
: [{ name: 'by_vec', path: '$.value.embedding', derive: 'vector', dims }],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
/** A collection answer as a list — `execute` returns the bare item for one. */
|
|
302
|
+
const many = (result) => (Array.isArray(result) ? result : result === undefined ? [] : [result]);
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A durable ledger storage adapter over one `@jarenjs/db` collection:
|
|
306
|
+
* the four methods, plus `rank` when a vector column is declared. It
|
|
307
|
+
* imports nothing from the ledger implementation — the storage contract is the
|
|
308
|
+
* whole interface between them.
|
|
309
|
+
*
|
|
310
|
+
* The prefix is INLINE in every query document rather than bound as an
|
|
311
|
+
* external, because a string operator only translates to SQL with a
|
|
312
|
+
* literal pattern; inlined, `keys()` and the ranked read both become a
|
|
313
|
+
* range scan over the key column. The ledger asks for a handful of
|
|
314
|
+
* distinct prefixes, so the documents are built once each and cached.
|
|
315
|
+
*/
|
|
316
|
+
export async function createDbStorage({ path = ':memory:', dims } = {}) {
|
|
317
|
+
const store = await openStore(ledgerModel(dims), { driver: nodeDriver(), path });
|
|
318
|
+
const slots = store.collection('slots');
|
|
319
|
+
const documents = new Map();
|
|
320
|
+
|
|
321
|
+
/** Every query document one prefix needs, built once. */
|
|
322
|
+
const forPrefix = (prefix) => {
|
|
323
|
+
let built = documents.get(prefix);
|
|
324
|
+
if (built !== undefined) return built;
|
|
325
|
+
const under = { '$starts-with': ['$r.key', prefix] };
|
|
326
|
+
const score = { $similarity: ['$r.value.embedding', '$q'] };
|
|
327
|
+
const mine = [{ $eq: ['$r.value.embeddedBy.model', '$model'] },
|
|
328
|
+
{ $eq: ['$r.value.embeddedBy.dims', '$dims'] }];
|
|
329
|
+
const counted = (where) => ({ $count: { $for: { r: '$[*]' }, $where: where, $return: '$r' } });
|
|
330
|
+
const ranked = {
|
|
331
|
+
$for: { r: '$[*]' },
|
|
332
|
+
$where: { $and: [under, ...mine] },
|
|
333
|
+
// the ledger re-scores and re-sorts what comes back, so this
|
|
334
|
+
// ordering only has to agree with its tie-break: score, then
|
|
335
|
+
// newest, then the key
|
|
336
|
+
$orderby: [{ $key: score, $dir: 'desc', $empty: 'least' },
|
|
337
|
+
{ $key: '$r.value.at', $dir: 'desc' }, '$r.key'],
|
|
338
|
+
$return: { key: '$r.key', score },
|
|
339
|
+
};
|
|
340
|
+
built = {
|
|
341
|
+
keys: { $for: { r: '$[*]' }, $where: under, $orderby: ['$r.key'], $return: '$r.key' },
|
|
342
|
+
ranked,
|
|
343
|
+
window: (limit) => ({ $subsequence: [ranked, 0, limit] }),
|
|
344
|
+
skipped: counted({ $and: [under, { $not: { $exists: '$r.value.embedding' } }] }),
|
|
345
|
+
held: counted({ $and: [under, { $exists: '$r.value.embedding' }] }),
|
|
346
|
+
ours: counted({ $and: [under, { $exists: '$r.value.embedding' }, ...mine] }),
|
|
347
|
+
names: { $distinct: { $for: { r: '$[*]' }, $where: under, $return: '$r.value.embeddedBy' } },
|
|
348
|
+
};
|
|
349
|
+
documents.set(prefix, built);
|
|
350
|
+
return built;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
mutate: async (prefix, transform) => store.transaction((tx) => {
|
|
355
|
+
const rows = tx.sync.collection('slots');
|
|
356
|
+
const prefixes = typeof prefix === 'string' ? [prefix] : prefix.prefixes ?? [];
|
|
357
|
+
const keys = [...new Set([...(prefix.keys ?? []), ...prefixes.flatMap((part) => many(rows.execute(forPrefix(part).keys)))])].sort();
|
|
358
|
+
const matches = (key) => (prefix.keys ?? []).includes(key) || prefixes.some((part) => key.startsWith(part));
|
|
359
|
+
const current = Object.fromEntries(keys.map((key) => [key, rows.get(key)?.value]));
|
|
360
|
+
for (const key of Object.keys(current)) if (current[key] === undefined) delete current[key];
|
|
361
|
+
const outcome = transform(current);
|
|
362
|
+
if (!outcome || typeof outcome.then === 'function') throw new TypeError('mutate callback must be synchronous');
|
|
363
|
+
if (outcome.next !== undefined) {
|
|
364
|
+
const next = JSON.parse(JSON.stringify(outcome.next));
|
|
365
|
+
if (Object.keys(next).some((key) => !matches(key))) throw new TypeError('mutation escaped its namespace');
|
|
366
|
+
for (const key of keys) if (!Object.hasOwn(next, key)) rows.delete(key);
|
|
367
|
+
for (const [key, value] of Object.entries(next)) rows.put({ key, value });
|
|
368
|
+
}
|
|
369
|
+
return outcome.result;
|
|
370
|
+
}, { mode: 'immediate' }),
|
|
371
|
+
get: async (key) => (await slots.get(key))?.value,
|
|
372
|
+
set: async (key, value) => { await slots.put({ key, value }); },
|
|
373
|
+
delete: async (key) => { await slots.delete(key); },
|
|
374
|
+
// sorted, because the ledger reads listings, the goal archive and a
|
|
375
|
+
// snapshot's entries in key order and its zero-padded sequences
|
|
376
|
+
// exist so that order is chronological
|
|
377
|
+
keys: async (prefix = '') => many(await slots.execute(forPrefix(prefix).keys)),
|
|
378
|
+
/**
|
|
379
|
+
* The optional fifth: rank where the records live. The window is the
|
|
380
|
+
* k-nearest plan — the vector column cuts the candidates, the engine
|
|
381
|
+
* orders them — and the two reports the ledger needs are counts,
|
|
382
|
+
* which push to SQL. Naming every identity costs a scan, so it is
|
|
383
|
+
* paid only when the counts prove a mixture, which is the one case
|
|
384
|
+
* that is about to refuse anyway.
|
|
385
|
+
*/
|
|
386
|
+
rank: async ({ prefix, vector, model, dims: width, limit }) => {
|
|
387
|
+
const docs = forPrefix(prefix);
|
|
388
|
+
const externals = { q: vector, model, dims: width };
|
|
389
|
+
const hits = many(await slots.execute(
|
|
390
|
+
limit === undefined ? docs.ranked : docs.window(limit), { externals }));
|
|
391
|
+
const skipped = await slots.execute(docs.skipped);
|
|
392
|
+
const held = await slots.execute(docs.held);
|
|
393
|
+
const ours = await slots.execute(docs.ours, { externals });
|
|
394
|
+
const identities = held === ours
|
|
395
|
+
? (ours === 0 ? [] : [{ model, dims: width }])
|
|
396
|
+
: many(await slots.execute(docs.names));
|
|
397
|
+
return { hits, skipped, identities };
|
|
398
|
+
},
|
|
399
|
+
// beyond the contract, and deliberately: the store is the host's to
|
|
400
|
+
// migrate, back up and explain, and hiding it would only mean
|
|
401
|
+
// opening a second one to do any of that
|
|
402
|
+
store,
|
|
403
|
+
close: () => store.close(),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`recall({ near })` reports which path answered — `via: 'adapter'` when the store ranked,
|
|
409
|
+
`via: 'sweep'` when the ledger did. Exact adapters answer the same records with the same scores
|
|
410
|
+
as the sweep: the adapter selects candidates, the kernels re-score them, and `minScore` and
|
|
411
|
+
`limit` are applied here, so an adapter can never quietly change what a similarity means. A
|
|
412
|
+
query carrying `tags` or `where` narrows on members the adapter knows nothing about and
|
|
413
|
+
takes the sweep. An adapter whose `rank` answers anything other than
|
|
414
|
+
`{ hits, skipped, identities }` is refused rather than trusted, because a capability that
|
|
415
|
+
cannot be relied on to report what it skipped is worse than one that is absent.
|
|
416
|
+
|
|
417
|
+
`identities` is what makes the mixture refusal the ledger's and not each adapter's: the
|
|
418
|
+
store reports the distinct `embeddedBy` it holds under the prefix, and the wording, the
|
|
419
|
+
order and the decision stay in one place. Above, that report is two pushed `COUNT(*)`
|
|
420
|
+
statements on the hot path — the naming scan is paid only when the counts prove a mixture,
|
|
421
|
+
which is the one case about to refuse anyway.
|
|
422
|
+
|
|
423
|
+
Adapters may also return `ranking: { algorithm, exhaustive, candidateCount }`.
|
|
424
|
+
Approximate selectors declare `exhaustive: false`; old adapters normalize to
|
|
425
|
+
`legacy-exact` with `exhaustive: true`. The sweep reports `exact-cosine`.
|
|
426
|
+
`candidateCount` counts returned candidates before filtering and capping, not
|
|
427
|
+
all indexed records. Approximate adapters should return enough candidates for
|
|
428
|
+
ledger re-scoring. Their candidate set can lose recall; their supplied scores
|
|
429
|
+
never become the final scores. Returned keys must be unique and under the
|
|
430
|
+
requested prefix, and stored ids, embedding identities and vectors are checked.
|
|
431
|
+
A concurrently deleted candidate is omitted. The adapter must still report
|
|
432
|
+
every identity under the prefix; completeness cannot be proved from its selected
|
|
433
|
+
hits alone. Tag/where filters continue to use the exhaustive sweep.
|
|
434
|
+
|
|
435
|
+
Index decision: <!--fact:recall.annDecision-->0/6 contender rows cleared all bars; retain exact. Required exact-top-10 recall ≥ 0.95, p95 speedup ≥ 2×, and a measured exact p95 ≥ 100 ms. The largest reference corpus contains 5183 documents; scale beyond it remains unmeasured.<!--/fact-->
|
|
436
|
+
|
|
437
|
+
Selecting "the records whose `embeddedBy` is `{ model, dims }`" is the one piece of the
|
|
438
|
+
ledger's rule an adapter has to apply itself, so it is exported rather than left to be
|
|
439
|
+
re-derived:
|
|
440
|
+
|
|
441
|
+
```js
|
|
442
|
+
import { sameIdentity, describeIdentity } from '@tangleai/context/ledger';
|
|
443
|
+
|
|
444
|
+
rank: async ({ prefix, vector, model, dims, limit }) => {
|
|
445
|
+
const query = { model, dims };
|
|
446
|
+
const under = await readUnder(prefix);
|
|
447
|
+
const mine = under.filter((record) => sameIdentity(record.embeddedBy, query));
|
|
448
|
+
const identities = [...new Map(under
|
|
449
|
+
.filter((record) => record.embedding !== undefined)
|
|
450
|
+
.map((record) => [describeIdentity(record.embeddedBy), record.embeddedBy])).values()];
|
|
451
|
+
return { hits: score(mine, vector).slice(0, limit), skipped: under.length - mine.length, identities };
|
|
452
|
+
},
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The SQL adapter above cannot call it — a predicate that pushes to the database has to be
|
|
456
|
+
written as a query document — which is exactly why the JavaScript form is published: every
|
|
457
|
+
other adapter, and every host keeping its own vector store beside the ledger, applies one
|
|
458
|
+
implementation instead of writing a second. Two edges make that worth insisting on:
|
|
459
|
+
`sameIdentity(undefined, undefined)` is **false** (a record with no identity has no space
|
|
460
|
+
to share, so "unknown" must never rank against "unknown"), and matching `dims` alone is
|
|
461
|
+
never enough (two models at 768 produce vectors whose cosine is arithmetic without
|
|
462
|
+
meaning).
|
|
463
|
+
|
|
464
|
+
Each dropped round is archived to a slot **before** the synopsis is written, and every
|
|
465
|
+
synopsis line carries its address:
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
[Earlier context was compacted. 33 round(s) are ARCHIVED, not lost: recall("rx-…") lists
|
|
469
|
+
every address; recall(name) returns one in full. What happened:]
|
|
470
|
+
- called fetch_record({"index":7}) → {"id":"REC0007","notes":"xxxx… [recall("r-8kq2p-442") · 442B]
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The excerpt is now a *preview*, not a summary. A `recall` tool is registered alongside your
|
|
474
|
+
own (only when there is a ledger, and never over a `recall` you registered yourself), so
|
|
475
|
+
the model fetches a round back when it needs one — a normal tool call that shows up in
|
|
476
|
+
`steps` like any other, rather than an automatic re-expansion guessing which round mattered.
|
|
477
|
+
|
|
478
|
+
- **Addresses are content-derived**, so compacting the same history twice writes the same
|
|
479
|
+
slots rather than a second copy. A fingerprint collision refuses compaction before
|
|
480
|
+
dropping transcript content; exact bytes establish whether an existing address is reusable.
|
|
481
|
+
- **The allowance grows with the number of archived rounds** instead of being flat, and is
|
|
482
|
+
capped at a quarter of the budget so the addresses cannot crowd out the recent tail. The
|
|
483
|
+
budget still holds to the character.
|
|
484
|
+
- **The header survives truncation.** If the synopsis itself has to be cut, the per-round
|
|
485
|
+
addresses go but the index address does not — and the index lists every one of them.
|
|
486
|
+
- **A store that refuses a write throws** (`AiError` `AI0001`). The alternative is dropping
|
|
487
|
+
a round while claiming an address for it, which is the failure this exists to remove.
|
|
488
|
+
|
|
489
|
+
The contract, asserted over every budget the benchmark sweeps in both payload shapes
|
|
490
|
+
(`test/agents/compaction-recovery.test.js`): **every fact the full transcript held is either
|
|
491
|
+
still in the request verbatim or reachable through an address the request names** — <!--fact:horizon.ledgerRecovered-->40 of 40<!--/fact--> record values at the same budget, where the same runs without a ledger keep <!--fact:horizon.synopsisBand-->1 to 28<!--/fact--> of them. What that
|
|
492
|
+
costs is a few characters of verbatim retention at the tightest budgets, published beside
|
|
493
|
+
the win.
|
|
494
|
+
|
|
495
|
+
That is the model-free half. Here is a real model on the same contexts — the realistic
|
|
496
|
+
payload shape, one needle question per trial, scored by whether the answer is right:
|
|
497
|
+
|
|
498
|
+
<!--fact:horizon.liveNeedle-->
|
|
499
|
+
| history budget | without a ledger | with a ledger | recall calls |
|
|
500
|
+
| --- | --- | --- | --- |
|
|
501
|
+
| 20000 | 66.7% | 66.7% | 0 |
|
|
502
|
+
| 10000 | 50.0% | 66.7% | 1 |
|
|
503
|
+
| 6000 | 0.0% | 50.0% | 3 |
|
|
504
|
+
| 4000 | 33.3% | 100.0% | 3 |
|
|
505
|
+
| 2000 | 0.0% | 100.0% | 5 |
|
|
506
|
+
<!--/fact-->
|
|
507
|
+
|
|
508
|
+
The last column is the point: those answers were fetched, not remembered. A ledger row
|
|
509
|
+
that scored well with **zero** recalls would have scored on what was still in front of it,
|
|
510
|
+
and the number is printed either way so that cannot be read as a win. Three trials per row
|
|
511
|
+
is a small sample with a wide error bar — the ceilings above are the structural claim, this
|
|
512
|
+
is the check that a model can actually use them.
|
|
513
|
+
|
|
514
|
+
**And here is what it does not fix.** A question that needs *every* fact at once (which two
|
|
515
|
+
of forty records are closest?) is unanswerable the instant one round is cut, and a ledger
|
|
516
|
+
does not change that: forty rounds fetched one at a time do not fit the budget they were
|
|
517
|
+
cut to fit. The benchmark scores that question too and publishes it beside the needle: <!--fact:horizon.pairwise-->0% at every budget that compacts anything except ledger/front at 20000<!--/fact-->.
|
|
518
|
+
Recall is the wrong shape of answer for it: the fact is not missing, the *relation* is, and
|
|
519
|
+
no number of one-at-a-time fetches reconstructs it inside the budget. Moving that number
|
|
520
|
+
needs the corpus held *outside* the context and worked on programmatically, which is what
|
|
521
|
+
[the environment](#the-environment--a-corpus-you-work-on-not-one-you-read) and
|
|
522
|
+
[the action language](#the-action-language--a-program-the-model-writes-and-the-compiler-checks)
|
|
523
|
+
below are for — the same question, asked of an environment, is answered by a program that
|
|
524
|
+
visits every record by address while the root carries a plan and a step report. The live
|
|
525
|
+
tier of the numbers above ran on <!--fact:horizon.live-->qwen/qwen3.6-35b-a3b, 3 trial(s) per row, 146 model calls<!--/fact-->.
|
|
526
|
+
|
|
527
|
+
Without a `ledger`, all of this is inert and compaction behaves exactly as it always did.
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
## The action language — a program the model writes and the compiler checks
|
|
531
|
+
|
|
532
|
+
For typed fixture and host authoring, the [AI program pen](../jaren/docs/PROGRAM-PEN.md)
|
|
533
|
+
emits this same document and imports no AI runtime.
|
|
534
|
+
|
|
535
|
+
The environment lets a model *address* a corpus. A program lets it *work* one: a small
|
|
536
|
+
document whose steps name slots and operations, generated under a schema, compiled before
|
|
537
|
+
anything runs, and executed by the harness.
|
|
538
|
+
|
|
539
|
+
```js
|
|
540
|
+
import { createProgramRunner, createProgramAuthor } from '@tangleai/agents/program';
|
|
541
|
+
import { createStructuredOutput } from '@tangleai/models/structured';
|
|
542
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
543
|
+
import querySchema from '@jarenjs/json/schemas/jaren-query.llm-profile.schema.json' with { type: 'json' };
|
|
544
|
+
|
|
545
|
+
const runner = createProgramRunner({ environment, client, compileQuery: compileJsonQuery });
|
|
546
|
+
const author = createProgramAuthor({
|
|
547
|
+
client, environment, compileQuery: compileJsonQuery, createStructuredOutput, querySchema,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const { value: program } = await author.author('Which two records have the closest values?');
|
|
551
|
+
const result = await runner.run(program); // result.answer.text
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
A program is a list of steps, each reading `from` a slot or an earlier step and writing `as`
|
|
555
|
+
a name the next step can read:
|
|
556
|
+
|
|
557
|
+
| step | does | calls a model |
|
|
558
|
+
|---|---|---|
|
|
559
|
+
| `chunk` | splits a slot into addressable pieces | no |
|
|
560
|
+
| `grep` | records which pieces matched a pattern | no |
|
|
561
|
+
| `select` | runs a query over a JSON slot | no |
|
|
562
|
+
| `stat` / `peek` | shape, sizes, a head excerpt | no |
|
|
563
|
+
| `map` | asks one question of **every piece** | **yes** |
|
|
564
|
+
| `reduce` | combines a map's results with a query | no |
|
|
565
|
+
| `answer` | reads the slot the answer is in | no |
|
|
566
|
+
|
|
567
|
+
Three properties, each asserted rather than intended:
|
|
568
|
+
|
|
569
|
+
- **A program that does not compile never runs.** `run()` puts the document through the
|
|
570
|
+
schema and then the compiler, and returns the errors having written nothing and spent no
|
|
571
|
+
model call. The compiler resolves names against what the environment actually holds, so a
|
|
572
|
+
step reading something no earlier step produced is `AI0201` *with a pointer* — the class of
|
|
573
|
+
error small models repair well, and one a schema cannot catch. A query that will not
|
|
574
|
+
compile keeps the **query engine's own** code (`JQ0003`, …) with its pointer rebased onto
|
|
575
|
+
the step it came from.
|
|
576
|
+
- **No step can carry content.** Every member of every step is an operation name, a binding,
|
|
577
|
+
a slot reference, a bounded instruction or a query document — `test/agents/program.test.js`
|
|
578
|
+
walks the grammar and fails if a string member is ever declared without a cap. So the
|
|
579
|
+
program is the same size for a 10 kB corpus and a 10 MB one, which is what keeps the root
|
|
580
|
+
request flat while a program runs.
|
|
581
|
+
- **`map` is the only step that calls a model**, so it is the only thing to bound:
|
|
582
|
+
`maxSubcalls` caps how many are made (and a capped map *says* how many pieces it did not
|
|
583
|
+
visit), `maxConcurrentSubcalls` caps how many are in flight, and the run's `AbortSignal`
|
|
584
|
+
reaches every one of them. A sub-call that fails is a **result** — `{ error }` in its own
|
|
585
|
+
slot — and the map completes, because forty pieces of which one was unreadable is a
|
|
586
|
+
finished map with one recorded failure, not a crashed program.
|
|
587
|
+
|
|
588
|
+
### Fan-out is concurrent, and that is the point
|
|
589
|
+
|
|
590
|
+
The RLM paper this design follows states its own limitation plainly: its sub-calls are
|
|
591
|
+
sequential, and "RLMs without asynchronous LM calls are slow". Running the fan-out in a
|
|
592
|
+
harness rather than inside an evaluator is what makes concurrency available at all — the
|
|
593
|
+
same program and the same sub-calls, run one at a time and then four at a time, is worth <!--fact:horizon.programFanout-->3.9x (814ms sequential vs 209ms at concurrency 4, 40 sub-calls of 20ms each)<!--/fact-->.
|
|
594
|
+
The per-call latency there is synthetic and deliberately so: a benchmark that made eighty
|
|
595
|
+
real calls to time its own scheduler would be measuring the provider's queue.
|
|
596
|
+
|
|
597
|
+
**What it answers, and what the root pays for it.** The pairwise question that compaction
|
|
598
|
+
scores 0% on at every budget is answered at a ceiling of <!--fact:horizon.program-->100%, with 40 of 40 records reaching the reduce over 40 sub-calls, while the root request carried 937 characters against a corpus of 17719<!--/fact-->.
|
|
599
|
+
By contrast a needle question over the same environment costs **one** sub-call, because
|
|
600
|
+
`grep` narrows to the piece that mentions the record before anything is spent on it.
|
|
601
|
+
|
|
602
|
+
**On the cheap tier** (D8 — the campaign targets the weak model deliberately, and publishes
|
|
603
|
+
the result whichever way it falls), the measurement is <!--fact:horizon.programLive-->2 of 3 authored programs compiled — but 1 of those attempts never came back at all (the 300 s deadline), so of the 2 that answered, 2 compiled. Answering 40 sub-calls itself it reached 40 of 40 records (0 sub-call(s) failed) and named the CORRECT pair<!--/fact-->.
|
|
604
|
+
Read that second half as the campaign's own result and the first half as a caveat about the
|
|
605
|
+
transport, not the tier: the sub-calls are where the model does the work, and it did it.
|
|
606
|
+
|
|
607
|
+
### Why there is no `$llm` operator
|
|
608
|
+
|
|
609
|
+
The obvious-looking alternative is to register an async `$llm` operator into the JSLT/query
|
|
610
|
+
registry so a stylesheet could call a model inline. **Deliberately not done.**
|
|
611
|
+
`@jarenjs/core`'s operators are pure synchronous functions and both evaluators are
|
|
612
|
+
synchronous by construction; making them async for this one caller would change an engine
|
|
613
|
+
that `@jarenjs/db` pushes down into, `@jarenjs/md` renders directives with and
|
|
614
|
+
`@jarenjs/app` derives state from — every one of them would inherit a promise, to save this
|
|
615
|
+
package a `map` step.
|
|
616
|
+
|
|
617
|
+
So the division is fixed, and it is worth stating because it is the first thing a reader
|
|
618
|
+
will want to reopen: **the program selects (pure, synchronous, compiled) and the harness
|
|
619
|
+
awaits (async, bounded, cancellable).** `map` is the seam between the two halves and it is
|
|
620
|
+
the only one. The pairwise question is answered under that rule — the closest pair of forty
|
|
621
|
+
records is a `$fold` over `$orderby`-sorted tuples, which is arithmetic the query engine
|
|
622
|
+
already does once the model has read each record once.
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
## Recursion — a job, not a conversation
|
|
626
|
+
|
|
627
|
+
`createAgent` is a bounded tool loop: you talk to it. `createLongHorizonAgent` is the
|
|
628
|
+
other shape — a corpus, a question over all of it, and nobody waiting to answer a
|
|
629
|
+
follow-up. It authors a program, runs it, and may let any sub-call be **another agent over
|
|
630
|
+
its own slice**.
|
|
631
|
+
|
|
632
|
+
```js
|
|
633
|
+
import { createLongHorizonAgent } from '@tangleai/agents/recursive';
|
|
634
|
+
|
|
635
|
+
const agent = createLongHorizonAgent({
|
|
636
|
+
client, environment, compileQuery: compileJsonQuery,
|
|
637
|
+
createStructuredOutput, createProgramAuthor, createProgramRunner, createEnvironment,
|
|
638
|
+
depth: 1, // default 1, hard cap 3
|
|
639
|
+
budget: { turns: 40, tokens: 200_000 }, // shared by the WHOLE tree
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const { answer, trajectory, stopReason, spent } = await agent.run('Which two are closest?');
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**Depth defaults to 1 and caps at 3.** The research this follows runs depths 0–3 and finds
|
|
646
|
+
most of its gain at depth 1, with depth 3 helping only on information-dense tasks — so
|
|
647
|
+
deeper is not the default, because it multiplies cost on every task where it does not help.
|
|
648
|
+
Ask for more and you get the cap *and are told*: `depthClamped` is true and the trajectory
|
|
649
|
+
records it. The benchmark publishes the trade rather than asserting it, as **median and p95**
|
|
650
|
+
call cost per depth — never the mean, which is the one summary that would hide the outlier
|
|
651
|
+
trajectories a caller has to provision for.
|
|
652
|
+
|
|
653
|
+
**Budgets are shared by the tree.** Depth × fan-out is multiplicative — depth 3 fanning
|
|
654
|
+
twenty ways is eight thousand leaf calls — so one account is threaded through every level,
|
|
655
|
+
and a turn is *reserved before* a call rather than charged after it, which is what keeps the
|
|
656
|
+
bound exact when four sub-calls launch together. Tokens cannot be known in advance, so a
|
|
657
|
+
token budget may overshoot by at most `maxConcurrentSubcalls - 1` calls' worth; that bound
|
|
658
|
+
is asserted, not hoped for. When a budget runs out the tree stops with a named `stopReason`
|
|
659
|
+
and **leaves its partial work in slots**, which is what makes a stopped run resumable rather
|
|
660
|
+
than merely failed.
|
|
661
|
+
|
|
662
|
+
**A child is isolated, and the isolation is invisible to it.** Each child gets the same
|
|
663
|
+
store seen through its own prefix: names go in prefixed and come out stripped, so a child's
|
|
664
|
+
corpus is `corpus` and it authors exactly the program it would author at the root. A child
|
|
665
|
+
naming a sibling's real address resolves *beneath itself*, so the sibling is unreachable
|
|
666
|
+
rather than merely discouraged — no check has to remember to run.
|
|
667
|
+
|
|
668
|
+
**A child's failure is a value.** A child whose program will not compile returns
|
|
669
|
+
`{ error, depth, address }` into its parent's map result slot, and the parent's map
|
|
670
|
+
completes. One bad branch is a recorded failure with somewhere to look, not a silent empty
|
|
671
|
+
answer — the propagation failure the research names.
|
|
672
|
+
|
|
673
|
+
### Recursive result contracts
|
|
674
|
+
|
|
675
|
+
`createLongHorizonAgent` compiles every level in recursive mode, including depth zero.
|
|
676
|
+
Inject `analyzeQuery` and `annotateTypes` from `@jarenjs/json/query` alongside
|
|
677
|
+
`compileQuery`. Each reduce and the final answer must preserve an item or sequence
|
|
678
|
+
of `{slot:string,value:any}` envelopes. The harness unwraps a child's envelope before
|
|
679
|
+
its value enters the parent's map; a sequence contributes its elements individually.
|
|
680
|
+
Empty query sequences normalize to an empty result.
|
|
681
|
+
|
|
682
|
+
```js
|
|
683
|
+
const query = ['$[*]']; // one array containing every map envelope
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`compileProgram(doc, {recursive: true, compileQuery, analyzeQuery, annotateTypes})`
|
|
687
|
+
refuses incompatible or unknown shapes with `AI0208` and the reduce's document path.
|
|
688
|
+
For unknown inference only, a reduce may declare `outputSchema` with required `slot`
|
|
689
|
+
and `value` members. The runner validates that declaration before writing its result;
|
|
690
|
+
a lying declaration is `AI0209`. Standalone programs may still reduce to arbitrary JSON.
|
|
691
|
+
This is a structural guarantee: correctness of the value still needs a host checker.
|
|
692
|
+
|
|
693
|
+
The recursive author example collects envelopes rather than choosing a maximum.
|
|
694
|
+
Map results wrap the parsed leaf reply in `value`; an object, array or null reply
|
|
695
|
+
is preserved as such. In recursive mode a failed map entry has `value: null`
|
|
696
|
+
alongside its `error` diagnostic (and any `raw`, `depth` or `address` metadata).
|
|
697
|
+
The collector keeps these entries; an error is distinguishable from a successful
|
|
698
|
+
null reply, and the map's `failed` count still records it. A structurally successful
|
|
699
|
+
program may contain only failures or no relevant facts; hosts must check its outcome.
|
|
700
|
+
An array constructor collects a query sequence into one
|
|
701
|
+
JSON array, including the empty case. An object member needs one value, so a bare
|
|
702
|
+
wildcard there fails when several items match. Numeric or string extrema require
|
|
703
|
+
an explicit homogeneous scalar projection and are not a general fact reducer.
|
|
704
|
+
|
|
705
|
+
`createProgramRunner().run()` returns the discriminated `ProgramRunResult` type:
|
|
706
|
+
`ok: true` has a `ProgramAnswer`, and `ok: false` has a null answer and an error.
|
|
707
|
+
Both retain completed step details, `subcalls`, `failed`, elapsed `ms` and the
|
|
708
|
+
configured `concurrency`. A compile refusal has empty steps and zero counts.
|
|
709
|
+
`answer.truncated` explicitly reports when the requested
|
|
710
|
+
preview is shorter than the stored result. A host that needs the full JSON result
|
|
711
|
+
can use `readProgramAnswer(environment, answer, {maxChars: 64000})` from
|
|
712
|
+
`@tangleai/agents` or `@tangleai/agents/program`. It checks slot metadata before loading
|
|
713
|
+
content, verifies the actual size afterwards, and returns `{ok, answer}` or
|
|
714
|
+
`{ok: false, error}`. The environment must be the one that owns the answer's scope.
|
|
715
|
+
|
|
716
|
+
`createLongHorizonAgent` uses this same reader before unwrapping a child's result.
|
|
717
|
+
Its `maxAnswerChars` option defaults to 200,000 characters per child. An oversized
|
|
718
|
+
or missing child result becomes a map failure with a diagnostic. The root answer
|
|
719
|
+
remains a bounded preview; increasing this child limit does not enlarge it.
|
|
720
|
+
|
|
721
|
+
### Verified program reuse
|
|
722
|
+
|
|
723
|
+
`createProgramSession({...authorOptions, reuse})` composes authoring and execution.
|
|
724
|
+
Omit `reuse` for fresh authoring. Opt-in policy requires `environmentId`, `schemaVersion`
|
|
725
|
+
and `check({question,result,reused})`, returning a boolean or validation outcome. The
|
|
726
|
+
host must change the environment identity when its corpus, tools or semantics change.
|
|
727
|
+
Optional `tools` names are compared exactly; `embedder` embeds the question for storage.
|
|
728
|
+
Ledger recall keeps its existing embedder identity checks.
|
|
729
|
+
|
|
730
|
+
Candidates above `threshold` are compiled and gated against current slot names.
|
|
731
|
+
An identical question fingerprint may proceed; a paraphrase additionally
|
|
732
|
+
requires `accept({question,skill,score}) === true` from the host. Similarity alone grants
|
|
733
|
+
no execution authority. Successful checked programs become validated skill records.
|
|
734
|
+
A rejected or wrong reuse records separate failure evidence and falls back to fresh
|
|
735
|
+
once. A failed fresh outcome ends the request. Returned `reuse.events` explains each
|
|
736
|
+
choice. For long-horizon jobs, the same policy applies at the root.
|
|
737
|
+
|
|
738
|
+
Fixture scorecard: <!--fact:program.reuse-->25/25 fixture answers correct; 5 author calls and 1125 token proxy with reuse, versus 25 calls and 5000 tokens fresh. Selected threshold 0.9 with 32 hash dimensions, host suitability proof and an outcome checker.<!--/fact-->
|
|
739
|
+
|
|
740
|
+
The live stream uses the same fixture-family checker and reports real provider token
|
|
741
|
+
usage; retrieval uses the local hash embedder and has no provider token charge.
|
|
742
|
+
|
|
743
|
+
<!--fact:program.reuseLive-->
|
|
744
|
+
|
|
745
|
+
| profile | mode | correct | author calls | reported tokens | reused answers |
|
|
746
|
+
|---------|------|---------|--------------|-----------------|----------------|
|
|
747
|
+
| primary | fresh | 0/4 | 4 | 10073 | 0 |
|
|
748
|
+
| primary | reuse | 0/4 | 4 | 3934 | 0 |
|
|
749
|
+
| secondary | fresh | 4/4 | 4 | 3183 | 0 |
|
|
750
|
+
| secondary | reuse | 4/4 | 2 | 1544 | 2 |
|
|
751
|
+
|
|
752
|
+
<!--/fact-->
|
|
753
|
+
|
|
754
|
+
The fixture threshold is not calibrated for other embedders or real question streams.
|
|
755
|
+
`benchmark/programmind-reuse.json` retains every threshold, wrong execution and fallback.
|
|
756
|
+
|
|
757
|
+
### Derived authoring profiles and host routes
|
|
758
|
+
|
|
759
|
+
Query, JSLT, app, FSM, DAG, statechart and composed workflow have generated
|
|
760
|
+
`*.authoring.schema.json` artifacts. `grammar: 'statechart'` uses
|
|
761
|
+
`compileStatechart`; `grammar: 'workflow'` uses `compileWorkflow` with the host's
|
|
762
|
+
versioned task registry. Their schemas live in `packages/flow/schemas/`;
|
|
763
|
+
workflow validation registers the DAG, query and JSLT grammars too.
|
|
764
|
+
`createGrammarAuthor({client, grammar, profile, schema, refs, compile})` always validates
|
|
765
|
+
against the full schema after profile decoding and then invokes the injected compiler.
|
|
766
|
+
Profiles intentionally allow values that the full grammar rejects. `docs:check` checks
|
|
767
|
+
source, named seam, profile hashes and generated output for drift.
|
|
768
|
+
|
|
769
|
+
<!--fact:program.profiles-->
|
|
770
|
+
|
|
771
|
+
| grammar | full closure bytes | profile bytes | full branches | profile branches |
|
|
772
|
+
|---------|--------------------|---------------|---------------|------------------|
|
|
773
|
+
| model | 8972 | 8903 | 7 | 7 |
|
|
774
|
+
| query | 20709 | 3564 | 47 | 10 |
|
|
775
|
+
| jslt | 23515 | 3491 | 59 | 6 |
|
|
776
|
+
| app | 47275 | 2799 | 106 | 0 |
|
|
777
|
+
| fsm | 24140 | 3026 | 51 | 4 |
|
|
778
|
+
| dag | 49558 | 5128 | 112 | 6 |
|
|
779
|
+
| statechart | 23058 | 2634 | 49 | 2 |
|
|
780
|
+
| workflow | 52878 | 3685 | 121 | 9 |
|
|
781
|
+
|
|
782
|
+
<!--/fact-->
|
|
783
|
+
|
|
784
|
+
Authors and program subcalls accept `selectModel({purpose,grammar,depth,limits})`.
|
|
785
|
+
Return `{client,identity}` or an ordered list for transport/timeout fallback. The default
|
|
786
|
+
uses the supplied client. Purposes are `author`, `subcall`, and `stylesheet`; embedding
|
|
787
|
+
routing is deliberately refused by the chat wrapper to protect embedding identity.
|
|
788
|
+
`limits` accepts `deadlineMs`, `outputTokens`, and `reasoningTokens`. Provider-reported
|
|
789
|
+
overruns are charged and refused; a remote provider can exceed a requested token limit
|
|
790
|
+
before the client learns its usage. Each fallback consumes the shared turn budget.
|
|
791
|
+
`onRoute` reports identity, outcome, usage and elapsed time. Tool-bearing requests cannot
|
|
792
|
+
use this retry path. The website retains its existing host selection because live evidence
|
|
793
|
+
does not establish a better default.
|
|
794
|
+
|
|
795
|
+
### Information-dense depth frontier
|
|
796
|
+
|
|
797
|
+
The original hierarchical corpus combines accepted leaf revisions into section and
|
|
798
|
+
regional totals, with rejected revisions as distractors. All depths receive identical
|
|
799
|
+
source and questions and use the same answer/evidence checker. Scripted extraction proves
|
|
800
|
+
traversal and accounting; it does not measure intelligence.
|
|
801
|
+
|
|
802
|
+
<!--fact:program.depth-->
|
|
803
|
+
|
|
804
|
+
| depth | correct fixture tasks | author calls | subcalls | token proxy |
|
|
805
|
+
|-------|-----------------------|--------------|----------|-------------|
|
|
806
|
+
| 0 | 1/1 | 1 | 2 | 150 |
|
|
807
|
+
| 1 | 1/1 | 3 | 5 | 400 |
|
|
808
|
+
| 2 | 1/1 | 8 | 14 | 1100 |
|
|
809
|
+
| 3 | 1/1 | 22 | 29 | 2550 |
|
|
810
|
+
|
|
811
|
+
<!--/fact-->
|
|
812
|
+
|
|
813
|
+
Live depth results: <!--fact:program.depthLive-->0/8 live depth tasks correct; 2 timed-out calls and 6 provider token-ceiling violations. No deeper default is justified.<!--/fact-->
|
|
814
|
+
|
|
815
|
+
Depth remains one by default, capped at three. A deeper default requires a live correctness
|
|
816
|
+
gain at the cost bound declared in `benchmark/programmind-depth-fixture.json`.
|
|
817
|
+
|
|
818
|
+
### What the cheap tier actually managed
|
|
819
|
+
|
|
820
|
+
Published because it is the campaign's own bet (D8) and because half of it lost. On the
|
|
821
|
+
qwen tier, the **program** path works: it authored plans that compile, answered all forty
|
|
822
|
+
sub-calls itself, and named the right pair — the number is in §"The action language" above.
|
|
823
|
+
|
|
824
|
+
The **recursive** path did not. Measured at depths 1 and 2, it managed <!--fact:horizon.depthLive-->0 of 4 tasks at depths 1 and 2 — every one of them died on the 300-second deadline during its first authoring call, so what this measured is that the recursive path does not currently RUN on this tier, not that it runs badly<!--/fact-->.
|
|
825
|
+
|
|
826
|
+
Read that precisely, because the distinction matters: this is not "recursion answers badly
|
|
827
|
+
on a small model". It is "recursion did not get far enough to be scored". The authoring call
|
|
828
|
+
at each level carries the digest, the question, a worked example and the program schema, and
|
|
829
|
+
on this tier that request exceeds a 300-second deadline — **even streamed**, which rules out
|
|
830
|
+
the non-streaming hang this repo measured elsewhere. Every level needs one such call, so the
|
|
831
|
+
chance of at least one timeout compounds with depth, which is exactly the shape observed:
|
|
832
|
+
the single-level program path lost 1 attempt in 3, the recursive path lost 4 in 4.
|
|
833
|
+
|
|
834
|
+
The model-free depth numbers in the benchmark are therefore the honest ones for now — they
|
|
835
|
+
say what recursion *costs* (1.5× and 2.0× the calls for the same answer on these tasks) and
|
|
836
|
+
say nothing about what it is worth on a task where depth should pay. The new hierarchical scorecards above retain the open provider-quality limitation.
|
|
837
|
+
|
|
838
|
+
### What is not guarded
|
|
839
|
+
|
|
840
|
+
Guardrails for recursive LM systems are under-explored, and this package does not pretend
|
|
841
|
+
otherwise. The depth cap, shared budget and abort signal bound execution; optional route limits also bound individual calls. There is no detection of a child that answers confidently and
|
|
842
|
+
wrongly, no loop detection beyond depth, and no per-branch quality gate. A thinking model
|
|
843
|
+
needs output room for the authoring call, and the finding in §"Thinking can be turned off"
|
|
844
|
+
above is *sharper* here, not exempt: recursion is the extreme case of a tool loop, so
|
|
845
|
+
turning thinking off wrecks it.
|
|
846
|
+
|
|
847
|
+
### Heartbeats are the host's
|
|
848
|
+
|
|
849
|
+
There is no scheduler here, deliberately. Re-entering a session on a timer is a *host*
|
|
850
|
+
concern — a browser page, a service worker, a cron — and this package injects its
|
|
851
|
+
environment rather than owning it. The ledger plus `agent.resume()` is the primitive: the
|
|
852
|
+
goal, the progress and the memories reload from storage and the run continues. What decides
|
|
853
|
+
*when* that happens is yours, and keeping it out is what lets the same agent run in a static
|
|
854
|
+
page with no store at all.
|