@tangleai/agents 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.js ADDED
@@ -0,0 +1,1021 @@
1
+ //@ts-check
2
+ /**
3
+ * The agent loop: a bounded, deterministic tool-call round-trip over a
4
+ * chat client and a toolbox. The model proposes tool calls; the loop
5
+ * executes them through the schema-guarded toolbox, appends the
6
+ * results, and asks again — up to `maxToolRounds` times, then it stops
7
+ * and says so instead of spinning. Weak local models are first-class
8
+ * citizens here: malformed tool arguments and failing tools become
9
+ * readable `{ error }` results the model can correct, never crashes.
10
+ *
11
+ * `send` never mutates the history it is given; it returns the full
12
+ * new transcript (wire-shape messages) so the host can persist it and
13
+ * send it back next turn.
14
+ *
15
+ * Given a `ledger`, compaction stops destroying: every round that
16
+ * leaves the request is written to an addressable slot first, the
17
+ * synopsis carries the addresses, and a `recall` tool fetches one back.
18
+ * Without a ledger the old path runs unchanged, synchronously, byte for
19
+ * byte — the new behaviour is opt-in and named.
20
+ *
21
+ * A ledger also gives the loop an OBJECTIVE and a BUDGET. The active
22
+ * goal and its progress are composed into the system prompt of every
23
+ * request — unconditionally, because it is the thing being worked on —
24
+ * while memories and skills are retrieved only when asked for. The
25
+ * composition happens in the REQUEST and never in the transcript: the
26
+ * history the host persists stays the immutable base prompt plus the
27
+ * conversation, so a resumed session composes today's goal rather than
28
+ * accumulating yesterday's.
29
+ */
30
+
31
+ import { goalPrompt } from '@tangleai/context/retention';
32
+ import { AiError } from '@tangleai/models/errors';
33
+ import { createToolbox } from './toolbox.js';
34
+ import { slotAddressesIn, RECALL_TOOL_NAME, createRecallTool, roundSlotName, indexSlotName, slotAddress, slotRef } from '@tangleai/context/recall';
35
+ import { environmentTools } from '@tangleai/context/environment';
36
+ import { excerpt, truncate, sizeOf } from '@jarenjs/core/chunk';
37
+
38
+ /**
39
+ * @typedef {Object} AgentHooks
40
+ * @property {(text: string) => void} [onDelta] - streamed reply text
41
+ * @property {(text: string) => void} [onReasoning] - streamed thinking
42
+ * @property {(call: { name: string, arguments: string }) => void} [onToolCall]
43
+ * @property {(step: { name: string, result: any }) => void} [onToolResult]
44
+ * @property {AbortSignal} [signal]
45
+ */
46
+
47
+ /**
48
+ * @param {{ client: { complete: (request: any) => Promise<any> },
49
+ * toolbox?: { toFunctionTools: () => any[], execute: (name: string, args: any) => any } | null,
50
+ * system?: string, maxToolRounds?: number, maxToolResultChars?: number,
51
+ * historyBudget?: number, ledger?: any,
52
+ * budget?: { turns?: number, tokens?: number, ms?: number,
53
+ * spent?: { turns?: number, tokens?: number, ms?: number } },
54
+ * retrieval?: { memories?: { tags?: string[], where?: any, limit?: number },
55
+ * skills?: { tags?: string[], where?: any, limit?: number } },
56
+ * now?: () => number,
57
+ * environment?: any, transcript?: { slot?: string, window?: number },
58
+ * compaction?: (droppedRounds: any[][], addresses?: any[]) => string }} options
59
+ * - `historyBudget` caps the request history in CHARACTERS (tokens
60
+ * are provider-private; characters are deterministic). When a
61
+ * request would exceed it, the middle of the conversation is
62
+ * replaced by one synopsis message; the system prompt, the first
63
+ * user message and the largest tail that fits always survive, and
64
+ * cuts happen only at tool-round boundaries so `tool_calls`/`tool`
65
+ * pairing stays wire-legal. `compaction` replaces the built-in
66
+ * synopsis writer (it receives the dropped rounds, each an array of
67
+ * wire messages, and — with a ledger — the address of each). The
68
+ * returned transcript is always the FULL, uncompacted history.
69
+ * - `ledger` (anything with `putSlot`/`getSlot`/`readSlot`, normally
70
+ * `createLedger()`) makes compaction RECOVERABLE: each dropped round
71
+ * is archived to a content-addressed slot before the synopsis is
72
+ * written, every synopsis line carries its address, and a `recall`
73
+ * tool is registered so the model can fetch one back. Nothing leaves
74
+ * the request without a copy that can be named. With no ledger the
75
+ * original lossy path runs unchanged. A ledger also supplies the
76
+ * active goal composed into every request, and the memories and
77
+ * skills `retrieval` asks for.
78
+ * - `budget` is a hard stop, not a warning: `turns` (one turn is one
79
+ * MODEL CALL — the unit that costs money and the unit a resumed
80
+ * session keeps counting), `tokens` and `ms` each end the run with a
81
+ * named `stopReason` (`budget-turns`, `budget-tokens`, `budget-ms`)
82
+ * and a message saying what remains. Each is optional; `spent` seeds
83
+ * the counters so a budget survives a reload. The `ms` clock starts at
84
+ * the first model call and is wall-clock from there.
85
+ * - `retrieval` composes ledger memories and skills into the system
86
+ * prompt of every request, with the ledger's own `recall` query shape.
87
+ * Absent, nothing is retrieved — the goal is unconditional, but what
88
+ * else is worth carrying is the host's call.
89
+ * - `now` returns milliseconds (`Date.now` by default), injected so a
90
+ * time budget is testable.
91
+ * - `environment` (from `createEnvironment`) registers the corpus
92
+ * operations as tools — `env_digest`, `env_peek`, `env_grep`,
93
+ * `env_chunk`, `env_stat`, `env_read` — beside the host's, skipping any
94
+ * name the host already registered. Content never enters a request
95
+ * unasked: every one of them answers with metadata and addresses
96
+ * except `env_read`, which makes the model state a character budget.
97
+ * - `transcript` (needs `environment`) makes the CONVERSATION one of
98
+ * those slots: it is written whole before every call and the request
99
+ * keeps `window` round units plus the address of the rest. That is the
100
+ * alternative to `historyBudget` rather than a tuning of it — there is
101
+ * no budget to exceed when the history is addressed instead of resent.
102
+ * Both together is legal and redundant; neither changes the other's
103
+ * behaviour.
104
+ * @returns {{ send: (history: any[], hooks?: AgentHooks) => Promise<{
105
+ * message: any, messages: any[], steps: any[], stopReason: string }>,
106
+ * resume: (history?: any[], hooks?: AgentHooks) => Promise<{
107
+ * message: any, messages: any[], steps: any[], stopReason: string }>,
108
+ * spend: () => { turns: number, tokens: number, ms: number } }}
109
+ */
110
+ export function createAgent(options) {
111
+ const client = options.client;
112
+ const toolbox = options.toolbox ?? null;
113
+ const system = options.system ?? '';
114
+ const maxToolRounds = options.maxToolRounds ?? 5;
115
+ const maxToolResultChars = options.maxToolResultChars ?? 8000;
116
+ const historyBudget = options.historyBudget;
117
+ const ledger = options.ledger ?? null;
118
+ const budget = options.budget ?? null;
119
+ const retrieval = options.retrieval ?? null;
120
+ const clock = options.now ?? (() => Date.now());
121
+ const environment = options.environment ?? null;
122
+ // the transcript-as-slot path is opt-in even with an environment
123
+ // present: an environment is a corpus to work on, and deciding that
124
+ // the CONVERSATION is one of its slots is a separate choice
125
+ const transcript = environment === null || options.transcript === undefined
126
+ ? null
127
+ : {
128
+ slot: options.transcript.slot ?? 'transcript',
129
+ window: Math.max(1, options.transcript.window ?? TRANSCRIPT_WINDOW),
130
+ };
131
+ // kept separately from the effective writer: the ledger path treats a
132
+ // HOST writer differently from the built-in one (see `compactToLedger`)
133
+ const hostCompaction = options.compaction ?? null;
134
+ const compaction = hostCompaction ?? synopsize;
135
+
136
+ if (ledger !== null && typeof ledger.putSlot !== 'function') {
137
+ throw new AiError('AI0001',
138
+ 'ledger: expected an object with putSlot/getSlot/readSlot (createLedger())');
139
+ }
140
+ // one recall tool per agent, and only with a ledger — a toolbox is
141
+ // built here rather than in the loop so its schema compiles once
142
+ const recallBox = ledger === null ? null : recallToolbox(ledger);
143
+ // the environment's operations, as tools, compiled once for the same
144
+ // reason. The model reaches the corpus through exactly the calls a
145
+ // harness makes — one implementation, in `environment.js`.
146
+ const envBox = environment === null ? null : toolboxOf(environmentTools(environment));
147
+
148
+ // what this agent has spent, across every `send` it has served. The
149
+ // counters live on the agent and not on a call, because a budget that
150
+ // reset each turn would bound nothing a long-horizon run cares about.
151
+ const spent = {
152
+ turns: budget?.spent?.turns ?? 0,
153
+ tokens: budget?.spent?.tokens ?? 0,
154
+ ms: budget?.spent?.ms ?? 0,
155
+ };
156
+ /** Wall-clock start of the first model call, or null before it. */
157
+ let startedAt = null;
158
+ const elapsed = () => spent.ms + (startedAt === null ? 0 : clock() - startedAt);
159
+
160
+ /** What this agent has spent so far — the seed for a resumed budget. */
161
+ const spend = () => ({ turns: spent.turns, tokens: spent.tokens, ms: elapsed() });
162
+
163
+ /**
164
+ * The budget dimension that is spent, or null. Checked BEFORE every
165
+ * model call, so an exhausted budget refuses rather than overruns —
166
+ * `maxToolRounds`'s posture, applied to the three quantities a run is
167
+ * actually bounded by.
168
+ * @returns {{ reason: string, content: string } | null}
169
+ */
170
+ function budgetStop() {
171
+ if (budget === null) return null;
172
+ const spentNow = spend();
173
+ for (const dimension of BUDGET_DIMENSIONS) {
174
+ const limit = budget[dimension];
175
+ if (typeof limit === 'number' && spentNow[dimension] >= limit) {
176
+ return {
177
+ reason: `budget-${dimension}`,
178
+ content: budgetMessage(dimension, budget, spentNow),
179
+ };
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * The system prompt for this turn: the immutable base, then the active
187
+ * goal and its progress, then whatever `retrieval` asks for. Read once
188
+ * per `send` rather than once per round — a goal does not change
189
+ * mid-turn, and re-reading it per round would put a storage round-trip
190
+ * in the tool loop for nothing.
191
+ *
192
+ * Returns null when there is nothing to compose, and the caller then
193
+ * sends the history untouched, by the same reference: an agent with no
194
+ * ledger, or a ledger with no goal and no retrieval, must be
195
+ * byte-identical to one built before any of this existed.
196
+ * @param {string} base
197
+ * @returns {Promise<string | null>}
198
+ */
199
+ async function composeSystem(base) {
200
+ if (ledger === null) return null;
201
+ /** @type {string[]} */
202
+ const sections = [];
203
+ if (typeof ledger.getGoal === 'function') {
204
+ const goal = await ledger.getGoal();
205
+ if (goal !== null && goal !== undefined && goal.status === 'active') {
206
+ if (typeof ledger.composeGoal === 'function') {
207
+ const composed = await ledger.composeGoal();
208
+ if (composed.error) throw new AiError('AI0001', composed.error);
209
+ sections.push(composed.text);
210
+ }
211
+ else sections.push(goalPrompt(goal));
212
+ }
213
+ }
214
+ if (retrieval !== null) {
215
+ if (retrieval.memories !== undefined && typeof ledger.recall === 'function') {
216
+ sections.push(...listSection('What you have learned so far',
217
+ await retrieved(ledger.recall(retrieval.memories), 'memories'), memoryLine));
218
+ }
219
+ if (retrieval.skills !== undefined && typeof ledger.recallSkills === 'function') {
220
+ sections.push(...listSection('Skills you can reuse',
221
+ await retrieved(ledger.recallSkills(retrieval.skills), 'skills'), skillLine));
222
+ }
223
+ }
224
+ if (sections.length === 0) return null;
225
+ return [base, ...sections].filter((part) => part !== '').join('\n\n');
226
+ }
227
+
228
+ /**
229
+ * One tool call, to `recall` or to the host's toolbox. `recall` is
230
+ * dispatched here rather than being added to the host's registry
231
+ * because the host's toolbox belongs to the host: an agent may not
232
+ * quietly grow it a tool that outlives the agent.
233
+ * @param {string} name
234
+ * @param {any} args
235
+ * @param {boolean} recalling - whether this agent listed `recall`
236
+ */
237
+ async function dispatch(name, args, listed) {
238
+ if (listed.recalling && name === RECALL_TOOL_NAME) return recallBox.box.execute(name, args);
239
+ if (listed.env.has(name)) return envBox.execute(name, args);
240
+ if (toolbox === null) return { error: 'no tools are available' };
241
+ return toolbox.execute(name, args);
242
+ }
243
+
244
+ /**
245
+ * @param {any[]} history
246
+ * @param {AgentHooks} [hooks]
247
+ */
248
+ async function send(history, hooks = {}) {
249
+ const messages = system !== '' && history[0]?.role !== 'system'
250
+ ? [{ role: 'system', content: system }, ...history]
251
+ : [...history];
252
+ const hostTools = toolbox !== null ? toolbox.toFunctionTools() : [];
253
+ // a host that registered its own `recall` keeps it: two definitions
254
+ // of one function name is not a wire-legal request
255
+ const recalling = recallBox !== null
256
+ && !hostTools.some((tool) => tool?.function?.name === RECALL_TOOL_NAME);
257
+ const named = new Set(hostTools.map((tool) => tool?.function?.name));
258
+ // same rule as `recall`: a host that registered its own tool of that
259
+ // name keeps it, because two definitions of one function name is not
260
+ // a wire-legal request. What this agent LISTED is what it dispatches.
261
+ const envTools = envBox === null
262
+ ? []
263
+ : envBox.toFunctionTools().filter((tool) => !named.has(tool?.function?.name));
264
+ const listed = {
265
+ recalling,
266
+ env: new Set(envTools.map((tool) => tool.function.name)),
267
+ };
268
+ const tools = [
269
+ ...hostTools,
270
+ ...(recalling ? recallBox.box.toFunctionTools() : []),
271
+ ...envTools,
272
+ ];
273
+ /** @type {any[]} */
274
+ const steps = [];
275
+ // the goal and the retrieved state, composed onto whatever base
276
+ // prompt this conversation carries — into the REQUEST, never into
277
+ // `messages`, so the transcript the host persists never accumulates
278
+ // a goal it would compose a second time next turn
279
+ const composed = await composeSystem(
280
+ messages[0]?.role === 'system' ? messages[0].content : system);
281
+
282
+ for (let round = 0; ; round++) {
283
+ const stop = budgetStop();
284
+ if (stop !== null) {
285
+ const message = { role: 'assistant', content: stop.content };
286
+ messages.push(message);
287
+ return { message, messages, steps, stopReason: stop.reason };
288
+ }
289
+
290
+ const request = composeRequest(messages, composed);
291
+ // the transcript as a slot: the conversation is written to the
292
+ // environment and the request keeps a window of it plus the
293
+ // address of the whole. This runs BEFORE any character budget,
294
+ // because a request that already carries two rounds has nothing
295
+ // left for compaction to cut.
296
+ const windowed = transcript === null
297
+ ? request
298
+ : await windowThroughEnvironment(request, transcript, environment);
299
+ const sent = historyBudget === undefined
300
+ ? windowed
301
+ : ledger === null
302
+ ? compactMessages(windowed, historyBudget, compaction)
303
+ : await compactToLedger(windowed, historyBudget, hostCompaction,
304
+ ledger, recallBox.options);
305
+ if (startedAt === null) startedAt = clock();
306
+ const completion = await client.complete({
307
+ messages: sent,
308
+ tools: tools.length > 0 ? tools : undefined,
309
+ onDelta: hooks.onDelta,
310
+ onReasoning: hooks.onReasoning,
311
+ signal: hooks.signal,
312
+ });
313
+ spent.turns += 1;
314
+ // reported usage is always counted (it is free and it is the truth);
315
+ // the character estimate is only paid for when a token budget
316
+ // depends on it
317
+ spent.tokens += tokensOf(sent, completion, budget?.tokens !== undefined);
318
+ const reply = completion.message;
319
+
320
+ if (reply.toolCalls === null || reply.toolCalls === undefined
321
+ || reply.toolCalls.length === 0) {
322
+ // reasoning rides on the RETURNED message only — the wire
323
+ // transcript stays clean for the next request
324
+ const transcript = { role: 'assistant', content: reply.content };
325
+ messages.push(transcript);
326
+ const message = reply.reasoning === undefined
327
+ ? transcript
328
+ : { ...transcript, reasoning: reply.reasoning };
329
+ return { message, messages, steps, stopReason: completion.finishReason ?? 'stop' };
330
+ }
331
+
332
+ if (round >= maxToolRounds) {
333
+ const message = {
334
+ role: 'assistant',
335
+ content: reply.content !== ''
336
+ ? reply.content
337
+ : `Stopped after ${maxToolRounds} tool rounds without a final answer — the tool work so far has been applied; send another message to continue.`,
338
+ };
339
+ messages.push(message);
340
+ return { message, messages, steps, stopReason: 'tool-limit' };
341
+ }
342
+
343
+ messages.push({
344
+ role: 'assistant',
345
+ content: reply.content,
346
+ tool_calls: reply.toolCalls.map((call) => ({
347
+ id: call.id,
348
+ type: 'function',
349
+ function: { name: call.name, arguments: call.arguments },
350
+ })),
351
+ });
352
+
353
+ for (const call of reply.toolCalls) {
354
+ hooks.onToolCall?.({ name: call.name, arguments: call.arguments });
355
+ const parsed = parseArguments(call.arguments);
356
+ const result = parsed.error !== undefined
357
+ ? parsed
358
+ : await dispatch(call.name, parsed.value, listed);
359
+ hooks.onToolResult?.({ name: call.name, result });
360
+ steps.push({ name: call.name, arguments: call.arguments, result });
361
+ messages.push({
362
+ role: 'tool',
363
+ tool_call_id: call.id,
364
+ name: call.name,
365
+ content: truncate(JSON.stringify(result ?? null), maxToolResultChars),
366
+ });
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Continue the active objective without a new instruction from the
373
+ * user — the closed-tab case, and the primitive a host schedules on
374
+ * top of. It is a normal `send` whose only user turn says "carry on":
375
+ * everything that makes carrying on possible (the objective, what has
376
+ * been tried, what was learned) is already composed into the prompt,
377
+ * so a resumed agent is not told where it got to, it reads it.
378
+ *
379
+ * Scheduling — a timer, a service worker, a cron — is deliberately not
380
+ * here; see the README. This package injects its environment rather
381
+ * than owning it, and a heartbeat is environment.
382
+ * @param {any[]} [history] - the persisted transcript, if there is one
383
+ * @param {AgentHooks} [hooks]
384
+ */
385
+ async function resume(history = [], hooks = {}) {
386
+ const goal = ledger !== null && typeof ledger.getGoal === 'function'
387
+ ? await ledger.getGoal()
388
+ : null;
389
+ if (goal === null || goal === undefined || goal.status !== 'active') {
390
+ throw new AiError('AI0001',
391
+ 'resume() needs an active goal — call ledger.setGoal() first');
392
+ }
393
+ return send([...history, { role: 'user', content: RESUME_TURN }], hooks);
394
+ }
395
+
396
+ return { send, resume, spend };
397
+ }
398
+
399
+ /** The user turn `resume` sends. */
400
+ const RESUME_TURN = 'Continue working on the objective in your instructions. The progress'
401
+ + ' recorded there is what has already been done — do not repeat it. Take the next step,'
402
+ + ' and say what you did.';
403
+
404
+ /**
405
+ * @param {string} text - the model's argument string
406
+ * @returns {{ value?: any, error?: string }}
407
+ */
408
+ function parseArguments(text) {
409
+ if (text === '' || text === undefined || text === null) return { value: {} };
410
+ try {
411
+ return { value: JSON.parse(text) };
412
+ }
413
+ catch (err) {
414
+ return { error: `tool arguments are not valid JSON: ${/** @type {Error} */ (err).message}` };
415
+ }
416
+ }
417
+
418
+ //#region the objective in the prompt
419
+
420
+ /** How much of one progress note, memory or skill a prompt line shows. */
421
+ const PROMPT_LINE_CHARS = 300;
422
+
423
+ /**
424
+ * The request's messages, with the composed system prompt in front of
425
+ * them. The transcript is not touched: composition belongs to a request
426
+ * the way compaction does, and for the same reason — what the host
427
+ * persists has to be the conversation, not this turn's rendering of the
428
+ * ledger.
429
+ *
430
+ * With nothing composed the SAME array comes back, by reference, so an
431
+ * agent without a ledger sends exactly the object it always sent.
432
+ * @param {any[]} messages
433
+ * @param {string | null} composed
434
+ * @returns {any[]}
435
+ */
436
+ function composeRequest(messages, composed) {
437
+ if (composed === null) return messages;
438
+ if (messages[0]?.role === 'system') {
439
+ return [{ ...messages[0], content: composed }, ...messages.slice(1)];
440
+ }
441
+ return [{ role: 'system', content: composed }, ...messages];
442
+ }
443
+
444
+ /** One retrieved memory, as a prompt line. */
445
+ const memoryLine = (memory) => `- ${excerpt(memory.text, PROMPT_LINE_CHARS)}`
446
+ + `${(memory.tags ?? []).length > 0 ? ` [${memory.tags.join(', ')}]` : ''}`
447
+ + ` (evidence: ${excerpt(typeof memory.evidence === 'string' ? memory.evidence : JSON.stringify(memory.evidence), PROMPT_LINE_CHARS)})`;
448
+
449
+ /** One retrieved skill, as a prompt line. */
450
+ const skillLine = (skill) => `- ${skill.name} — when ${excerpt(skill.when, PROMPT_LINE_CHARS)}:`
451
+ + ` ${excerpt(skill.instructions, PROMPT_LINE_CHARS)}`
452
+ + `${(skill.tools ?? []).length > 0 ? ` (tools: ${skill.tools.join(', ')})` : ''}`;
453
+
454
+ /**
455
+ * A retrieved section, or nothing at all when the retrieval came back
456
+ * empty — an empty heading in a prompt is a fact about the harness, not
457
+ * about the work.
458
+ * @param {string} title
459
+ * @param {any[]} records
460
+ * @param {(record: any) => string} line
461
+ * @returns {string[]}
462
+ */
463
+ function listSection(title, records, line) {
464
+ if (records.length === 0) return [];
465
+ return [[`## ${title}`, ...records.map(line)].join('\n')];
466
+ }
467
+
468
+ /**
469
+ * A retrieval's records, or a loud failure. The ledger answers a
470
+ * `where` predicate it cannot evaluate with `{ error }` rather than
471
+ * ignoring it (a filter silently dropped answers the wrong question),
472
+ * and a prompt built from the wrong question is worse than a run that
473
+ * refuses to start.
474
+ * @param {Promise<any>} pending
475
+ * @param {string} what
476
+ * @returns {Promise<any[]>}
477
+ */
478
+ async function retrieved(pending, what) {
479
+ const records = await pending;
480
+ if (Array.isArray(records)) return records;
481
+ // a ranked recall (`{ near }`) answers `{ memories | skills, scores,
482
+ // skipped }`; the records go into the prompt exactly as an array would
483
+ if (Array.isArray(records?.[what])) return records[what];
484
+ throw new AiError('AI0001',
485
+ `retrieval of ${what} failed: ${records?.error ?? 'the ledger returned no records'}`);
486
+ }
487
+
488
+ //#endregion
489
+
490
+ //#region the transcript as a slot
491
+
492
+ /** Round units kept in the request when the transcript is a slot. */
493
+ const TRANSCRIPT_WINDOW = 2;
494
+
495
+ /**
496
+ * A toolbox holding one set of definitions, compiled once.
497
+ * @param {any[]} definitions
498
+ */
499
+ function toolboxOf(definitions) {
500
+ const box = createToolbox();
501
+ for (const definition of definitions) box.add(definition);
502
+ return box;
503
+ }
504
+
505
+ /**
506
+ * The conversation as text a `grep` can answer from: one header line per
507
+ * message, then its content. Line-oriented on purpose — `grep` reports
508
+ * the line that matched, so a tool result written as one JSON line comes
509
+ * back as one legible hit with its address beside it.
510
+ * @param {any[]} messages
511
+ * @returns {string}
512
+ */
513
+ export function transcriptText(messages) {
514
+ return messages.map((message, index) => {
515
+ const head = `[${index}] ${message.role}`;
516
+ if (Array.isArray(message.tool_calls)) {
517
+ return message.tool_calls
518
+ .map((call) => `${head} → ${call.function?.name}(${call.function?.arguments})`)
519
+ .join('\n');
520
+ }
521
+ if (message.role === 'tool') return `${head} ${message.name}: ${message.content}`;
522
+ return `${head}: ${message.content}`;
523
+ }).join('\n');
524
+ }
525
+
526
+ /**
527
+ * Write the whole conversation to its slot and keep a window of it in
528
+ * the request, with the address of the rest.
529
+ *
530
+ * This is the alternative to a history budget rather than a tuning of
531
+ * one. A budget answers "what do I cut to fit?"; this answers "why is
532
+ * the conversation in the request at all?" — it is stored, it is
533
+ * addressable, and the model reaches the part it needs with the same
534
+ * `env_grep`/`env_read` it uses on any other corpus. The request stops
535
+ * growing with the conversation: pins, one pointer, N rounds.
536
+ *
537
+ * The window is counted in ROUND UNITS, so an assistant message and the
538
+ * tool replies it belongs to are never split — the same wire-legality
539
+ * rule compaction obeys.
540
+ * @param {any[]} messages
541
+ * @param {{ slot: string, window: number }} transcript
542
+ * @param {any} environment
543
+ * @returns {Promise<any[]>}
544
+ */
545
+ async function windowThroughEnvironment(messages, transcript, environment) {
546
+ const written = await environment.put(transcript.slot, transcriptText(messages),
547
+ { kind: 'transcript', count: messages.length });
548
+
549
+ /** @type {any[]} */
550
+ const pinnedSystem = [];
551
+ let rest = messages;
552
+ if (messages[0]?.role === 'system') {
553
+ pinnedSystem.push(messages[0]);
554
+ rest = messages.slice(1);
555
+ }
556
+ const units = roundUnits(rest);
557
+ const firstUser = units.findIndex((unit) => unit[0].role === 'user');
558
+ const cut = Math.max(firstUser + 1, units.length - transcript.window);
559
+ if (cut <= firstUser + 1) return messages;
560
+
561
+ const pointer = {
562
+ role: 'assistant',
563
+ content: `[The whole conversation is in the environment as slot "${transcript.slot}"`
564
+ + ` (${messages.length} messages, ${written?.size ?? 0} characters). Nothing was dropped:`
565
+ + ` env_grep finds the round you need and env_read returns it. This request carries the`
566
+ + ` last ${units.length - cut} round(s).]`,
567
+ };
568
+ return [
569
+ ...pinnedSystem,
570
+ ...(firstUser >= 0 ? units[firstUser] : []),
571
+ pointer,
572
+ ...units.slice(cut).flat(),
573
+ ];
574
+ }
575
+
576
+ //#endregion
577
+
578
+ //#region budgets
579
+
580
+ /** The dimensions a run is bounded by, in the order they are checked. */
581
+ const BUDGET_DIMENSIONS = /** @type {const} */ (['turns', 'tokens', 'ms']);
582
+
583
+ /**
584
+ * Characters per token when a provider reports no usage. Crude and
585
+ * deliberately stated rather than hidden: a budget that silently did not
586
+ * apply because the provider was quiet would be worse than no budget.
587
+ * Four is the usual English-text ratio and it is the same order of
588
+ * magnitude on JSON, which is what a tool transcript mostly is.
589
+ */
590
+ const TOKEN_CHARS = 4;
591
+
592
+ /**
593
+ * What one exchange cost. The provider's own `usage` wins whenever it
594
+ * reported any (`client.js` normalizes it onto the completion); the
595
+ * character estimate is the fallback, and it counts the request that was
596
+ * actually sent plus the reply that came back.
597
+ * @param {any[]} sent - the messages this call carried
598
+ * @param {any} completion
599
+ * @param {boolean} estimate - whether the fallback is worth computing
600
+ * @returns {number}
601
+ */
602
+ function tokensOf(sent, completion, estimate) {
603
+ const usage = completion?.usage;
604
+ const reported = typeof usage?.total_tokens === 'number' && usage.total_tokens > 0
605
+ ? usage.total_tokens
606
+ : (usage?.prompt_tokens ?? 0) + (usage?.completion_tokens ?? 0);
607
+ if (reported > 0) return reported;
608
+ if (!estimate) return 0;
609
+ let chars = String(completion?.message?.content ?? '').length;
610
+ for (const message of sent) chars += sizeOf(message);
611
+ return Math.ceil(chars / TOKEN_CHARS);
612
+ }
613
+
614
+ /**
615
+ * The message a spent budget stops with. It names the dimension that
616
+ * ran out and what is left of the others, because "stopped" without a
617
+ * quantity is indistinguishable from a crash to whoever reads the
618
+ * transcript next — including the model, on a resumed turn.
619
+ * @param {string} dimension
620
+ * @param {any} budget
621
+ * @param {{ turns: number, tokens: number, ms: number }} spent
622
+ * @returns {string}
623
+ */
624
+ function budgetMessage(dimension, budget, spent) {
625
+ const remaining = BUDGET_DIMENSIONS.map((name) => (typeof budget[name] === 'number'
626
+ ? `${name}: ${Math.max(0, budget[name] - spent[name])} of ${budget[name]} left`
627
+ : `${name}: no limit`));
628
+ return `Stopped: the ${dimension} budget is spent (${spent[dimension]} of ${budget[dimension]}).`
629
+ + ` Remaining — ${remaining.join(', ')}. The work so far has been applied and recorded;`
630
+ + ' raise the budget or start a new run to continue.';
631
+ }
632
+
633
+ //#endregion
634
+
635
+ //#region history compaction
636
+
637
+ /** The characters one round unit costs a request. `sizeOf` is the
638
+ * suite's one size rule (`@jarenjs/core/chunk`); a second local copy of
639
+ * "how big is this" is how two budgets in one repository come to
640
+ * disagree about the same message. */
641
+ function unitSize(unit) {
642
+ let total = 0;
643
+ for (const message of unit) total += sizeOf(message);
644
+ return total;
645
+ }
646
+
647
+ /**
648
+ * Group wire messages into indivisible round units: an assistant
649
+ * message carrying `tool_calls` travels with its `tool` replies (the
650
+ * pairing OpenAI requires); everything else is a unit of one. The
651
+ * leading system message is NOT a unit — the caller pins it.
652
+ * @param {any[]} messages
653
+ * @returns {any[][]}
654
+ */
655
+ function roundUnits(messages) {
656
+ /** @type {any[][]} */
657
+ const units = [];
658
+ for (let i = 0; i < messages.length; ) {
659
+ const message = messages[i];
660
+ if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
661
+ const unit = [message];
662
+ i++;
663
+ while (i < messages.length && messages[i].role === 'tool') {
664
+ unit.push(messages[i]);
665
+ i++;
666
+ }
667
+ units.push(unit);
668
+ }
669
+ else {
670
+ units.push([message]);
671
+ i++;
672
+ }
673
+ }
674
+ return units;
675
+ }
676
+
677
+ /**
678
+ * The lines one dropped round contributes to a synopsis: one per tool
679
+ * call (name, argument excerpt, result excerpt), or one for a plain
680
+ * turn. Both writers share it, so the addressed synopsis says exactly
681
+ * what the plain one says plus the address.
682
+ * @param {any[]} unit
683
+ * @returns {string[]}
684
+ */
685
+ function unitLines(unit) {
686
+ const head = unit[0];
687
+ if (head.role === 'assistant' && Array.isArray(head.tool_calls)) {
688
+ return head.tool_calls.map((call) => {
689
+ const reply = unit.find((m) => m.role === 'tool' && m.tool_call_id === call.id);
690
+ return `- called ${call.function?.name}(${excerpt(call.function?.arguments, 60)}) → ${excerpt(reply?.content, 60)}`;
691
+ });
692
+ }
693
+ return [`- ${head.role}: ${excerpt(head.content, 60)}`];
694
+ }
695
+
696
+ /**
697
+ * The built-in deterministic synopsis of dropped rounds: one line per
698
+ * tool call (name, argument excerpt, result excerpt) or plain turn. No
699
+ * second model call — a single-model local host runs unassisted.
700
+ *
701
+ * With no ledger this is the whole story and the excerpt is the only
702
+ * copy — which is what `ledger` exists to fix; see `synopsizeAddressed`.
703
+ * @param {any[][]} dropped
704
+ * @returns {string}
705
+ */
706
+ function synopsize(dropped) {
707
+ const lines = ['[Earlier context was compacted to fit the history budget. What happened:]'];
708
+ for (const unit of dropped) lines.push(...unitLines(unit));
709
+ return lines.join('\n');
710
+ }
711
+
712
+ /** The allowance the ledger-free path holds back for the synopsis. */
713
+ const SYNOPSIS_RESERVE = 600;
714
+
715
+ /** The smallest synopsis the excess truncation will leave behind. */
716
+ const SYNOPSIS_FLOOR = 180;
717
+
718
+ /**
719
+ * The largest share of the budget an addressed synopsis may claim: one
720
+ * part in `SYNOPSIS_SHARE`, or `SYNOPSIS_RESERVE`, whichever is bigger.
721
+ *
722
+ * Without a cap the derived allowance is self-defeating at exactly the
723
+ * budgets this order exists to improve. Forty archived rounds address
724
+ * out to some 5 000 characters; at a 6 000-character budget the synopsis
725
+ * would take the whole request and the recent tail — the context the
726
+ * model is actually working in — would be squeezed to one round.
727
+ * Measured at budget 6 000 on the benchmark's realistic payload shape,
728
+ * uncapped kept 1 of 40 record values verbatim in a request of 5
729
+ * messages; capped keeps 6, in a request of 15. Both recover 40 of 40,
730
+ * because a
731
+ * synopsis truncated past its per-round addresses still carries the
732
+ * index address in its header. Paying a second `recall` hop for the
733
+ * oldest rounds is the cheaper half of that trade.
734
+ */
735
+ const SYNOPSIS_SHARE = 4;
736
+
737
+ /**
738
+ * How many times the tail selection is re-run to settle its own
739
+ * allowance. The allowance depends on how many rounds are dropped and
740
+ * how many are dropped depends on the allowance; each pass can only
741
+ * raise both, so the sequence climbs and settles — usually on the second
742
+ * pass. A run that had not settled by the last one simply keeps the
743
+ * largest allowance it reached, which is the safe end of the wobble.
744
+ */
745
+ const RESERVE_PASSES = 4;
746
+
747
+ /**
748
+ * Choose what survives: pin the system prompt and the first user
749
+ * message, keep the largest round-aligned tail that fits, and report
750
+ * the rounds in between as dropped. Returns null when nothing needs to
751
+ * go — under budget, or with no droppable middle.
752
+ *
753
+ * `reserveFor` is the seam between the two callers: a flat number for
754
+ * the ledger-free path (unchanged since it was written), and for the
755
+ * ledger path the addressed synopsis's own size — which it can compute
756
+ * exactly, because it is about to write it — under a cap.
757
+ * @param {any[]} messages
758
+ * @param {number} budget
759
+ * @param {(dropped: any[][]) => number} reserveFor
760
+ * @returns {{ pins: any[], dropped: any[][], tail: any[], kept: number } | null}
761
+ */
762
+ function planCompaction(messages, budget, reserveFor) {
763
+ let total = 0;
764
+ for (const message of messages) total += sizeOf(message);
765
+ if (total <= budget) return null;
766
+
767
+ /** @type {any[]} */
768
+ const pinnedSystem = [];
769
+ let rest = messages;
770
+ if (messages[0]?.role === 'system') {
771
+ pinnedSystem.push(messages[0]);
772
+ rest = messages.slice(1);
773
+ }
774
+ const units = roundUnits(rest);
775
+ const firstUser = units.findIndex((unit) => unit[0].role === 'user');
776
+ const pinned = pinnedSystem.reduce((n, m) => n + sizeOf(m), 0)
777
+ + (firstUser >= 0 ? unitSize(units[firstUser]) : 0);
778
+
779
+ // budget the pins plus room for the synopsis itself, then take tail
780
+ // units (newest first) while they fit — always at least the last one
781
+ const select = (reserve) => {
782
+ let used = pinned + reserve;
783
+ let cut = units.length;
784
+ for (let k = units.length - 1; k > firstUser; k--) {
785
+ const size = unitSize(units[k]);
786
+ if (cut < units.length && used + size > budget) break;
787
+ used += size;
788
+ cut = k;
789
+ }
790
+ return cut;
791
+ };
792
+
793
+ let cut = select(reserveFor([]));
794
+ for (let pass = 0; pass < RESERVE_PASSES; pass++) {
795
+ const next = select(reserveFor(units.slice(firstUser + 1, cut)));
796
+ if (next === cut) break;
797
+ cut = next;
798
+ }
799
+
800
+ const dropped = units.slice(firstUser + 1, cut);
801
+ if (dropped.length === 0) return null;
802
+ const tail = units.slice(cut).flat();
803
+ return {
804
+ pins: [...pinnedSystem, ...(firstUser >= 0 ? units[firstUser] : [])],
805
+ dropped,
806
+ tail,
807
+ kept: pinned + tail.reduce((n, m) => n + sizeOf(m), 0),
808
+ };
809
+ }
810
+
811
+ /**
812
+ * The compacted request: the pins, one synopsis message, the tail. The
813
+ * synopsis takes exactly the room the plan reserved — whatever the
814
+ * writer produced is truncated to the remaining allowance, so the budget
815
+ * holds deterministically however long a writer runs on.
816
+ * @param {{ pins: any[], tail: any[], kept: number }} plan
817
+ * @param {string} content
818
+ * @param {number} budget
819
+ * @param {number} floor - the smallest synopsis worth keeping; the
820
+ * ledger path raises it so its header, which carries the index
821
+ * address, can never be the part that gets cut
822
+ * @returns {any[]}
823
+ */
824
+ function assemble(plan, content, budget, floor) {
825
+ const synopsis = { role: 'assistant', content };
826
+ const excess = plan.kept + sizeOf(synopsis) - budget;
827
+ if (excess > 0) {
828
+ synopsis.content = truncate(synopsis.content,
829
+ Math.max(floor, synopsis.content.length - excess));
830
+ }
831
+ return [...plan.pins, synopsis, ...plan.tail];
832
+ }
833
+
834
+ /**
835
+ * Fit the history into `budget` characters, destructively: the dropped
836
+ * middle becomes one synopsis whose 60-character excerpts are the only
837
+ * surviving trace of what those rounds found. Under budget the array
838
+ * passes through untouched (same reference).
839
+ * @param {any[]} messages
840
+ * @param {number} budget
841
+ * @param {(droppedRounds: any[][]) => string} compaction
842
+ * @returns {any[]}
843
+ */
844
+ function compactMessages(messages, budget, compaction) {
845
+ const plan = planCompaction(messages, budget, () => SYNOPSIS_RESERVE);
846
+ if (plan === null) return messages;
847
+ return assemble(plan, compaction(plan.dropped), budget, SYNOPSIS_FLOOR);
848
+ }
849
+
850
+ //#endregion
851
+
852
+ //#region compaction that moves
853
+
854
+ /**
855
+ * What one dropped round is worth archiving as: its exact bytes, and the
856
+ * content-addressed name they hash to. Pure — nothing is written here,
857
+ * because the tail selection needs the exact synopsis size before it can
858
+ * decide what to drop, and the synopsis needs the addresses.
859
+ * @param {any[][]} dropped
860
+ * @returns {{ entries: any[], index: any }}
861
+ */
862
+ function planArchive(dropped) {
863
+ const entries = dropped.map((unit) => {
864
+ const text = JSON.stringify(unit);
865
+ return { name: roundSlotName(text), size: text.length, text, kind: 'agent-round' };
866
+ });
867
+ // the listing writes addresses in the SAME `recall("…")` form the
868
+ // synopsis lines use: one address syntax, so following the index needs
869
+ // no second reading rule — from a model or from anything auditing what
870
+ // a request can still reach
871
+ const lines = [`${entries.length} archived round(s) — recall(name) returns one in full.`];
872
+ entries.forEach((entry, i) => lines.push(
873
+ `${slotAddress(entry.name, entry.size)} ${unitLines(dropped[i])[0]?.slice(2) ?? ''}`));
874
+ const text = lines.join('\n');
875
+ return {
876
+ entries,
877
+ index: { name: indexSlotName(text), size: text.length, text, kind: 'agent-round-index' },
878
+ };
879
+ }
880
+
881
+ /**
882
+ * The addressed synopsis. Every line carries the address of the round it
883
+ * previews, and the header carries the address of the index that lists
884
+ * them all — which is the part that survives truncation, so a request
885
+ * whose synopsis was cut short still names a way back to every round it
886
+ * cut.
887
+ * @param {any[][]} dropped
888
+ * @param {{ entries: any[], index: any }} archive
889
+ * @param {((droppedRounds: any[][], addresses: any[]) => string) | null} hostWriter
890
+ * @returns {string}
891
+ */
892
+ function synopsizeAddressed(dropped, archive, hostWriter) {
893
+ const header = `[Earlier context was compacted. ${dropped.length} round(s) are ARCHIVED, `
894
+ + `not lost: ${slotRef(archive.index.name)} lists every address; recall(name) returns`
895
+ + ' one in full. What happened:]';
896
+ if (hostWriter !== null) {
897
+ return `${header}\n${hostWriter(dropped, archive.entries.map(
898
+ ({ name, size, kind }) => ({ name, size, kind })))}`;
899
+ }
900
+ const lines = [header];
901
+ dropped.forEach((unit, i) => {
902
+ const own = unitLines(unit);
903
+ const address = slotAddress(archive.entries[i].name, archive.entries[i].size);
904
+ // a round the plain writer has nothing to say about still gets its
905
+ // address — the whole promise is that no round leaves unaddressed,
906
+ // and `unitLines` says nothing about an assistant turn that carries
907
+ // an EMPTY `tool_calls` array (which the loop never writes, but a
908
+ // host's persisted history can)
909
+ if (own.length === 0) {
910
+ lines.push(`- ${address}`);
911
+ }
912
+ else {
913
+ own[own.length - 1] += ` ${address}`;
914
+ lines.push(...own);
915
+ }
916
+ });
917
+ return lines.join('\n');
918
+ }
919
+
920
+ /**
921
+ * Write the archive. The fingerprint and length propose an address;
922
+ * exact content comparison proves it. A collision refuses compaction
923
+ * before any transcript can be dropped, while identical bytes reuse storage.
924
+ *
925
+ * A store that refuses a write is fatal and says so: the whole point of
926
+ * this path is that nothing leaves the request without a copy, and
927
+ * carrying on would drop the round anyway while claiming an address for
928
+ * it that answers nothing.
929
+ * @param {any} ledger
930
+ * @param {{ entries: any[], index: any }} archive
931
+ */
932
+ async function writeArchive(ledger, archive, referenceText, protectedNames) {
933
+ if (typeof ledger.putArchive === 'function') {
934
+ const written = await ledger.putArchive([...archive.entries, archive.index], { referenceText, protectedNames, immutable: true });
935
+ if (written?.error) throw new AiError('AI0001', `compaction could not archive rounds: ${written.error}`);
936
+ return;
937
+ }
938
+ const pending = new Map();
939
+ for (const entry of [...archive.entries, archive.index]) {
940
+ const planned = pending.get(entry.name);
941
+ if (planned !== undefined && planned.text !== entry.text)
942
+ throw new AiError('AI0001', `compaction archive address collision at '${entry.name}'`);
943
+ const existing = await ledger.getSlot(entry.name);
944
+ if (existing !== null && existing !== undefined) {
945
+ if (await ledger.readSlot(entry.name) !== entry.text)
946
+ throw new AiError('AI0001', `compaction archive address collision at '${entry.name}'`);
947
+ continue;
948
+ }
949
+ pending.set(entry.name, entry);
950
+ }
951
+ for (const entry of pending.values()) {
952
+ const written = await ledger.putSlot(entry.name, entry.text, { kind: entry.kind });
953
+ if (written?.error !== undefined) {
954
+ throw new AiError('AI0001',
955
+ `compaction could not archive a round to slot '${entry.name}': ${written.error}`);
956
+ }
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Fit the history into `budget` characters WITHOUT destroying anything:
962
+ * every dropped round is archived to an addressable slot first, and the
963
+ * synopsis that replaces it carries the address. The excerpt stops being
964
+ * a summary and becomes a preview of something a `recall` can fetch.
965
+ *
966
+ * The allowance is derived from the rounds being dropped rather than
967
+ * flat, because the addresses make the synopsis grow with their number
968
+ * and a fixed 600 would stop holding. With the built-in writer the
969
+ * allowance is the synopsis's EXACT size — pure string work over a known
970
+ * set of rounds, so the budget is spent to the character instead of
971
+ * guessed at. A host writer is only ESTIMATED, by what the built-in
972
+ * writer would have produced, and is then called once at the end: a hook
973
+ * that talked to a model must not be run several times per request by
974
+ * the settling loop, and whatever it returns is truncated to the
975
+ * allowance anyway.
976
+ * @param {any[]} messages
977
+ * @param {number} budget
978
+ * @param {((droppedRounds: any[][], addresses: any[]) => string) | null} hostWriter
979
+ * @param {any} ledger
980
+ * @param {{ index?: string }} recallOptions - updated with the current
981
+ * index address, so an unknown-slot rejection can point at the listing
982
+ * @returns {Promise<any[]>}
983
+ */
984
+ async function compactToLedger(messages, budget, hostWriter, ledger, recallOptions) {
985
+ const ceiling = Math.max(SYNOPSIS_RESERVE, Math.floor(budget / SYNOPSIS_SHARE));
986
+ const reserveFor = (dropped) => Math.min(ceiling, sizeOf({
987
+ role: 'assistant',
988
+ content: synopsizeAddressed(dropped, planArchive(dropped),
989
+ hostWriter === null ? null : () => synopsize(dropped)),
990
+ }));
991
+ const plan = planCompaction(messages, budget, reserveFor);
992
+ if (plan === null) return messages;
993
+
994
+ const archive = planArchive(plan.dropped);
995
+ const retained = [...plan.pins, ...plan.tail];
996
+ await writeArchive(ledger, archive, JSON.stringify(retained),
997
+ slotAddressesIn(retained.map((message) => message.content ?? '').join('\n')));
998
+ recallOptions.index = archive.index.name;
999
+
1000
+ const content = synopsizeAddressed(plan.dropped, archive, hostWriter);
1001
+ return assemble(plan, content, budget,
1002
+ Math.max(SYNOPSIS_FLOOR, content.indexOf('\n') + 1 || content.length));
1003
+ }
1004
+
1005
+ /**
1006
+ * A private toolbox holding just `recall`: the tool's schema compiles
1007
+ * once per agent, and a call to it answers `{ error }` under exactly the
1008
+ * rules every host tool answers under, because it goes through the same
1009
+ * registry. `options` is read at call time, so each compaction can point
1010
+ * an unknown-address rejection at the index it just wrote.
1011
+ * @param {any} ledger
1012
+ */
1013
+ function recallToolbox(ledger) {
1014
+ /** @type {{ index?: string }} */
1015
+ const options = {};
1016
+ const box = createToolbox();
1017
+ box.add(createRecallTool(ledger, options));
1018
+ return { box, options };
1019
+ }
1020
+
1021
+ //#endregion