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