@tangleai/agents 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +854 -0
- package/package.json +85 -0
- package/src/agent.d.ts +160 -0
- package/src/agent.js +1021 -0
- package/src/index.d.ts +11 -0
- package/src/index.js +13 -0
- package/src/program-result.d.ts +111 -0
- package/src/program-result.js +48 -0
- package/src/program-session.d.ts +48 -0
- package/src/program-session.js +121 -0
- package/src/program-shape.d.ts +21 -0
- package/src/program-shape.js +53 -0
- package/src/program.d.ts +244 -0
- package/src/program.js +940 -0
- package/src/recursive.d.ts +148 -0
- package/src/recursive.js +384 -0
- package/src/refine.d.ts +58 -0
- package/src/refine.js +599 -0
- package/src/schemas/program.d.ts +82 -0
- package/src/schemas/program.js +205 -0
- package/src/toolbox.d.ts +55 -0
- package/src/toolbox.js +178 -0
package/src/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
|