@cubicecho/agent-core 2.18.1 → 2.19.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/README.md +177 -3
- package/dist/agent-loop.d.ts +171 -11
- package/dist/agent-loop.js +77 -19
- package/dist/compaction.d.ts +27 -1
- package/dist/compaction.js +45 -12
- package/dist/events.d.ts +17 -4
- package/dist/hooks.d.ts +8 -2
- package/dist/hooks.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/ledger.d.ts +132 -0
- package/dist/ledger.js +166 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.js +6 -2
- package/dist/stream.d.ts +11 -0
- package/llms.txt +17 -1
- package/package.json +1 -1
package/dist/agent-loop.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
|
-
import { capabilitiesFor, effortFor, } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
4
|
import { continueTurn } from "./continuation.js";
|
|
5
5
|
import { errorMessage } from "./errors.js";
|
|
6
6
|
import { runMetrics } from "./events.js";
|
|
7
|
-
import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
8
|
-
import {
|
|
7
|
+
import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
8
|
+
import { rebaseLedger, recordRequest } from "./ledger.js";
|
|
9
|
+
import { contextTokens, toolsChars } from "./retry.js";
|
|
9
10
|
import { runTurn } from "./run-turn.js";
|
|
10
11
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
11
12
|
import { askJson, tryAsk } from "./side-task.js";
|
|
@@ -206,17 +207,25 @@ function cacheDiagnosis(previous, messages, tools, usage) {
|
|
|
206
207
|
* prompt unchanged from step to step, a load adds to the tool array — which every request sends
|
|
207
208
|
* in the stable order `toolOrder` asks for — and
|
|
208
209
|
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
209
|
-
* preselection shapes the first step
|
|
210
|
-
* because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
|
210
|
+
* preselection shapes the first step unless `preselectRouting` says to append it. A turn cut off
|
|
211
|
+
* at `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
|
211
212
|
* continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
|
|
212
|
-
* cache compared against the request before it
|
|
213
|
+
* cache compared against the request before it and the request broken down by what filled it; see
|
|
214
|
+
* `TurnUsage` and `runMetrics`.
|
|
215
|
+
*
|
|
216
|
+
* Each step's reported prompt goes on a token ledger, in the same epoch as the step before where
|
|
217
|
+
* the request only appended to it and in a new one where it did not — a `beforeStep` that rewrote
|
|
218
|
+
* the transcript, a tool array a load grew — so that what the messages between two steps cost can
|
|
219
|
+
* be read back as a subtraction. The prompt recorded is the first request's, before any
|
|
220
|
+
* continuation, and a step that reported none, or reported a cache count above its prompt, is not
|
|
221
|
+
* recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
|
|
213
222
|
*
|
|
214
223
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
215
224
|
* and cancellation. See `AgentLoopOptions`.
|
|
216
225
|
*/
|
|
217
226
|
export async function runAgentLoop(options) {
|
|
218
227
|
const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
|
|
219
|
-
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, } = options;
|
|
228
|
+
const { onRequest, onTurn, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
|
|
220
229
|
const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
|
|
221
230
|
const started = Date.now();
|
|
222
231
|
// What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
|
|
@@ -272,17 +281,32 @@ export async function runAgentLoop(options) {
|
|
|
272
281
|
maxTokens: hooks.maxTokens,
|
|
273
282
|
})
|
|
274
283
|
: { context: "", notes: [] };
|
|
284
|
+
// Read once, so what the result hands back is what every step of this run said.
|
|
285
|
+
const preface = hooks?.preface ?? configureHooks().preface;
|
|
275
286
|
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
276
287
|
const toolCalls = [];
|
|
277
288
|
const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
|
|
278
289
|
let previous;
|
|
290
|
+
let ledger = options.ledger ?? [];
|
|
291
|
+
// The request the ledger's last entry came from, which is what the next one is measured against.
|
|
292
|
+
// Nothing before this run's first: an earlier run's head is not something the loop can see.
|
|
293
|
+
let measured;
|
|
279
294
|
for (let step = 0; step < config.maxToolIterations; step++) {
|
|
280
295
|
// A stop aborts the request in flight, but a tool call already handed off runs to its own
|
|
281
296
|
// end — so the signal is read between steps as well.
|
|
282
297
|
signal?.throwIfAborted();
|
|
283
|
-
|
|
298
|
+
const before = messages;
|
|
299
|
+
messages =
|
|
300
|
+
(await beforeStep?.(messages, step, {
|
|
301
|
+
...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
|
|
302
|
+
limit: config.contextLength ?? 0,
|
|
303
|
+
ledger,
|
|
304
|
+
})) ?? messages;
|
|
305
|
+
ledger = rebaseLedger(ledger, before, messages);
|
|
284
306
|
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
285
|
-
|
|
307
|
+
// Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
|
|
308
|
+
// first step needs nothing of its own.
|
|
309
|
+
const routed = preselectRouting !== "append" && preselected.length > 0 && step === 0;
|
|
286
310
|
// Ordered here rather than left to `buildBody`, so `names` below is what the request actually
|
|
287
311
|
// declared — a diagnosis reading an order the server never saw calls an untouched tool array
|
|
288
312
|
// `tools-changed`.
|
|
@@ -293,11 +317,12 @@ export async function runAgentLoop(options) {
|
|
|
293
317
|
: tools, toolOrder);
|
|
294
318
|
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
295
319
|
// away the cache for the whole transcript. What is loaded is said in `declared` and in the
|
|
296
|
-
// `load_tools` result instead. The preselected first step is the one exception, by design
|
|
320
|
+
// `load_tools` result instead. The preselected first step is the one exception, by design,
|
|
321
|
+
// and `preselectRouting: "append"` is the way out of it.
|
|
297
322
|
const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog)}`.trim() : system;
|
|
298
323
|
const request = [
|
|
299
324
|
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
300
|
-
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context,
|
|
325
|
+
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, preface),
|
|
301
326
|
];
|
|
302
327
|
const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
|
|
303
328
|
const turnOptions = {
|
|
@@ -315,15 +340,37 @@ export async function runAgentLoop(options) {
|
|
|
315
340
|
onThinking: (text) => onEvent({ kind: "thinking", text }),
|
|
316
341
|
onOutput: (text) => onEvent({ kind: "output", text }),
|
|
317
342
|
};
|
|
343
|
+
// Built the way `negotiate` is about to build the first attempt — the same flags, the same
|
|
344
|
+
// model's refusals — so what the host is told is what is sent unless something is refused.
|
|
345
|
+
if (onRequest) {
|
|
346
|
+
const opening = build(supports, modelCapabilitiesFor(supports, config.model));
|
|
347
|
+
onRequest({ messages: opening.messages, tools: opening.tools ?? [], step });
|
|
348
|
+
}
|
|
318
349
|
const first = await runTurn(client, supports, build, turnOptions);
|
|
319
350
|
const names = declared.map((tool) => (tool.type === "function" ? tool.function.name : ""));
|
|
320
351
|
// Compared before any continuation is joined on: the cache a request meets is the one its own
|
|
321
352
|
// prompt found, and a continuation's prompt is this request's plus the reply so far.
|
|
353
|
+
// The breakdown measures the array `toolSchemaTokens` does, so where no prompt was reported
|
|
354
|
+
// the two give one number for the tool block rather than two.
|
|
355
|
+
const charsPerToken = charsPerTokenFor(supports, config.model);
|
|
322
356
|
Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
|
|
323
357
|
toolsDeclared: declared.length,
|
|
324
|
-
toolSchemaTokens: Math.ceil(toolsChars(declared) /
|
|
358
|
+
toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerToken),
|
|
359
|
+
context: contextTokens({ model: config.model, stream: true, messages: request, tools: declared }, { charsPerToken, promptTokens: first.usage.prompt }),
|
|
325
360
|
});
|
|
326
361
|
const firstPrompt = first.usage.prompt;
|
|
362
|
+
// Recorded from the first request for the same reason. A cache count above the prompt is a
|
|
363
|
+
// server reporting its prompt net of the cache, whose differences would measure nothing.
|
|
364
|
+
const sent = {
|
|
365
|
+
messages: request,
|
|
366
|
+
tools: names,
|
|
367
|
+
prompt: first.usage.cached > firstPrompt ? 0 : firstPrompt,
|
|
368
|
+
through: messages.length - 1,
|
|
369
|
+
};
|
|
370
|
+
const entered = recordRequest(ledger, measured, sent);
|
|
371
|
+
if (entered !== ledger)
|
|
372
|
+
measured = sent;
|
|
373
|
+
ledger = entered;
|
|
327
374
|
const turn = maxContinuations > 0
|
|
328
375
|
? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
|
|
329
376
|
: first;
|
|
@@ -342,6 +389,7 @@ export async function runAgentLoop(options) {
|
|
|
342
389
|
totalTokens: usage.total,
|
|
343
390
|
cachedTokens: usage.cached,
|
|
344
391
|
turn: { ...turn.usage, finishReason: turn.finishReason },
|
|
392
|
+
ledger,
|
|
345
393
|
},
|
|
346
394
|
});
|
|
347
395
|
if (turn.finishReason === "length") {
|
|
@@ -391,11 +439,12 @@ export async function runAgentLoop(options) {
|
|
|
391
439
|
: {}),
|
|
392
440
|
});
|
|
393
441
|
if (!calls.length) {
|
|
442
|
+
let afterTurn = Promise.resolve([]);
|
|
394
443
|
if (hooks) {
|
|
395
444
|
const at = question ? messages.indexOf(question) : -1;
|
|
396
445
|
// Not awaited: the answer is ready, and remembering it is not something to hold it for.
|
|
397
|
-
//
|
|
398
|
-
|
|
446
|
+
// Handed back instead, for a host that wants its notes.
|
|
447
|
+
afterTurn = notify(hooks.run, "afterTurn", {
|
|
399
448
|
...hooks.context,
|
|
400
449
|
reply: turn.content,
|
|
401
450
|
...(at >= 0
|
|
@@ -417,12 +466,16 @@ export async function runAgentLoop(options) {
|
|
|
417
466
|
loaded: [...loaded],
|
|
418
467
|
used: [...used],
|
|
419
468
|
notes: gathered.notes,
|
|
469
|
+
context: gathered.context,
|
|
470
|
+
preface,
|
|
471
|
+
afterTurn,
|
|
420
472
|
metrics: {
|
|
421
473
|
...metrics,
|
|
422
474
|
...(onDemand ? loads : {}),
|
|
423
475
|
wallMs: Date.now() - started,
|
|
424
476
|
outcome: turn.finishReason === "length" ? "truncated" : "answered",
|
|
425
477
|
},
|
|
478
|
+
ledger,
|
|
426
479
|
};
|
|
427
480
|
}
|
|
428
481
|
// Per step, not per run: the answer to a call made two steps ago was true before the tools in
|
|
@@ -430,7 +483,11 @@ export async function runAgentLoop(options) {
|
|
|
430
483
|
const answered = new Map();
|
|
431
484
|
const run = async ({ call, args, error: unreadable, normal }) => {
|
|
432
485
|
const { name, arguments: raw } = call.function;
|
|
433
|
-
|
|
486
|
+
// Built for every call rather than only the dispatched ones, so a host hears of the calls
|
|
487
|
+
// the loop answers itself in the same shape. Arguments that could not be read are none.
|
|
488
|
+
const request = { id: call.id, name, args: args ?? {}, raw };
|
|
489
|
+
onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
|
|
490
|
+
onToolCall?.(request);
|
|
434
491
|
let content;
|
|
435
492
|
let ok = true;
|
|
436
493
|
try {
|
|
@@ -452,7 +509,6 @@ export async function runAgentLoop(options) {
|
|
|
452
509
|
if (onDemand && inCatalog(catalog, name))
|
|
453
510
|
loaded.add(name);
|
|
454
511
|
used.add(name);
|
|
455
|
-
const request = { id: call.id, name, args, raw };
|
|
456
512
|
content = dedupable(request)
|
|
457
513
|
? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
|
|
458
514
|
: await dispatch(request, signal);
|
|
@@ -464,8 +520,10 @@ export async function runAgentLoop(options) {
|
|
|
464
520
|
content = errorMessage(error);
|
|
465
521
|
ok = false;
|
|
466
522
|
}
|
|
467
|
-
onEvent({ kind: "tool-result", name, ok, text: preview(content) });
|
|
468
|
-
|
|
523
|
+
onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
|
|
524
|
+
const result = { id: call.id, name, ok, content };
|
|
525
|
+
onToolResult?.(result);
|
|
526
|
+
return result;
|
|
469
527
|
};
|
|
470
528
|
const outcomes = [];
|
|
471
529
|
if (parallel) {
|
|
@@ -479,7 +537,7 @@ export async function runAgentLoop(options) {
|
|
|
479
537
|
}
|
|
480
538
|
}
|
|
481
539
|
for (const { id, name, ok, content } of outcomes) {
|
|
482
|
-
toolCalls.push({ name, ok });
|
|
540
|
+
toolCalls.push({ id, name, ok });
|
|
483
541
|
messages.push({ role: "tool", tool_call_id: id, content });
|
|
484
542
|
}
|
|
485
543
|
}
|
package/dist/compaction.d.ts
CHANGED
|
@@ -62,6 +62,17 @@ export interface CompactionOptions {
|
|
|
62
62
|
compactAt?: number;
|
|
63
63
|
/** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
|
|
64
64
|
keepRatio?: number;
|
|
65
|
+
/**
|
|
66
|
+
* The fraction of `limit` the whole request should come down to, in place of `keepRatio`: the
|
|
67
|
+
* cut is the earliest that leaves `used`, less what is folded, plus `summaryTokens`, at or under
|
|
68
|
+
* it. Absent, or not above zero, plans by `keepRatio` as before.
|
|
69
|
+
*/
|
|
70
|
+
target?: number;
|
|
71
|
+
/**
|
|
72
|
+
* What the summary is assumed to cost when planning to a `target` — the ceiling its writer is
|
|
73
|
+
* held to, since the plan is made before it is written. 1024 by default, which is `summariser`'s.
|
|
74
|
+
*/
|
|
75
|
+
summaryTokens?: number;
|
|
65
76
|
/** One message's tokens. `messageTokens` by default, divided by `charsPerToken`. */
|
|
66
77
|
estimate?: (message: Message) => number;
|
|
67
78
|
/**
|
|
@@ -91,6 +102,12 @@ export interface CompactionPlan {
|
|
|
91
102
|
toSummarise: Message[];
|
|
92
103
|
/** The summary an earlier compaction left, which this one continues. */
|
|
93
104
|
previous?: string;
|
|
105
|
+
/**
|
|
106
|
+
* What the request is expected to cost once folded, in tokens: what was in use, less what is
|
|
107
|
+
* summarised, plus the summary's ceiling. Only on a plan made to a `target`, and above the
|
|
108
|
+
* target where no legal cut reaches it.
|
|
109
|
+
*/
|
|
110
|
+
after?: number;
|
|
94
111
|
}
|
|
95
112
|
/**
|
|
96
113
|
* Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
|
|
@@ -107,11 +124,20 @@ export interface CompactionPlan {
|
|
|
107
124
|
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
108
125
|
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
109
126
|
*
|
|
127
|
+
* `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
|
|
128
|
+
* sit in the window on top of it uncounted. Given a `target` the plan is made against what the
|
|
129
|
+
* request will be afterwards instead: the cut is the first user message at which `used`, less the
|
|
130
|
+
* folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
|
|
131
|
+
* cut gets there the plan folds up to the last user message, which is as near as a legal one
|
|
132
|
+
* comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
|
|
133
|
+
* credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
|
|
134
|
+
* `estimateFrom` for one that is measured.
|
|
135
|
+
*
|
|
110
136
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
111
137
|
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
112
138
|
* `CompactionOptions`.
|
|
113
139
|
*/
|
|
114
|
-
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
140
|
+
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, target, summaryTokens, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
115
141
|
/**
|
|
116
142
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
117
143
|
* message as its role and at most 4000 characters of its text.
|
package/dist/compaction.js
CHANGED
|
@@ -5,6 +5,8 @@ import { ask } from "./side-task.js";
|
|
|
5
5
|
export const COMPACT_AT = 0.75;
|
|
6
6
|
/** The fraction of the window the kept tail may fill, leaving room for the run to grow again. */
|
|
7
7
|
export const KEEP_RATIO = 0.35;
|
|
8
|
+
/** The ceiling `summariser` gives a summary, and what `planCompaction` assumes one will cost. */
|
|
9
|
+
const SUMMARY_TOKENS = 1024;
|
|
8
10
|
/** How much of any one message the summariser is shown. A pasted file is not worth it whole. */
|
|
9
11
|
const SUMMARY_SLICE = 4000;
|
|
10
12
|
/**
|
|
@@ -104,11 +106,20 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
104
106
|
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
105
107
|
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
106
108
|
*
|
|
109
|
+
* `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
|
|
110
|
+
* sit in the window on top of it uncounted. Given a `target` the plan is made against what the
|
|
111
|
+
* request will be afterwards instead: the cut is the first user message at which `used`, less the
|
|
112
|
+
* folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
|
|
113
|
+
* cut gets there the plan folds up to the last user message, which is as near as a legal one
|
|
114
|
+
* comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
|
|
115
|
+
* credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
|
|
116
|
+
* `estimateFrom` for one that is measured.
|
|
117
|
+
*
|
|
107
118
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
108
119
|
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
109
120
|
* `CompactionOptions`.
|
|
110
121
|
*/
|
|
111
|
-
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
|
|
122
|
+
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, target, summaryTokens = SUMMARY_TOKENS, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
|
|
112
123
|
if (!(limit > 0))
|
|
113
124
|
return undefined;
|
|
114
125
|
const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
|
|
@@ -117,20 +128,42 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
|
|
|
117
128
|
const head = systemHead(messages);
|
|
118
129
|
const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
|
|
119
130
|
const previous = givenPrevious ?? head.previous;
|
|
120
|
-
const budget = limit * keepRatio;
|
|
121
|
-
let kept = 0;
|
|
122
131
|
let cut = messages.length;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
let after;
|
|
133
|
+
if (target !== undefined && target > 0) {
|
|
134
|
+
// Forward from the head, so the first cut that reaches the target is the one that folds least.
|
|
135
|
+
let folded = 0;
|
|
136
|
+
for (let at = from; at < messages.length; at++) {
|
|
137
|
+
if (messages[at].role === "user") {
|
|
138
|
+
cut = at;
|
|
139
|
+
after = cost - folded + summaryTokens;
|
|
140
|
+
if (after <= limit * target)
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
folded += estimate(messages[at]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const budget = limit * keepRatio;
|
|
148
|
+
let kept = 0;
|
|
149
|
+
for (let at = messages.length - 1; at > from; at--) {
|
|
150
|
+
kept += estimate(messages[at]);
|
|
151
|
+
if (kept > budget)
|
|
152
|
+
break;
|
|
153
|
+
cut = at;
|
|
154
|
+
}
|
|
155
|
+
while (cut < messages.length && messages[cut].role !== "user")
|
|
156
|
+
cut++;
|
|
128
157
|
}
|
|
129
|
-
while (cut < messages.length && messages[cut].role !== "user")
|
|
130
|
-
cut++;
|
|
131
158
|
if (cut >= messages.length || cut - from < 2)
|
|
132
159
|
return undefined;
|
|
133
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
from,
|
|
162
|
+
cut,
|
|
163
|
+
toSummarise: messages.slice(from, cut),
|
|
164
|
+
...(previous ? { previous } : {}),
|
|
165
|
+
...(after === undefined ? {} : { after: Math.round(after) }),
|
|
166
|
+
};
|
|
134
167
|
}
|
|
135
168
|
/**
|
|
136
169
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
@@ -158,7 +191,7 @@ export function summaryInput(plan) {
|
|
|
158
191
|
* @param options Cancellation and notices; the ceiling is 1024 and the instruction
|
|
159
192
|
* `SUMMARY_PROMPT` unless given.
|
|
160
193
|
*/
|
|
161
|
-
export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens =
|
|
194
|
+
export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = SUMMARY_TOKENS, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
|
|
162
195
|
/**
|
|
163
196
|
* The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
|
|
164
197
|
*
|
package/dist/events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TokenLedger } from "./ledger.ts";
|
|
1
2
|
import type { TurnUsage } from "./stream.ts";
|
|
2
3
|
/**
|
|
3
4
|
* What a run is doing, while it is doing it.
|
|
@@ -76,9 +77,9 @@ export type RunEventKind =
|
|
|
76
77
|
| "thinking"
|
|
77
78
|
/** Reply tokens, as they arrive. */
|
|
78
79
|
| "output"
|
|
79
|
-
/** The model asked for a tool, with the arguments it chose. */
|
|
80
|
+
/** The model asked for a tool, with the arguments it chose. `id` is the call's. */
|
|
80
81
|
| "tool-call"
|
|
81
|
-
/** A tool came back, with what it said. */
|
|
82
|
+
/** A tool came back, with what it said. `id` is the id of the call it answers. */
|
|
82
83
|
| "tool-result"
|
|
83
84
|
/** Something the runner did that is not the model's doing — a preselection, a retry. */
|
|
84
85
|
| "notice"
|
|
@@ -110,6 +111,12 @@ export interface RunUsage {
|
|
|
110
111
|
* from a caller emitting usage of its own.
|
|
111
112
|
*/
|
|
112
113
|
turn?: TurnReport;
|
|
114
|
+
/**
|
|
115
|
+
* The run's token ledger as of this turn, whole rather than the entry it added — a host whose
|
|
116
|
+
* run then fails still holds the latest one, and a rewrite in `beforeStep` moves earlier entries.
|
|
117
|
+
* Absent from a caller emitting usage of its own.
|
|
118
|
+
*/
|
|
119
|
+
ledger?: TokenLedger;
|
|
113
120
|
}
|
|
114
121
|
/** One turn's usage and measurements as a `usage` event carries them. */
|
|
115
122
|
export interface TurnReport extends TurnUsage {
|
|
@@ -119,8 +126,8 @@ export interface TurnReport extends TurnUsage {
|
|
|
119
126
|
/**
|
|
120
127
|
* One thing that happened in a run, as a watcher receives it.
|
|
121
128
|
*
|
|
122
|
-
* Every field is always present — the empty ones are `""` or `null` rather than
|
|
123
|
-
* client reads it without guarding each key.
|
|
129
|
+
* Every field but `id` is always present — the empty ones are `""` or `null` rather than
|
|
130
|
+
* missing — so a client reads it without guarding each key.
|
|
124
131
|
*/
|
|
125
132
|
export interface RunEvent {
|
|
126
133
|
/** The run it belongs to. `emit` fills this in; a caller does not pass it. */
|
|
@@ -141,6 +148,12 @@ export interface RunEvent {
|
|
|
141
148
|
text: string;
|
|
142
149
|
/** Tool name on the tool kinds, otherwise empty. */
|
|
143
150
|
name: string;
|
|
151
|
+
/**
|
|
152
|
+
* The call's id on the tool kinds, which pairs a `tool-result` with its `tool-call` when one
|
|
153
|
+
* tool is called twice in a step. The one field `emit` does not fill in: it is there only where
|
|
154
|
+
* the emitter gave one, and optional so an event built before it existed still compiles.
|
|
155
|
+
*/
|
|
156
|
+
id?: string;
|
|
144
157
|
/**
|
|
145
158
|
* The caller's own flow step this happened inside, so a watcher can group a run the way the
|
|
146
159
|
* work is written. Empty for events that belong to the run rather than to any one step, and
|
package/dist/hooks.d.ts
CHANGED
|
@@ -204,11 +204,17 @@ export declare function assembleContext(outcomes: readonly HookOutcome[], maxTok
|
|
|
204
204
|
* system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
|
|
205
205
|
* back: a host that stores what the user typed never remembers the context as something they said.
|
|
206
206
|
*
|
|
207
|
+
* A question keeps its context for as long as it is sent. A host stores `context`, and the preface
|
|
208
|
+
* it was said under, beside the question and calls this for each stored one on every later request
|
|
209
|
+
* — a question sent with its blocks one turn and bare the next is a different prompt from that
|
|
210
|
+
* message on, and loses the server's prefix cache behind it.
|
|
211
|
+
*
|
|
207
212
|
* @param history The request's messages. Neither the array nor any message in it is changed.
|
|
208
|
-
* @param index Where
|
|
213
|
+
* @param index Where the question sits in `history` — which is not where it sits in the
|
|
209
214
|
* session once a compaction has folded the head into a summary. Anything but a user message there
|
|
210
215
|
* leaves the request as it was.
|
|
211
|
-
* @param context What `assembleContext` built
|
|
216
|
+
* @param context What `assembleContext` built, this turn or the turn the question was asked.
|
|
217
|
+
* Empty returns `history` itself.
|
|
212
218
|
* @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
|
|
213
219
|
* unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
|
|
214
220
|
* @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
|
package/dist/hooks.js
CHANGED
|
@@ -128,11 +128,17 @@ export function assembleContext(outcomes, maxTokens) {
|
|
|
128
128
|
* system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
|
|
129
129
|
* back: a host that stores what the user typed never remembers the context as something they said.
|
|
130
130
|
*
|
|
131
|
+
* A question keeps its context for as long as it is sent. A host stores `context`, and the preface
|
|
132
|
+
* it was said under, beside the question and calls this for each stored one on every later request
|
|
133
|
+
* — a question sent with its blocks one turn and bare the next is a different prompt from that
|
|
134
|
+
* message on, and loses the server's prefix cache behind it.
|
|
135
|
+
*
|
|
131
136
|
* @param history The request's messages. Neither the array nor any message in it is changed.
|
|
132
|
-
* @param index Where
|
|
137
|
+
* @param index Where the question sits in `history` — which is not where it sits in the
|
|
133
138
|
* session once a compaction has folded the head into a summary. Anything but a user message there
|
|
134
139
|
* leaves the request as it was.
|
|
135
|
-
* @param context What `assembleContext` built
|
|
140
|
+
* @param context What `assembleContext` built, this turn or the turn the question was asked.
|
|
141
|
+
* Empty returns `history` itself.
|
|
136
142
|
* @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
|
|
137
143
|
* unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
|
|
138
144
|
* @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
10
|
* that differs between one server and the next.
|
|
11
11
|
*/
|
|
12
|
-
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
|
|
12
|
+
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
|
|
14
14
|
export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
15
15
|
export type { CatalogServer } from "./catalog.ts";
|
|
@@ -20,6 +20,7 @@ export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./contin
|
|
|
20
20
|
export { errorMessage } from "./errors.ts";
|
|
21
21
|
export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
|
|
22
22
|
export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
|
|
23
|
+
export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from "./ledger.ts";
|
|
23
24
|
export { resetAll } from "./reset.ts";
|
|
24
25
|
export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
|
|
25
26
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { continueTurn, isContinuable, } from "./continuation.js";
|
|
|
18
18
|
export { errorMessage } from "./errors.js";
|
|
19
19
|
export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
|
|
20
20
|
export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
|
|
21
|
+
export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from "./ledger.js";
|
|
21
22
|
export { resetAll } from "./reset.js";
|
|
22
23
|
export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
|
|
23
24
|
export { runTurn } from "./run-turn.js";
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
import { type TokenEstimateOptions } from "./retry.ts";
|
|
3
|
+
/**
|
|
4
|
+
* What stretches of a transcript cost, read off the prompt counts the server already reported.
|
|
5
|
+
*
|
|
6
|
+
* A calibrated divisor fixes the average and nothing else: a transcript is tool results, JSON
|
|
7
|
+
* arguments, code and prose, each at its own ratio, plus whatever the chat template adds per
|
|
8
|
+
* message. But a request that only appended to the one before it differs from it by exactly the
|
|
9
|
+
* cost of what was appended, so two reported prompts and a subtraction say what no estimate can —
|
|
10
|
+
* the template's tokens and a tool result's real density included, and reasoning, which is never
|
|
11
|
+
* replayed, left out.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is a pure function over a plain array, so a host with its own loop keeps a ledger
|
|
14
|
+
* the same way `runAgentLoop` does, and stores it however it stores the transcript.
|
|
15
|
+
*/
|
|
16
|
+
type Message = OpenAI.ChatCompletionMessageParam;
|
|
17
|
+
/**
|
|
18
|
+
* One request's reported prompt, and how far into the transcript that request reached.
|
|
19
|
+
*
|
|
20
|
+
* Three numbers and nothing else, so it serialises as it is: a host that stores a row per message
|
|
21
|
+
* keeps `prompt` and `epoch` in two columns on the row `through` names, and one that stores a
|
|
22
|
+
* session keeps the array beside it.
|
|
23
|
+
*/
|
|
24
|
+
export interface LedgerEntry {
|
|
25
|
+
/**
|
|
26
|
+
* The index, in the host's transcript, of the last message the request carried. A system prompt
|
|
27
|
+
* the host keeps outside the transcript is not counted, and −1 is a request that carried none of
|
|
28
|
+
* the transcript at all.
|
|
29
|
+
*/
|
|
30
|
+
through: number;
|
|
31
|
+
/** The prompt tokens the endpoint reported for that request, everything ahead of it included. */
|
|
32
|
+
prompt: number;
|
|
33
|
+
/**
|
|
34
|
+
* Which unbroken run of appending the request belongs to. Two entries with the same epoch shared
|
|
35
|
+
* everything up to the earlier one's last message, so their prompts subtract; two with different
|
|
36
|
+
* epochs did not, and the difference between them means nothing.
|
|
37
|
+
*/
|
|
38
|
+
epoch: number;
|
|
39
|
+
}
|
|
40
|
+
/** A transcript's entries in the order their requests were sent, which is also by `through`. */
|
|
41
|
+
export type TokenLedger = LedgerEntry[];
|
|
42
|
+
/** What `recordRequest` needs to know of one request. */
|
|
43
|
+
export interface LedgerRequest {
|
|
44
|
+
/** The messages as they were sent, a system prompt and any context a hook added included. */
|
|
45
|
+
messages: Message[];
|
|
46
|
+
/** The names of the tools declared, in the order they were sent. Absent is none. */
|
|
47
|
+
tools?: readonly string[];
|
|
48
|
+
/** The prompt tokens the endpoint reported. Zero or less is no report, and records nothing. */
|
|
49
|
+
prompt: number;
|
|
50
|
+
/** The index, in the host's transcript, of the last message the request carried. */
|
|
51
|
+
through: number;
|
|
52
|
+
}
|
|
53
|
+
/** What `tokensBetween` and `estimateFrom` take besides the ledger and the transcript. */
|
|
54
|
+
export interface LedgerEstimateOptions extends TokenEstimateOptions {
|
|
55
|
+
/**
|
|
56
|
+
* One unmeasured message's tokens. `messageTokens` by default, divided by `charsPerToken` — the
|
|
57
|
+
* calibrated estimate, given `charsPerTokenFor` the model.
|
|
58
|
+
*/
|
|
59
|
+
estimate?: (message: Message) => number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The ledger with one more request on it, in the epoch the request before it says it belongs to.
|
|
63
|
+
*
|
|
64
|
+
* The epoch carries on only where the new request declared the same tools in the same order and
|
|
65
|
+
* sent every earlier message unchanged — the case in which the two prompts subtract. Anything else
|
|
66
|
+
* starts a new one: a fold, a prune, a system prompt that moved, and a tool array that grew, whose
|
|
67
|
+
* schema would otherwise be counted as the cost of the messages beside it. A request that reported
|
|
68
|
+
* no prompt records nothing, and neither does one that reaches no further than the last entry in
|
|
69
|
+
* the same epoch, so a retry or a continuation leaves the first reading standing.
|
|
70
|
+
*
|
|
71
|
+
* @param ledger The ledger so far. Not written to; handed back as it is when nothing is recorded.
|
|
72
|
+
* @param previous The request the ledger's last entry was recorded from — not merely the last one
|
|
73
|
+
* sent, so that a request which reported nothing is skipped over rather than compared against.
|
|
74
|
+
* `undefined` where that is not known, which starts a new epoch.
|
|
75
|
+
* @param next The request just answered.
|
|
76
|
+
*/
|
|
77
|
+
export declare function recordRequest(ledger: TokenLedger, previous: LedgerRequest | undefined, next: LedgerRequest): TokenLedger;
|
|
78
|
+
/**
|
|
79
|
+
* The ledger for a transcript that has been rewritten, keeping the boundaries the rewrite left
|
|
80
|
+
* standing.
|
|
81
|
+
*
|
|
82
|
+
* A difference inside an epoch stays true after the epoch ends, since the prefix the two requests
|
|
83
|
+
* shared cancels whatever became of it. So the entries that fall in the part of the transcript
|
|
84
|
+
* still at its head keep their place, the ones in the part still at its tail move with it, and
|
|
85
|
+
* only the ones in between — the messages a fold removed or a prune stubbed — are dropped. The
|
|
86
|
+
* tail's entries move to a later epoch as well, so no difference is read across the rewrite.
|
|
87
|
+
*
|
|
88
|
+
* @param ledger The ledger for `before`. Not written to.
|
|
89
|
+
* @param before The transcript the ledger's indexes are in.
|
|
90
|
+
* @param after The transcript that replaces it. One that only appended to `before`, or is the same
|
|
91
|
+
* array, hands the ledger back as it is.
|
|
92
|
+
*/
|
|
93
|
+
export declare function rebaseLedger(ledger: TokenLedger, before: readonly Message[], after: readonly Message[]): TokenLedger;
|
|
94
|
+
/**
|
|
95
|
+
* What the messages from `from` up to, not including, `to` cost the window, measured where the
|
|
96
|
+
* ledger can say and estimated where it cannot.
|
|
97
|
+
*
|
|
98
|
+
* Between two boundaries recorded in one epoch it is a subtraction and exact. A boundary falls
|
|
99
|
+
* after a question and after each step's tool results, never after an assistant message, so a
|
|
100
|
+
* stretch that starts or ends inside a group is given that group's measured total divided by
|
|
101
|
+
* character share — the way `contextTokens` divides a reported prompt — and messages no pair of
|
|
102
|
+
* entries covers fall back to the estimate: the ones before the first entry, the group either side
|
|
103
|
+
* of an epoch's end, and whatever has not been sent yet.
|
|
104
|
+
*
|
|
105
|
+
* A difference of zero or less is not trusted and is estimated instead. That is how a server
|
|
106
|
+
* reporting `prompt_tokens` net of its cache would show up, though one whose net counts still
|
|
107
|
+
* happened to rise would not be caught by it.
|
|
108
|
+
*
|
|
109
|
+
* @param ledger The ledger kept for `messages`.
|
|
110
|
+
* @param from The first message counted. Below zero counts from the start.
|
|
111
|
+
* @param to The first message not counted, as `slice` takes it. Past the end counts to the end.
|
|
112
|
+
* @param messages The transcript the ledger's indexes are in.
|
|
113
|
+
* @param options The estimate for what is not measured: a divisor, or a function of the caller's.
|
|
114
|
+
* @returns Whole tokens, rounded once over the stretch rather than per message.
|
|
115
|
+
*/
|
|
116
|
+
export declare function tokensBetween(ledger: TokenLedger, from: number, to: number, messages: readonly Message[], options?: LedgerEstimateOptions): number;
|
|
117
|
+
/**
|
|
118
|
+
* A per-message cost for `planCompaction`'s `estimate`: the measured share where the ledger covers
|
|
119
|
+
* the message, the calibrated estimate where it does not.
|
|
120
|
+
*
|
|
121
|
+
* The planner asks about one message at a time, and nothing reports anything that fine, so a
|
|
122
|
+
* measured message is given its group's total by character share. The shares are not rounded,
|
|
123
|
+
* which is what makes a whole group of them add back up to the difference the server reported.
|
|
124
|
+
*
|
|
125
|
+
* @param ledger The ledger kept for `messages`.
|
|
126
|
+
* @param messages The transcript the plan will be made for. The function it returns knows a
|
|
127
|
+
* message by identity, so it has to be asked about these objects and not copies of them; one it
|
|
128
|
+
* does not know is estimated.
|
|
129
|
+
* @param options The estimate for what is not measured: a divisor, or a function of the caller's.
|
|
130
|
+
*/
|
|
131
|
+
export declare function estimateFrom(ledger: TokenLedger, messages: readonly Message[], options?: LedgerEstimateOptions): (message: Message) => number;
|
|
132
|
+
export {};
|