@tangleai/agents 0.21.1 → 0.25.0
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 +45 -0
- package/README.md +7 -6
- package/package.json +6 -6
- package/src/agent.d.ts +31 -33
- package/src/agent.js +522 -669
- package/src/index.d.ts +12 -11
- package/src/index.js +7 -12
- package/src/program-result.d.ts +7 -29
- package/src/program-result.js +16 -40
- package/src/program-session.d.ts +7 -13
- package/src/program-session.js +130 -108
- package/src/program-shape.d.ts +17 -17
- package/src/program-shape.js +47 -40
- package/src/program.d.ts +140 -108
- package/src/program.js +637 -712
- package/src/recursive.d.ts +65 -37
- package/src/recursive.js +223 -263
- package/src/refine.d.ts +49 -15
- package/src/refine.js +396 -445
- package/src/schemas/program.d.ts +25 -25
- package/src/schemas/program.js +88 -104
- package/src/toolbox.d.ts +27 -26
- package/src/toolbox.js +90 -124
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
}
|
|
149
|
+
return null;
|
|
213
150
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
406
|
-
* @returns {{ value?: any, error?: string }}
|
|
355
|
+
* @param text - the model's argument string
|
|
407
356
|
*/
|
|
408
357
|
function parseArguments(text) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
598
|
-
* @param
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|