@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.
@@ -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 { toolsChars } from "./retry.js";
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. A turn cut off at `maxTokens` is said so as a notice,
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; see `TurnUsage` and `runMetrics`.
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
- messages = (await beforeStep?.(messages, step)) ?? messages;
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
- const routed = preselected.length > 0 && step === 0;
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, hooks?.preface),
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) / charsPerTokenFor(supports, config.model)),
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
- // `notify` never rejects.
398
- void notify(hooks.run, "afterTurn", {
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
- onEvent({ kind: "tool-call", name, text: preview(raw) });
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
- return { id: call.id, name, ok, content };
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
  }
@@ -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.
@@ -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
- for (let at = messages.length - 1; at > from; at--) {
124
- kept += estimate(messages[at]);
125
- if (kept > budget)
126
- break;
127
- cut = at;
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 { from, cut, toSummarise: messages.slice(from, cut), ...(previous ? { previous } : {}) };
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 = 1024, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
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 missing — so a
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 this turn's question sits in `history` — which is not where it sits in the
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. Empty returns `history` itself.
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 this turn's question sits in `history` — which is not where it sits in the
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. Empty returns `history` itself.
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";
@@ -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 {};